Files
gestion-materiaux-destock/src/hooks/useImages.ts
T

381 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 }
}