feat: hook useImages — pipeline HEIC→IA→feather→rotation→search→upload
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user