feat: make AI background removal optional with resize-only upload and batch detour selection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
import { DropZone } from '@/components/images/DropZone'
|
import { DropZone } from '@/components/images/DropZone'
|
||||||
import { ImageCard } from '@/components/images/ImageCard'
|
import { ImageCard } from '@/components/images/ImageCard'
|
||||||
@@ -10,8 +10,36 @@ import { useImages } from '@/hooks/useImages'
|
|||||||
import type { ImageItem } from '@/types/images'
|
import type { ImageItem } from '@/types/images'
|
||||||
|
|
||||||
function ImagesPageInner() {
|
function ImagesPageInner() {
|
||||||
const { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, removeItem, assignProduct, reprocess } = useImages()
|
const { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, removeItem, assignProduct, reprocess, detour } = useImages()
|
||||||
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((id: string, value: boolean) => {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (value) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectAll = useCallback(() => {
|
||||||
|
const detourable = items.filter(i =>
|
||||||
|
i.status === 'converted' || i.status === 'not_found' || i.status === 'error'
|
||||||
|
)
|
||||||
|
setSelected(new Set(detourable.map(i => i.id) as string[]))
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => setSelected(new Set()), [])
|
||||||
|
|
||||||
|
const detourSelected = useCallback(() => {
|
||||||
|
Array.from(selected).forEach(id => detour(id))
|
||||||
|
setSelected(new Set())
|
||||||
|
}, [selected, detour])
|
||||||
|
|
||||||
|
const detourable = items.filter(i =>
|
||||||
|
i.status === 'converted' || i.status === 'not_found' || i.status === 'error'
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -31,12 +59,39 @@ function ImagesPageInner() {
|
|||||||
<GlobalActions items={items} onUploadAll={uploadAll} />
|
<GlobalActions items={items} onUploadAll={uploadAll} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Barre de sélection multiple pour détourage par lot */}
|
||||||
|
{detourable.length > 0 && (
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 bg-slate-800 rounded-lg border border-slate-700">
|
||||||
|
<span className="text-xs text-slate-400">{detourable.length} image{detourable.length > 1 ? 's' : ''} sans détourage</span>
|
||||||
|
<button onClick={selectAll} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||||
|
Tout sélectionner
|
||||||
|
</button>
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-slate-500">·</span>
|
||||||
|
<span className="text-xs text-white font-medium">{selected.size} sélectionnée{selected.size > 1 ? 's' : ''}</span>
|
||||||
|
<button
|
||||||
|
onClick={detourSelected}
|
||||||
|
className="ml-auto px-3 py-1 text-xs bg-purple-700 hover:bg-purple-600 text-white font-semibold rounded"
|
||||||
|
>
|
||||||
|
✂️ Détourer la sélection
|
||||||
|
</button>
|
||||||
|
<button onClick={clearSelection} className="text-xs text-slate-500 hover:text-slate-300">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<ImageCard
|
<ImageCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
selected={selected.has(item.id)}
|
||||||
|
onSelect={toggleSelect}
|
||||||
onFeatherChange={setFeather}
|
onFeatherChange={setFeather}
|
||||||
onAlphaThresholdChange={setAlphaThreshold}
|
onAlphaThresholdChange={setAlphaThreshold}
|
||||||
onRotate={rotate}
|
onRotate={rotate}
|
||||||
@@ -45,6 +100,7 @@ function ImagesPageInner() {
|
|||||||
onOpenLightbox={setLightboxItem}
|
onOpenLightbox={setLightboxItem}
|
||||||
onAssignProduct={assignProduct}
|
onAssignProduct={assignProduct}
|
||||||
onReprocess={reprocess}
|
onReprocess={reprocess}
|
||||||
|
onDetour={detour}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { ImageItem } from '@/types/images'
|
|||||||
|
|
||||||
interface ImageCardProps {
|
interface ImageCardProps {
|
||||||
item: ImageItem
|
item: ImageItem
|
||||||
|
selected?: boolean
|
||||||
|
onSelect?: (id: string, selected: boolean) => void
|
||||||
onFeatherChange: (id: string, value: number) => void
|
onFeatherChange: (id: string, value: number) => void
|
||||||
onAlphaThresholdChange: (id: string, value: number) => void
|
onAlphaThresholdChange: (id: string, value: number) => void
|
||||||
onRotate: (id: string, direction: 'cw' | 'ccw') => void
|
onRotate: (id: string, direction: 'cw' | 'ccw') => void
|
||||||
@@ -12,19 +14,21 @@ interface ImageCardProps {
|
|||||||
onOpenLightbox: (item: ImageItem) => void
|
onOpenLightbox: (item: ImageItem) => void
|
||||||
onAssignProduct: (id: string, shopifyProductId: string, shopifyProductTitle: string) => void
|
onAssignProduct: (id: string, shopifyProductId: string, shopifyProductTitle: string) => void
|
||||||
onReprocess: (id: string) => void
|
onReprocess: (id: string) => void
|
||||||
|
onDetour: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
pending: { label: '⏳ En attente', color: 'text-slate-400' },
|
pending: { label: '⏳ En attente', color: 'text-slate-400' },
|
||||||
converting: { label: '🔄 Conversion HEIC', color: 'text-yellow-400' },
|
converting: { label: '🔄 Conversion HEIC', color: 'text-yellow-400' },
|
||||||
processing: { label: '🤖 Détourage IA', color: 'text-blue-400' },
|
searching: { label: '🔍 Recherche…', color: 'text-purple-400' },
|
||||||
searching: { label: '🔍 Recherche…', color: 'text-purple-400' },
|
converted: { label: '✓ Prêt (sans détourage)', color: 'text-amber-400' },
|
||||||
ready: { label: '✓ Prêt', color: 'text-green-400' },
|
processing: { label: '🤖 Détourage IA', color: 'text-blue-400' },
|
||||||
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
|
ready: { label: '✓ Détouré', color: 'text-green-400' },
|
||||||
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
|
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
|
||||||
skipped: { label: '⏭️ Déjà sur Shopify', color: 'text-slate-400' },
|
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
|
||||||
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
|
skipped: { label: '⏭️ Déjà sur Shopify', color: 'text-slate-400' },
|
||||||
error: { label: '❌ Erreur', color: 'text-red-400' },
|
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
|
||||||
|
error: { label: '❌ Erreur', color: 'text-red-400' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
|
function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
|
||||||
@@ -95,6 +99,8 @@ function ProductSearch({ itemId, onAssign }: { itemId: string; onAssign: (id: st
|
|||||||
|
|
||||||
export function ImageCard({
|
export function ImageCard({
|
||||||
item,
|
item,
|
||||||
|
selected = false,
|
||||||
|
onSelect,
|
||||||
onFeatherChange,
|
onFeatherChange,
|
||||||
onAlphaThresholdChange,
|
onAlphaThresholdChange,
|
||||||
onRotate,
|
onRotate,
|
||||||
@@ -103,18 +109,31 @@ export function ImageCard({
|
|||||||
onOpenLightbox,
|
onOpenLightbox,
|
||||||
onAssignProduct,
|
onAssignProduct,
|
||||||
onReprocess,
|
onReprocess,
|
||||||
|
onDetour,
|
||||||
}: ImageCardProps) {
|
}: ImageCardProps) {
|
||||||
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
|
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
|
||||||
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
|
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
|
||||||
const canAdjust = !!item.rawPngBlob && !isProcessing
|
const canAdjust = !!item.rawPngBlob && !isProcessing
|
||||||
|
const canUpload = (item.status === 'ready' || item.status === 'converted') && !!item.shopifyProductId
|
||||||
|
const canDetour = (item.status === 'converted' || item.status === 'error' || item.status === 'not_found') && !isProcessing
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden flex flex-col">
|
<div className={`bg-slate-900 border rounded-xl overflow-hidden flex flex-col ${selected ? 'border-indigo-500 ring-1 ring-indigo-500' : 'border-slate-700'}`}>
|
||||||
{/* Miniatures avant / après */}
|
{/* Miniatures avant / après */}
|
||||||
<div
|
<div
|
||||||
className="relative h-40 bg-slate-800 cursor-pointer"
|
className="relative h-40 bg-slate-800 cursor-pointer"
|
||||||
onClick={() => onOpenLightbox(item)}
|
onClick={() => onOpenLightbox(item)}
|
||||||
>
|
>
|
||||||
|
{onSelect && (
|
||||||
|
<div className="absolute top-2 left-2 z-10" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={e => onSelect(item.id, e.target.checked)}
|
||||||
|
className="w-4 h-4 accent-indigo-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={`absolute top-0 bottom-0 left-0 overflow-hidden ${item.processedBlob ? 'w-1/2' : 'w-full'}`}>
|
<div className={`absolute top-0 bottom-0 left-0 overflow-hidden ${item.processedBlob ? 'w-1/2' : 'w-full'}`}>
|
||||||
<BlobThumbnail blob={item.originalBlob} alt="Original" />
|
<BlobThumbnail blob={item.originalBlob} alt="Original" />
|
||||||
</div>
|
</div>
|
||||||
@@ -195,7 +214,7 @@ export function ImageCard({
|
|||||||
|
|
||||||
{/* Boutons d'action */}
|
{/* Boutons d'action */}
|
||||||
<div className="flex gap-2 mt-auto pt-1">
|
<div className="flex gap-2 mt-auto pt-1">
|
||||||
{item.status === 'ready' && (
|
{canUpload && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onUpload(item.id)}
|
onClick={() => onUpload(item.id)}
|
||||||
className="flex-1 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded"
|
className="flex-1 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded"
|
||||||
@@ -203,7 +222,16 @@ export function ImageCard({
|
|||||||
⬆️ Uploader
|
⬆️ Uploader
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(item.status === 'ready' || item.status === 'uploaded' || item.status === 'skipped' || item.status === 'error' || item.status === 'not_found') && item.rawPngBlob && (
|
{canDetour && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDetour(item.id)}
|
||||||
|
title="Lancer le détourage IA"
|
||||||
|
className="flex-1 py-1.5 text-xs bg-purple-700 hover:bg-purple-600 text-white font-semibold rounded"
|
||||||
|
>
|
||||||
|
✂️ Détourer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(item.status === 'ready' || item.status === 'uploaded' || item.status === 'skipped' || item.status === 'error') && item.rawPngBlob && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onReprocess(item.id)}
|
onClick={() => onReprocess(item.id)}
|
||||||
title="Relancer le détourage IA"
|
title="Relancer le détourage IA"
|
||||||
|
|||||||
+67
-40
@@ -59,7 +59,6 @@ function blobToBase64(blob: Blob): Promise<string> {
|
|||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const result = reader.result as string
|
const result = reader.result as string
|
||||||
// Supprimer le préfixe "data:image/jpeg;base64,"
|
|
||||||
const base64 = result.split(',')[1]
|
const base64 = result.split(',')[1]
|
||||||
resolve(base64)
|
resolve(base64)
|
||||||
}
|
}
|
||||||
@@ -83,24 +82,21 @@ export function useImages() {
|
|||||||
async (id: string, ref: string) => {
|
async (id: string, ref: string) => {
|
||||||
update(id, { status: 'searching' })
|
update(id, { status: 'searching' })
|
||||||
try {
|
try {
|
||||||
// 1. Cherche d'abord dans Google Sheets (col C = UGS → col A = Shopify ID)
|
|
||||||
const sheetsRes = await fetch(`/api/products/search-sheets?ref=${encodeURIComponent(ref)}`)
|
const sheetsRes = await fetch(`/api/products/search-sheets?ref=${encodeURIComponent(ref)}`)
|
||||||
const sheetsData = await sheetsRes.json()
|
const sheetsData = await sheetsRes.json()
|
||||||
if (sheetsData.found) {
|
if (sheetsData.found) {
|
||||||
update(id, {
|
update(id, {
|
||||||
status: 'ready',
|
status: 'converted',
|
||||||
shopifyProductId: sheetsData.shopifyId,
|
shopifyProductId: sheetsData.shopifyId,
|
||||||
shopifyProductTitle: sheetsData.title,
|
shopifyProductTitle: sheetsData.title,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback : recherche directe dans Shopify par handle/titre
|
|
||||||
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
|
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.found) {
|
if (data.found) {
|
||||||
update(id, {
|
update(id, {
|
||||||
status: 'ready',
|
status: 'converted',
|
||||||
shopifyProductId: data.shopifyId,
|
shopifyProductId: data.shopifyId,
|
||||||
shopifyProductTitle: data.title,
|
shopifyProductTitle: data.title,
|
||||||
})
|
})
|
||||||
@@ -114,31 +110,40 @@ export function useImages() {
|
|||||||
[update],
|
[update],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pipeline complet pour un item (avec ref)
|
// Pipeline léger : conversion HEIC + recherche produit (sans détourage IA)
|
||||||
const runPipeline = useCallback(
|
const runConvertAndSearch = useCallback(
|
||||||
async (id: string, blob: Blob, ref: string, feather: number, rotation: number, alphaThreshold: number) => {
|
async (id: string, blob: Blob, ref: string) => {
|
||||||
const { convertHeicToJpeg } = await import('@/lib/client/heicConverter')
|
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
|
let jpegBlob = blob
|
||||||
|
|
||||||
if (blob.type === 'image/heic' || blob.type === 'image/heif' || blob.type === '') {
|
if (blob.type === 'image/heic' || blob.type === 'image/heif' || blob.type === '') {
|
||||||
try {
|
try {
|
||||||
update(id, { status: 'converting' })
|
update(id, { status: 'converting' })
|
||||||
jpegBlob = await convertHeicToJpeg(blob)
|
jpegBlob = await convertHeicToJpeg(blob)
|
||||||
// Met à jour originalBlob avec le JPEG converti (Chrome ne peut pas afficher HEIC)
|
|
||||||
update(id, { originalBlob: jpegBlob })
|
update(id, { originalBlob: jpegBlob })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
|
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
|
||||||
return
|
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
|
let rawPng: Blob
|
||||||
try {
|
try {
|
||||||
update(id, { status: 'processing', progress: null })
|
update(id, { status: 'processing', progress: null })
|
||||||
rawPng = await removeBackgroundRaw(jpegBlob, (p) => update(id, { progress: p }))
|
rawPng = await removeBackgroundRaw(blob, (p) => update(id, { progress: p }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
update(id, { status: 'error', error: `Détourage IA : ${(err as Error).message}` })
|
update(id, { status: 'error', error: `Détourage IA : ${(err as Error).message}` })
|
||||||
return
|
return
|
||||||
@@ -153,10 +158,9 @@ export function useImages() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id, { rawPngBlob: rawPng, processedBlob: finalBlob })
|
update(id, { rawPngBlob: rawPng, processedBlob: finalBlob, status: 'ready' })
|
||||||
await searchProduct(id, ref)
|
|
||||||
},
|
},
|
||||||
[update, searchProduct],
|
[update],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Recomposite rapide (feather, alphaThreshold ou rotation changés) — ne relance pas l'IA
|
// Recomposite rapide (feather, alphaThreshold ou rotation changés) — ne relance pas l'IA
|
||||||
@@ -201,15 +205,13 @@ export function useImages() {
|
|||||||
error: null,
|
error: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
}))
|
}))
|
||||||
// Recalcule la ref depuis le nom du fichier (plus fiable que le nom du dossier ZIP)
|
|
||||||
// item.filename = "DOSSIER/32263-.heic" → on prend la partie après le dernier "/"
|
|
||||||
const itemsWithRef = newItems.map(item => {
|
const itemsWithRef = newItems.map(item => {
|
||||||
const basename = item.filename.split('/').pop() ?? item.filename
|
const basename = item.filename.split('/').pop() ?? item.filename
|
||||||
return { ...item, ref: refFromFilename(basename) }
|
return { ...item, ref: refFromFilename(basename) }
|
||||||
})
|
})
|
||||||
dispatch({ type: 'ADD', items: itemsWithRef })
|
dispatch({ type: 'ADD', items: itemsWithRef })
|
||||||
for (const item of itemsWithRef) {
|
for (const item of itemsWithRef) {
|
||||||
queueRef.current.enqueue(() => runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation, item.alphaThreshold))
|
queueRef.current.enqueue(() => runConvertAndSearch(item.id, item.originalBlob, item.ref))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const ref = refFromFilename(file.name)
|
const ref = refFromFilename(file.name)
|
||||||
@@ -231,11 +233,35 @@ export function useImages() {
|
|||||||
progress: null,
|
progress: null,
|
||||||
}
|
}
|
||||||
dispatch({ type: 'ADD', items: [newItem] })
|
dispatch({ type: 'ADD', items: [newItem] })
|
||||||
queueRef.current.enqueue(() => runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation, newItem.alphaThreshold))
|
queueRef.current.enqueue(() => runConvertAndSearch(newItem.id, newItem.originalBlob, newItem.ref))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[runPipeline],
|
[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(
|
const setFeather = useCallback(
|
||||||
@@ -270,12 +296,23 @@ export function useImages() {
|
|||||||
const uploadOne = useCallback(
|
const uploadOne = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const item = items.find((i) => i.id === id)
|
const item = items.find((i) => i.id === id)
|
||||||
if (!item || item.status !== 'ready' || !item.processedBlob || !item.shopifyProductId) return
|
if (!item) return
|
||||||
|
const canUpload = item.status === 'ready' || item.status === 'converted'
|
||||||
|
if (!canUpload || !item.shopifyProductId) return
|
||||||
|
|
||||||
update(id, { status: 'uploading' })
|
update(id, { status: 'uploading' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const base64 = await blobToBase64(item.processedBlob)
|
// 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 basename = item.filename.split('/').pop()?.replace(/\.[^.]+$/, '') ?? item.ref
|
||||||
const filename = `${basename}.jpg`
|
const filename = `${basename}.jpg`
|
||||||
|
|
||||||
@@ -288,7 +325,7 @@ export function useImages() {
|
|||||||
|
|
||||||
let res = await doUpload(item.shopifyProductId)
|
let res = await doUpload(item.shopifyProductId)
|
||||||
|
|
||||||
// 404 = ID périmé (produit supprimé/recréé) → relancer la recherche par ref
|
// 404 = ID périmé → relancer la recherche par ref
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
const searchRes = await fetch(`/api/products/search?ref=${encodeURIComponent(item.ref)}`)
|
const searchRes = await fetch(`/api/products/search?ref=${encodeURIComponent(item.ref)}`)
|
||||||
const searchData = await searchRes.json()
|
const searchData = await searchRes.json()
|
||||||
@@ -309,10 +346,9 @@ export function useImages() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const uploadAll = useCallback(async () => {
|
const uploadAll = useCallback(async () => {
|
||||||
const ready = items.filter((i) => i.status === 'ready')
|
const ready = items.filter((i) => i.status === 'ready' || i.status === 'converted')
|
||||||
for (const item of ready) {
|
for (const item of ready) {
|
||||||
await uploadOne(item.id)
|
await uploadOne(item.id)
|
||||||
// Pause de 500ms entre chaque upload pour éviter le rate limit Shopify
|
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise(r => setTimeout(r, 500))
|
||||||
}
|
}
|
||||||
}, [items, uploadOne])
|
}, [items, uploadOne])
|
||||||
@@ -323,21 +359,12 @@ export function useImages() {
|
|||||||
|
|
||||||
const assignProduct = useCallback(
|
const assignProduct = useCallback(
|
||||||
(id: string, shopifyProductId: string, shopifyProductTitle: string) => {
|
(id: string, shopifyProductId: string, shopifyProductTitle: string) => {
|
||||||
update(id, { shopifyProductId, shopifyProductTitle, status: 'ready' })
|
|
||||||
},
|
|
||||||
[update],
|
|
||||||
)
|
|
||||||
|
|
||||||
const reprocess = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
const item = items.find((i) => i.id === id)
|
const item = items.find((i) => i.id === id)
|
||||||
if (!item) return
|
const newStatus = item?.processedBlob ? 'ready' : 'converted'
|
||||||
queueRef.current.enqueue(() =>
|
update(id, { shopifyProductId, shopifyProductTitle, status: newStatus })
|
||||||
runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation, item.alphaThreshold)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
[items, runPipeline],
|
[items, update],
|
||||||
)
|
)
|
||||||
|
|
||||||
return { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, removeItem, assignProduct, reprocess }
|
return { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, removeItem, assignProduct, reprocess, detour }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export async function resizeToSquare(blob: Blob, size = 1000): Promise<Blob> {
|
||||||
|
const img = await createImageBitmap(blob)
|
||||||
|
const canvas = new OffscreenCanvas(size, size)
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(0, 0, size, size)
|
||||||
|
const scale = Math.min(size / img.width, size / img.height)
|
||||||
|
const w = img.width * scale
|
||||||
|
const h = img.height * scale
|
||||||
|
const x = (size - w) / 2
|
||||||
|
const y = (size - h) / 2
|
||||||
|
ctx.drawImage(img, x, y, w, h)
|
||||||
|
img.close()
|
||||||
|
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
|
||||||
|
}
|
||||||
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
export type ImageStatus =
|
export type ImageStatus =
|
||||||
| 'pending' // ajouté, pas encore traité
|
| 'pending' // ajouté, pas encore traité
|
||||||
| 'converting' // HEIC → JPEG en cours
|
| 'converting' // HEIC → JPEG en cours
|
||||||
| 'processing' // détourage IA en cours
|
|
||||||
| 'searching' // recherche produit Shopify en cours
|
| 'searching' // recherche produit Shopify en cours
|
||||||
| 'ready' // prêt à uploader
|
| 'converted' // converti + recherche terminée, détourage non lancé
|
||||||
|
| 'processing' // détourage IA en cours
|
||||||
|
| 'ready' // détouré, prêt à uploader
|
||||||
| 'uploading' // upload Shopify en cours
|
| 'uploading' // upload Shopify en cours
|
||||||
| 'uploaded' // uploadé avec succès
|
| 'uploaded' // uploadé avec succès
|
||||||
| 'skipped' // image déjà présente sur le produit Shopify
|
| 'skipped' // image déjà présente sur le produit Shopify
|
||||||
|
|||||||
Reference in New Issue
Block a user