feat: hook useImages — pipeline HEIC→IA→feather→rotation→search→upload

This commit is contained in:
2026-06-08 17:41:00 +02:00
parent 50469700d0
commit 901199c45d
+246
View File
@@ -0,0 +1,246 @@
'use client'
import { useReducer, useCallback } from 'react'
import type { ImageItem, ImageStatus } from '@/types/images'
// ── Reducer ───────────────────────────────────────────────────────────────────
type Action =
| { type: 'ADD'; items: ImageItem[] }
| { type: 'UPDATE'; id: string; patch: Partial<ImageItem> }
| { type: 'REMOVE'; id: string }
function reducer(state: ImageItem[], action: Action): ImageItem[] {
switch (action.type) {
case 'ADD':
return [...state, ...action.items]
case 'UPDATE':
return state.map((item) =>
item.id === action.id ? { ...item, ...action.patch } : item,
)
case 'REMOVE':
return state.filter((item) => item.id !== action.id)
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function refFromFilename(filename: string): string {
const dot = filename.lastIndexOf('.')
return dot === -1 ? filename : filename.slice(0, dot)
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Supprimer le préfixe "data:image/jpeg;base64,"
const base64 = result.split(',')[1]
resolve(base64)
}
reader.onerror = () => reject(new Error('FileReader error'))
reader.readAsDataURL(blob)
})
}
// ── Hook ──────────────────────────────────────────────────────────────────────
export function useImages() {
const [items, dispatch] = useReducer(reducer, [])
const update = useCallback((id: string, patch: Partial<ImageItem>) => {
dispatch({ type: 'UPDATE', id, patch })
}, [])
// Recherche produit Shopify
const searchProduct = useCallback(
async (id: string, ref: string) => {
update(id, { status: 'searching' })
try {
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
const data = await res.json()
if (data.found) {
update(id, {
status: 'ready',
shopifyProductId: data.shopifyId,
shopifyProductTitle: data.title,
})
} else {
update(id, { status: 'not_found' })
}
} catch (err) {
update(id, { status: 'error', error: `Recherche produit : ${(err as Error).message}` })
}
},
[update],
)
// Pipeline complet pour un item (avec ref)
const runPipeline = useCallback(
async (id: string, blob: Blob, ref: string, feather: number, rotation: number) => {
const { convertHeicToJpeg } = await import('@/lib/client/heicConverter')
const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator')
let jpegBlob = blob
if (blob.type === 'image/heic' || blob.type === 'image/heif' || blob.type === '') {
try {
update(id, { status: 'converting' })
jpegBlob = await convertHeicToJpeg(blob)
} catch (err) {
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
return
}
}
let rawPng: Blob
try {
update(id, { status: 'processing', progress: null })
rawPng = await removeBackgroundRaw(jpegBlob, (p) => update(id, { progress: p }))
} catch (err) {
update(id, { status: 'error', error: `Détourage IA : ${(err as Error).message}` })
return
}
let finalBlob: Blob
try {
const composited = await compositeOnWhite(rawPng, feather)
finalBlob = await rotateBlob(composited, rotation)
} catch (err) {
update(id, { status: 'error', error: `Post-traitement : ${(err as Error).message}` })
return
}
update(id, { rawPngBlob: rawPng, processedBlob: finalBlob })
await searchProduct(id, ref)
},
[update, searchProduct],
)
// Recomposite rapide (feather ou rotation changés) — ne relance pas l'IA
const recomposite = useCallback(
async (id: string, rawPngBlob: Blob, feather: number, rotation: number) => {
const { compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator')
try {
const composited = await compositeOnWhite(rawPngBlob, feather)
const finalBlob = await rotateBlob(composited, rotation)
update(id, { processedBlob: finalBlob, feather, rotation })
} catch (err) {
update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` })
}
},
[update],
)
// ── Actions exposées ───────────────────────────────────────────────────────
const addFiles = useCallback(
async (files: File[]) => {
const { readImagesFromZip } = await import('@/lib/client/zipImporter')
for (const file of files) {
if (file.name.toLowerCase().endsWith('.zip')) {
const entries = await readImagesFromZip(file)
const newItems: ImageItem[] = entries.map((entry) => ({
id: crypto.randomUUID(),
filename: `${entry.folder}/${entry.filename}.${entry.ext}`,
ref: entry.folder,
originalBlob: entry.blob,
rawPngBlob: null,
processedBlob: null,
rotation: 0,
feather: 2,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
status: 'pending' as ImageStatus,
error: null,
progress: null,
}))
dispatch({ type: 'ADD', items: newItems })
for (const item of newItems) {
runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation)
}
} else {
const ref = refFromFilename(file.name)
const newItem: ImageItem = {
id: crypto.randomUUID(),
filename: file.name,
ref,
originalBlob: file,
rawPngBlob: null,
processedBlob: null,
rotation: 0,
feather: 2,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
status: 'pending',
error: null,
progress: null,
}
dispatch({ type: 'ADD', items: [newItem] })
runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation)
}
}
},
[runPipeline],
)
const setFeather = useCallback(
(id: string, value: number) => {
const item = items.find((i) => i.id === id)
if (!item || !item.rawPngBlob) return
recomposite(id, item.rawPngBlob, value, item.rotation)
},
[items, recomposite],
)
const rotate = useCallback(
(id: string, direction: 'cw' | 'ccw') => {
const item = items.find((i) => i.id === id)
if (!item || !item.rawPngBlob) return
const delta = direction === 'cw' ? 90 : -90
const newRotation = ((item.rotation + delta) % 360 + 360) % 360
recomposite(id, item.rawPngBlob, item.feather, newRotation)
},
[items, recomposite],
)
const uploadOne = useCallback(
async (id: string) => {
const item = items.find((i) => i.id === id)
if (!item || item.status !== 'ready' || !item.processedBlob || !item.shopifyProductId) return
update(id, { status: 'uploading' })
try {
const base64 = await blobToBase64(item.processedBlob)
const filename = `${item.ref}.jpg`
const res = await fetch('/api/images/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shopifyProductId: item.shopifyProductId, imageBase64: base64, filename }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
update(id, { status: 'uploaded', uploadedUrl: data.url })
} catch (err) {
update(id, { status: 'error', error: `Upload : ${(err as Error).message}` })
}
},
[items, update],
)
const uploadAll = useCallback(() => {
items.filter((i) => i.status === 'ready').forEach((i) => uploadOne(i.id))
}, [items, uploadOne])
const removeItem = useCallback((id: string) => {
dispatch({ type: 'REMOVE', id })
}, [])
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem }
}