'use client' import { useReducer, useCallback, useRef } from 'react' import type { ImageItem, ImageStatus } from '@/types/images' // File d'attente avec concurrence limitée pour le pipeline IA const CONCURRENCY = 2 function createQueue() { let running = 0 const queue: Array<() => Promise> = [] function next() { if (running >= CONCURRENCY || queue.length === 0) return running++ const task = queue.shift()! task().finally(() => { running--; next() }) } return { enqueue(task: () => Promise) { queue.push(task) next() }, } } // ── Reducer ─────────────────────────────────────────────────────────────────── type Action = | { type: 'ADD'; items: ImageItem[] } | { type: 'UPDATE'; id: string; patch: Partial } | { 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('.') const base = dot === -1 ? filename : filename.slice(0, dot) // 1. Supprime le suffixe de numérotation "-N" (ex: "151721-2" → "151721", "29679-G-1" → "29679-G") // 2. Supprime les tirets/espaces résiduels en fin (ex: "27228-GRIS-" → "27228-GRIS", "26774-" → "26774") return base.replace(/-\d+$/, '').replace(/[-_\s]+$/, '') } function blobToBase64(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { const result = reader.result as string 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 queueRef = useRef(createQueue()) const update = useCallback((id: string, patch: Partial) => { dispatch({ type: 'UPDATE', id, patch }) }, []) // Recherche produit Shopify const searchProduct = useCallback( async (id: string, ref: string) => { update(id, { status: 'searching' }) try { const sheetsRes = await fetch(`/api/products/search-sheets?ref=${encodeURIComponent(ref)}`) const sheetsData = await sheetsRes.json() if (sheetsData.found) { update(id, { status: 'converted', shopifyProductId: sheetsData.shopifyId, shopifyProductTitle: sheetsData.title, }) return } const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`) const data = await res.json() if (data.found) { update(id, { status: 'converted', 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 léger : conversion HEIC + recherche produit (sans détourage IA) const runConvertAndSearch = useCallback( async (id: string, blob: Blob, ref: string) => { const { convertHeicToJpeg } = await import('@/lib/client/heicConverter') let jpegBlob = blob if (blob.type === 'image/heic' || blob.type === 'image/heif' || blob.type === '') { try { update(id, { status: 'converting' }) jpegBlob = await convertHeicToJpeg(blob) update(id, { originalBlob: jpegBlob }) } catch (err) { update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` }) return } } else { update(id, { status: 'searching' }) } await searchProduct(id, ref) }, [update, searchProduct], ) // Pipeline IA complet : détourage + composite + rotation const runBgRemoval = useCallback( async (id: string, blob: Blob, feather: number, rotation: number, alphaThreshold: number) => { const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover') const { rotateBlob } = await import('@/lib/client/imageRotator') let rawPng: Blob try { update(id, { status: 'processing', progress: null }) rawPng = await removeBackgroundRaw(blob, (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, alphaThreshold) 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, status: 'ready' }) }, [update], ) // Recomposite rapide (feather, alphaThreshold ou rotation changés) — ne relance pas l'IA const recomposite = useCallback( async (id: string, rawPngBlob: Blob, feather: number, rotation: number, alphaThreshold: number) => { const { compositeOnWhite } = await import('@/lib/client/bgRemover') const { rotateBlob } = await import('@/lib/client/imageRotator') try { const composited = await compositeOnWhite(rawPngBlob, feather, alphaThreshold) const finalBlob = await rotateBlob(composited, rotation) update(id, { processedBlob: finalBlob, feather, rotation, alphaThreshold }) } 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, alphaThreshold: 30, shopifyProductId: null, shopifyProductTitle: null, uploadedUrl: null, status: 'pending' as ImageStatus, error: null, progress: null, })) const itemsWithRef = newItems.map(item => { const basename = item.filename.split('/').pop() ?? item.filename return { ...item, ref: refFromFilename(basename) } }) dispatch({ type: 'ADD', items: itemsWithRef }) for (const item of itemsWithRef) { queueRef.current.enqueue(() => runConvertAndSearch(item.id, item.originalBlob, item.ref)) } } 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, alphaThreshold: 30, shopifyProductId: null, shopifyProductTitle: null, uploadedUrl: null, status: 'pending', error: null, progress: null, } dispatch({ type: 'ADD', items: [newItem] }) queueRef.current.enqueue(() => runConvertAndSearch(newItem.id, newItem.originalBlob, newItem.ref)) } } }, [runConvertAndSearch], ) // Lancer le détourage IA sur une image (depuis statut converted ou error) const detour = useCallback( (id: string) => { const item = items.find((i) => i.id === id) if (!item) return queueRef.current.enqueue(() => runBgRemoval(item.id, item.originalBlob, item.feather, item.rotation, item.alphaThreshold) ) }, [items, runBgRemoval], ) // Relancer le pipeline complet (conversion + détourage) sur une image déjà traitée const reprocess = useCallback( (id: string) => { const item = items.find((i) => i.id === id) if (!item) return queueRef.current.enqueue(() => runBgRemoval(item.id, item.originalBlob, item.feather, item.rotation, item.alphaThreshold) ) }, [items, runBgRemoval], ) 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, item.alphaThreshold) }, [items, recomposite], ) const setAlphaThreshold = useCallback( (id: string, value: number) => { const item = items.find((i) => i.id === id) if (!item || !item.rawPngBlob) return recomposite(id, item.rawPngBlob, item.feather, item.rotation, value) }, [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, item.alphaThreshold) }, [items, recomposite], ) const uploadOne = useCallback( async (id: string) => { const item = items.find((i) => i.id === id) if (!item) return const canUpload = item.status === 'ready' || item.status === 'converted' if (!canUpload || !item.shopifyProductId) return update(id, { status: 'uploading' }) try { // Si détouré : utilise processedBlob ; sinon : redimensionne à 1000×1000 let uploadBlob: Blob if (item.processedBlob) { uploadBlob = item.processedBlob } else { const { resizeToSquare } = await import('@/lib/client/imageResizer') uploadBlob = await resizeToSquare(item.originalBlob) } const base64 = await blobToBase64(uploadBlob) const basename = item.filename.split('/').pop()?.replace(/\.[^.]+$/, '') ?? item.ref const filename = `${basename}.jpg` const doUpload = async (shopifyProductId: string) => fetch('/api/images/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shopifyProductId, imageBase64: base64, filename }), }) let res = await doUpload(item.shopifyProductId) // 404 = ID périmé → relancer la recherche par ref if (res.status === 404) { const searchRes = await fetch(`/api/products/search?ref=${encodeURIComponent(item.ref)}`) const searchData = await searchRes.json() if (searchData.found && searchData.shopifyId) { update(id, { shopifyProductId: searchData.shopifyId, shopifyProductTitle: searchData.title }) res = await doUpload(searchData.shopifyId) } } const data = await res.json() if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) update(id, { status: data.skipped ? 'skipped' : 'uploaded', uploadedUrl: data.url }) } catch (err) { update(id, { status: 'error', error: `Upload : ${(err as Error).message}` }) } }, [items, update], ) const uploadAll = useCallback(async () => { const ready = items.filter((i) => i.status === 'ready' || i.status === 'converted') for (const item of ready) { await uploadOne(item.id) await new Promise(r => setTimeout(r, 500)) } }, [items, uploadOne]) const uploadSelected = useCallback(async (ids: Set) => { const toUpload = items.filter(i => ids.has(i.id) && (i.status === 'ready' || i.status === 'converted') && !!i.shopifyProductId ) for (const item of toUpload) { await uploadOne(item.id) await new Promise(r => setTimeout(r, 500)) } }, [items, uploadOne]) const removeItem = useCallback((id: string) => { dispatch({ type: 'REMOVE', id }) }, []) const assignProduct = useCallback( (id: string, shopifyProductId: string, shopifyProductTitle: string) => { const item = items.find((i) => i.id === id) const newStatus = item?.processedBlob ? 'ready' : 'converted' update(id, { shopifyProductId, shopifyProductTitle, status: newStatus }) }, [items, update], ) return { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, uploadSelected, removeItem, assignProduct, reprocess, detour } }