99de0da823
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
'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<void>> = []
|
||
|
||
function next() {
|
||
if (running >= CONCURRENCY || queue.length === 0) return
|
||
running++
|
||
const task = queue.shift()!
|
||
task().finally(() => { running--; next() })
|
||
}
|
||
|
||
return {
|
||
enqueue(task: () => Promise<void>) {
|
||
queue.push(task)
|
||
next()
|
||
},
|
||
}
|
||
}
|
||
|
||
// ── 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('.')
|
||
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<string> {
|
||
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<ImageItem>) => {
|
||
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<string>) => {
|
||
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 }
|
||
}
|