feat: utilitaires client heicConverter, bgRemover, imageRotator, zipImporter (TS)
Porte les 4 utilitaires de traitement d'images en TypeScript pour utilisation côté client : - heicConverter.ts : convertit HEIC/HEIF en JPEG via heic2any - bgRemover.ts : supprime les fonds d'images avec @imgly/background-removal + composite sur blanc - imageRotator.ts : pivote les images JPEG via Canvas - zipImporter.ts : extrait les images d'un fichier ZIP avec structure dossier-ref/image.ext Tous les modules utilisent des APIs du navigateur et doivent être importés dynamiquement depuis du code 'use client'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
const CDN_VERSION = '1.7.0'
|
||||||
|
const CDN_URL = `https://cdn.jsdelivr.net/npm/@imgly/background-removal@${CDN_VERSION}/dist/index.mjs`
|
||||||
|
|
||||||
|
let _removeBg: ((blob: Blob, opts: Record<string, unknown>) => Promise<Blob>) | null = null
|
||||||
|
|
||||||
|
async function loadLib() {
|
||||||
|
if (_removeBg) return _removeBg
|
||||||
|
const mod = await import(/* webpackIgnore: true */ CDN_URL)
|
||||||
|
_removeBg = mod.removeBackground
|
||||||
|
return _removeBg!
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressEvent {
|
||||||
|
key: string
|
||||||
|
percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime le fond d'un Blob image via @imgly IA.
|
||||||
|
* Retourne un PNG avec fond transparent (à combiner avec compositeOnWhite).
|
||||||
|
*/
|
||||||
|
export async function removeBackgroundRaw(
|
||||||
|
blob: Blob,
|
||||||
|
onProgress?: (e: ProgressEvent) => void,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const removeBg = await loadLib()
|
||||||
|
|
||||||
|
return removeBg(blob, {
|
||||||
|
output: { format: 'image/png', type: 'foreground', quality: 1 },
|
||||||
|
progress: (key: string, current: number, total: number) => {
|
||||||
|
if (onProgress && total > 0) {
|
||||||
|
onProgress({ key, percent: Math.round((current / total) * 100) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite un PNG transparent sur fond blanc avec feather optionnel.
|
||||||
|
* @param pngBlob — PNG avec transparence (résultat de removeBackgroundRaw)
|
||||||
|
* @param feather — récupération des bords en px (0 = strict, 5 = doux)
|
||||||
|
*/
|
||||||
|
export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob> {
|
||||||
|
const objectUrl = URL.createObjectURL(pngBlob)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image()
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const { width, height } = img
|
||||||
|
|
||||||
|
const fgCanvas = document.createElement('canvas')
|
||||||
|
fgCanvas.width = width
|
||||||
|
fgCanvas.height = height
|
||||||
|
const fgCtx = fgCanvas.getContext('2d')!
|
||||||
|
|
||||||
|
fgCtx.drawImage(img, 0, 0)
|
||||||
|
|
||||||
|
if (feather > 0) {
|
||||||
|
fgCtx.globalCompositeOperation = 'destination-over'
|
||||||
|
fgCtx.filter = `blur(${feather}px)`
|
||||||
|
fgCtx.drawImage(img, 0, 0)
|
||||||
|
fgCtx.filter = 'none'
|
||||||
|
fgCtx.globalCompositeOperation = 'source-over'
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = document.createElement('canvas')
|
||||||
|
out.width = width
|
||||||
|
out.height = height
|
||||||
|
const ctx = out.getContext('2d')!
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
ctx.drawImage(fgCanvas, 0, 0)
|
||||||
|
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
out.toBlob(
|
||||||
|
(b) => (b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'))),
|
||||||
|
'image/jpeg',
|
||||||
|
0.92,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
reject(new Error('Impossible de charger le PNG'))
|
||||||
|
}
|
||||||
|
img.src = objectUrl
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Convertit un Blob HEIC/HEIF en Blob JPEG via heic2any.
|
||||||
|
* À utiliser uniquement côté client (navigateur).
|
||||||
|
*/
|
||||||
|
export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
|
||||||
|
if (!(blob instanceof Blob)) {
|
||||||
|
throw new TypeError('L\'entrée doit être un Blob')
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = blob.type.toLowerCase()
|
||||||
|
if (type !== '' && !['image/heic', 'image/heif'].includes(type)) {
|
||||||
|
throw new TypeError(`Format attendu : HEIC/HEIF. Reçu : ${blob.type || '(vide)'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const heic2any = (await import('heic2any')).default
|
||||||
|
|
||||||
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Timeout conversion HEIC (120s)')), 120_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
const conversion = heic2any({ blob, toType: 'image/jpeg', quality: 0.92 })
|
||||||
|
|
||||||
|
const result = await Promise.race([conversion, timeout])
|
||||||
|
const output = Array.isArray(result) ? result[0] : result
|
||||||
|
|
||||||
|
if (!(output instanceof Blob)) {
|
||||||
|
throw new Error("heic2any n'a pas retourné un Blob")
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Pivote un Blob JPEG via Canvas.
|
||||||
|
* @param blob — image source (JPEG)
|
||||||
|
* @param degrees — angle en degrés (0, 90, 180, 270)
|
||||||
|
* @returns Blob JPEG pivoté, ou le blob original si degrees === 0
|
||||||
|
*/
|
||||||
|
export function rotateBlob(blob: Blob, degrees: number): Promise<Blob> {
|
||||||
|
const norm = ((degrees % 360) + 360) % 360
|
||||||
|
if (norm === 0) return Promise.resolve(blob)
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image()
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const swap = norm === 90 || norm === 270
|
||||||
|
const w = swap ? img.naturalHeight : img.naturalWidth
|
||||||
|
const h = swap ? img.naturalWidth : img.naturalHeight
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = w
|
||||||
|
canvas.height = h
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
|
||||||
|
ctx.translate(w / 2, h / 2)
|
||||||
|
ctx.rotate((norm * Math.PI) / 180)
|
||||||
|
ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2)
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
canvas.toBlob(
|
||||||
|
(b) => (b ? resolve(b) : reject(new Error('rotateBlob : toBlob a retourné null'))),
|
||||||
|
'image/jpeg',
|
||||||
|
0.92,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
reject(new Error('rotateBlob : impossible de charger le blob'))
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import JSZip from 'jszip'
|
||||||
|
import type { ZipEntry } from '@/types/images'
|
||||||
|
|
||||||
|
const IMAGE_EXTS = new Set(['heic', 'heif', 'jpg', 'jpeg', 'png'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait toutes les images d'un fichier ZIP.
|
||||||
|
* Structure attendue : dossier-ref/image.ext (le nom du dossier = référence produit).
|
||||||
|
*/
|
||||||
|
export async function readImagesFromZip(file: File): Promise<ZipEntry[]> {
|
||||||
|
const zip = await JSZip.loadAsync(file)
|
||||||
|
|
||||||
|
const entries: Array<{ relativePath: string; entry: JSZip.JSZipObject }> = []
|
||||||
|
zip.forEach((relativePath, entry) => {
|
||||||
|
if (!entry.dir) entries.push({ relativePath, entry })
|
||||||
|
})
|
||||||
|
|
||||||
|
const results: ZipEntry[] = []
|
||||||
|
|
||||||
|
for (const { relativePath, entry } of entries) {
|
||||||
|
if (relativePath.startsWith('__MACOSX/')) continue
|
||||||
|
|
||||||
|
const parts = relativePath.split('/').filter((p) => p.length > 0)
|
||||||
|
const filename = parts[parts.length - 1]
|
||||||
|
if (filename.startsWith('.')) continue
|
||||||
|
|
||||||
|
const dotIdx = filename.lastIndexOf('.')
|
||||||
|
if (dotIdx === -1) continue
|
||||||
|
const ext = filename.slice(dotIdx + 1).toLowerCase()
|
||||||
|
if (!IMAGE_EXTS.has(ext)) continue
|
||||||
|
|
||||||
|
const folder = parts.length > 1 ? parts[0] : filename.slice(0, dotIdx)
|
||||||
|
const isHeic = ext === 'heic' || ext === 'heif'
|
||||||
|
const mimeType = isHeic
|
||||||
|
? 'image/heic'
|
||||||
|
: ext === 'jpg' || ext === 'jpeg'
|
||||||
|
? 'image/jpeg'
|
||||||
|
: 'image/png'
|
||||||
|
|
||||||
|
const rawBlob = await entry.async('blob')
|
||||||
|
const blob = new Blob([rawBlob], { type: mimeType })
|
||||||
|
|
||||||
|
results.push({ folder, filename: filename.slice(0, dotIdx), ext, blob, isHeic })
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user