346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useSearchParams } from 'next/navigation';
|
|
import { toast } from 'react-hot-toast';
|
|
import { Eraser, Download, Sparkles, Trash2, RefreshCw } from 'lucide-react';
|
|
import FileUpload from '@/components/FileUpload';
|
|
import JobProgress from '@/components/JobProgress';
|
|
import { modulesApi, assetsApi, jobsApi } from '@/lib/api';
|
|
import { useStore } from '@/lib/store';
|
|
|
|
const outputFormats = [
|
|
{ value: 'png', label: 'PNG (Transparent)' },
|
|
{ value: 'webp', label: 'WebP' },
|
|
{ value: 'tiff', label: 'TIFF (Clipping Path)' },
|
|
];
|
|
|
|
interface QueueItem {
|
|
id: string;
|
|
file?: File; // Optional because we might load by assetId from URL
|
|
assetId?: string;
|
|
originalFileName?: string; // To display when only assetId is known
|
|
jobId?: string;
|
|
status: 'pending' | 'uploading' | 'processing' | 'completed' | 'error';
|
|
resultAssetId?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export default function RemoveBackgroundPage() {
|
|
const { addJob, updateJob } = useStore();
|
|
const searchParams = useSearchParams();
|
|
const [queue, setQueue] = useState<QueueItem[]>([]);
|
|
const [outputFormat, setOutputFormat] = useState('png');
|
|
const [refineMask, setRefineMask] = useState(true);
|
|
const [processing, setProcessing] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const urlAssetId = searchParams.get('assetId');
|
|
if (urlAssetId) {
|
|
// Add as a pending item if not already in queue
|
|
setQueue(prev => {
|
|
if (prev.some(item => item.assetId === urlAssetId)) return prev;
|
|
return [...prev, {
|
|
id: Math.random().toString(36).substring(7),
|
|
assetId: urlAssetId,
|
|
status: 'pending',
|
|
originalFileName: 'Asset from URL' // We might not know the name yet, that's fine
|
|
}];
|
|
});
|
|
toast.success('Asset added to queue from URL');
|
|
}
|
|
}, [searchParams]);
|
|
|
|
const handleFileUpload = (files: File[]) => {
|
|
const newItems: QueueItem[] = files.map(file => ({
|
|
id: Math.random().toString(36).substring(7),
|
|
file,
|
|
originalFileName: file.name,
|
|
status: 'pending'
|
|
}));
|
|
setQueue(prev => [...prev, ...newItems]);
|
|
toast.success(`${files.length} images added to queue`);
|
|
};
|
|
|
|
const processItem = async (item: QueueItem) => {
|
|
if (item.status === 'completed' || item.status === 'processing') return item;
|
|
|
|
try {
|
|
let assetId = item.assetId;
|
|
|
|
// 1. Upload if needed
|
|
if (!assetId && item.file) {
|
|
setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'uploading' } : i));
|
|
try {
|
|
const uploadRes = await assetsApi.upload(item.file);
|
|
assetId = uploadRes.data.id;
|
|
} catch (err: any) {
|
|
if (err.response?.status === 409) {
|
|
const existingAssetId = err.response.data.detail.asset_id;
|
|
const shouldOverwrite = window.confirm(
|
|
`File "${item.file.name}" already exists. \nClick OK to Overwrite, Cancel to Use Existing.`
|
|
);
|
|
|
|
if (shouldOverwrite) {
|
|
const uploadRes = await assetsApi.upload(item.file, undefined, false, true);
|
|
assetId = uploadRes.data.id;
|
|
} else {
|
|
assetId = existingAssetId;
|
|
}
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
setQueue(prev => prev.map(i => i.id === item.id ? { ...i, assetId, status: 'pending' } : i));
|
|
} else if (!assetId && !item.file) {
|
|
throw new Error("No file or asset ID provided");
|
|
}
|
|
|
|
// 2. Process
|
|
setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'processing' } : i));
|
|
|
|
// This assumes modulesApi.removeBackground takes { asset_id, output_format, refine_mask }
|
|
const response = await modulesApi.removeBackground({
|
|
asset_id: assetId!,
|
|
output_format: outputFormat,
|
|
refine_mask: refineMask,
|
|
});
|
|
|
|
const job = response.data;
|
|
addJob({
|
|
id: job.id,
|
|
module: 'background_removal',
|
|
status: job.status,
|
|
progress: job.progress,
|
|
created_at: job.created_at,
|
|
});
|
|
|
|
// Poll for completion
|
|
let currentJob = job;
|
|
while (currentJob.status !== 'completed' && currentJob.status !== 'failed') {
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
const pollRes = await jobsApi.get(job.id);
|
|
if (pollRes?.data) currentJob = pollRes.data;
|
|
else break;
|
|
}
|
|
|
|
if (currentJob.status === 'completed' && currentJob.output_asset_ids?.length > 0) {
|
|
const resultId = currentJob.output_asset_ids[0];
|
|
setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'completed', resultAssetId: resultId } : i));
|
|
return { ...item, status: 'completed', resultAssetId: resultId };
|
|
} else {
|
|
throw new Error(currentJob.error_message || 'Job failed');
|
|
}
|
|
|
|
} catch (err: any) {
|
|
console.error(err);
|
|
const errMsg = err.message || 'Failed';
|
|
setQueue(prev => prev.map(i => i.id === item.id ? { ...i, status: 'error', error: errMsg } : i));
|
|
return { ...item, status: 'error', error: errMsg };
|
|
}
|
|
};
|
|
|
|
const handleProcessQueue = async () => {
|
|
setProcessing(true);
|
|
const pending = queue.filter(i => i.status === 'pending' || i.status === 'error');
|
|
const limit = 3;
|
|
|
|
// We process only pending items. Note that if we just uploaded (item.status is 'pending' but with assetId set), it will process.
|
|
// If it was 'uploading', it shouldn't happen here because upload is part of processItem if we do it one by one,
|
|
// OR we can separates upload step. The current logic puts upload inside processItem which is fine.
|
|
|
|
for (let i = 0; i < pending.length; i += limit) {
|
|
const chunk = pending.slice(i, i + limit);
|
|
await Promise.all(chunk.map(item => processItem(item)));
|
|
}
|
|
setProcessing(false);
|
|
toast.success('Queue processing complete');
|
|
};
|
|
|
|
const handleClearQueue = () => {
|
|
setQueue([]);
|
|
};
|
|
|
|
const removeItem = (id: string) => {
|
|
setQueue(prev => prev.filter(i => i.id !== id));
|
|
};
|
|
|
|
const handleDownload = async (assetId: string) => {
|
|
try {
|
|
const response = await assetsApi.download(assetId);
|
|
// We need the filename. If we haven't fetched the asset details, we might guess or try to get it.
|
|
// For now let's hope the browser handles it or we fetch header.
|
|
// Actually assetsApi.download returns a blob?
|
|
// Better logic: fetch asset details to get filename, then download.
|
|
// But here let's just use generic name if we can't get it easily, or maybe we fetch asset first?
|
|
// To be safe and quick:
|
|
const url = window.URL.createObjectURL(response.data);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `removed_bg_${assetId}.png`; // fallback
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
toast.error("Download failed");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto space-y-8">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 bg-forge-yellow/10 rounded-lg flex items-center justify-center">
|
|
<Eraser className="w-6 h-6 text-forge-yellow" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Background Remover</h1>
|
|
<p className="text-gray-500">Remove backgrounds instantly with AI precision</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
|
{/* Controls - Left Column */}
|
|
<div className="lg:col-span-1 space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
Upload Images
|
|
</label>
|
|
<FileUpload
|
|
onUploadMultiple={handleFileUpload}
|
|
accept={{ 'image/*': ['.png', '.jpg', '.jpeg', '.webp'] }}
|
|
label="Upload images (Multiple allowed)"
|
|
multiple={true}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
Output Format
|
|
</label>
|
|
<select
|
|
value={outputFormat}
|
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setOutputFormat(e.target.value)}
|
|
className="select-field"
|
|
>
|
|
{outputFormats.map((format) => (
|
|
<option key={format.value} value={format.value}>
|
|
{format.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="checkbox"
|
|
id="refineMask"
|
|
checked={refineMask}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRefineMask(e.target.checked)}
|
|
className="w-4 h-4 rounded border-gray-600 bg-forge-dark text-forge-yellow focus:ring-forge-yellow"
|
|
/>
|
|
<label htmlFor="refineMask" className="text-gray-300 text-sm">
|
|
Refine edges (better quality, slower)
|
|
</label>
|
|
</div>
|
|
|
|
{queue.length > 0 && (
|
|
<div className="pt-4 border-t border-gray-800 space-y-4">
|
|
<button
|
|
onClick={handleProcessQueue}
|
|
disabled={processing || !queue.some(i => i.status === 'pending' || i.status === 'error')}
|
|
className="btn-primary w-full flex items-center justify-center gap-2"
|
|
>
|
|
{processing ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
|
{processing ? 'Processing Queue...' : 'Process All'}
|
|
</button>
|
|
<button
|
|
onClick={handleClearQueue}
|
|
disabled={processing}
|
|
className="btn-secondary w-full flex items-center justify-center gap-2"
|
|
>
|
|
<Trash2 className="w-4 h-4" /> Clear Queue
|
|
</button>
|
|
<div className="text-center text-xs text-gray-500">
|
|
{queue.filter(i => i.status === 'completed').length} / {queue.length} completed
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Results / Queue - Right Column */}
|
|
<div className="lg:col-span-3 space-y-4">
|
|
<h2 className="text-lg font-semibold text-white">Queue</h2>
|
|
|
|
{queue.length === 0 ? (
|
|
<div className="bg-forge-dark rounded-xl border border-gray-800 p-12 flex flex-col items-center justify-center text-gray-500">
|
|
<Eraser className="w-12 h-12 mb-4 opacity-50" />
|
|
<p>Queue is empty. Upload images to start.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{queue.map(item => (
|
|
<div key={item.id} className="bg-forge-dark rounded-xl border border-gray-800 p-4 flex gap-4 items-start">
|
|
<div className="w-24 h-24 bg-black/40 rounded-lg flex-shrink-0 overflow-hidden relative border border-gray-700">
|
|
{/* Show source image if available (file or if we fetched asset details, but simpler to just show file or placeholder for now if from URL) */}
|
|
{item.file ? (
|
|
<img src={URL.createObjectURL(item.file)} alt="source" className="w-full h-full object-cover" />
|
|
) : item.assetId ? (
|
|
<img src={`/api/v1/assets/${item.assetId}/download`} alt="source" className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-gray-600">No Img</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex justify-between items-start mb-2">
|
|
<h3 className="text-white font-medium truncate" title={item.originalFileName || item.assetId}>
|
|
{item.originalFileName || 'Asset ID: ' + item.assetId}
|
|
</h3>
|
|
<button onClick={() => removeItem(item.id)} className="text-gray-500 hover:text-red-400">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
{/* Status Badges */}
|
|
{item.status === 'pending' && <span className="text-xs font-medium px-2 py-1 bg-gray-800 text-gray-400 rounded">Pending</span>}
|
|
{item.status === 'uploading' && <span className="text-xs font-medium px-2 py-1 bg-blue-900/50 text-blue-400 rounded animate-pulse">Uploading...</span>}
|
|
{item.status === 'processing' && <span className="text-xs font-medium px-2 py-1 bg-yellow-900/50 text-forge-yellow rounded animate-pulse">Processing...</span>}
|
|
{item.status === 'error' && <span className="text-xs font-medium px-2 py-1 bg-red-900/50 text-red-400 rounded">Error: {item.error}</span>}
|
|
{item.status === 'completed' && <span className="text-xs font-medium px-2 py-1 bg-green-900/50 text-green-400 rounded">Completed</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Result Preview (if completed) */}
|
|
{item.status === 'completed' && item.resultAssetId && (
|
|
<div className="w-32 h-32 bg-[url('/grid.png')] bg-gray-800 rounded-lg flex-shrink-0 overflow-hidden relative border border-gray-700 bg-checker">
|
|
<img
|
|
src={`/api/v1/assets/${item.resultAssetId}/download`}
|
|
alt="result"
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
<button
|
|
onClick={() => handleDownload(item.resultAssetId!)}
|
|
className="p-2 bg-forge-yellow rounded-full hover:bg-yellow-400 text-black"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<style jsx global>{`
|
|
.bg-checker {
|
|
background-image: linear-gradient(45deg, #2a2a2a 25%, transparent 25%), linear-gradient(-45deg, #2a2a2a 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #2a2a2a 75%), linear-gradient(-45deg, transparent 75%, #2a2a2a 75%);
|
|
background-size: 20px 20px;
|
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|