mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Ultra quality (People only): - BiRefNet-matting ONNX (928MB) for true alpha matting with per-pixel transparency on hair wisps and fine edges - Custom rembg session class, zero new Python dependencies - Model pre-downloaded in Docker build for immediate availability Quality tier labels: Fast / HD / Max / Ultra (shorter, fits 4-col grid)
850 lines
29 KiB
TypeScript
850 lines
29 KiB
TypeScript
import {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Download,
|
|
ImageIcon,
|
|
Package,
|
|
Upload,
|
|
User,
|
|
} from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { ProgressCard } from "@/components/common/progress-card";
|
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
|
import { useFileStore } from "@/stores/file-store";
|
|
|
|
type SubjectType = "people" | "products" | "general";
|
|
type Quality = "fast" | "balanced" | "best" | "ultra";
|
|
type BackgroundType = "transparent" | "color" | "gradient" | "image";
|
|
|
|
type BgModel =
|
|
| "birefnet-general"
|
|
| "birefnet-general-lite"
|
|
| "birefnet-matting"
|
|
| "birefnet-portrait"
|
|
| "bria-rmbg"
|
|
| "u2net";
|
|
|
|
const MODEL_MAP: Record<SubjectType, Partial<Record<Quality, BgModel>>> = {
|
|
people: {
|
|
fast: "u2net",
|
|
balanced: "birefnet-portrait",
|
|
best: "birefnet-portrait",
|
|
ultra: "birefnet-matting",
|
|
},
|
|
products: { fast: "u2net", balanced: "bria-rmbg", best: "birefnet-general" },
|
|
general: { fast: "u2net", balanced: "birefnet-general-lite", best: "birefnet-general" },
|
|
};
|
|
|
|
const SUBJECT_OPTIONS: { value: SubjectType; label: string; icon: typeof User }[] = [
|
|
{ value: "people", label: "People", icon: User },
|
|
{ value: "products", label: "Products", icon: Package },
|
|
{ value: "general", label: "General", icon: ImageIcon },
|
|
];
|
|
|
|
const ALL_QUALITY_OPTIONS: { value: Quality; label: string; peopleOnly?: boolean }[] = [
|
|
{ value: "fast", label: "Fast" },
|
|
{ value: "balanced", label: "HD" },
|
|
{ value: "best", label: "Max" },
|
|
{ value: "ultra", label: "Ultra", peopleOnly: true },
|
|
];
|
|
|
|
const COLOR_PRESETS = [
|
|
{ color: "#FFFFFF", label: "White" },
|
|
{ color: "#000000", label: "Black" },
|
|
{ color: "#FF0000", label: "Red" },
|
|
{ color: "#00FF00", label: "Green" },
|
|
{ color: "#0000FF", label: "Blue" },
|
|
];
|
|
|
|
const GRADIENT_PRESETS = [
|
|
{ color1: "#667eea", color2: "#764ba2", label: "Purple" },
|
|
{ color1: "#f093fb", color2: "#f5576c", label: "Pink" },
|
|
{ color1: "#4facfe", color2: "#00f2fe", label: "Blue" },
|
|
{ color1: "#43e97b", color2: "#38f9d7", label: "Green" },
|
|
{ color1: "#fa709a", color2: "#fee140", label: "Sunset" },
|
|
{ color1: "#a18cd1", color2: "#fbc2eb", label: "Lavender" },
|
|
];
|
|
|
|
// ── Section label ──
|
|
|
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
// ── Shared controls (used by both standalone page and pipeline steps) ──
|
|
|
|
export interface RemoveBgControlsProps {
|
|
settings: Record<string, unknown>;
|
|
onChange: (settings: Record<string, unknown>) => void;
|
|
}
|
|
|
|
export function RemoveBgControls({ settings, onChange }: RemoveBgControlsProps) {
|
|
const [subject, setSubject] = useState<SubjectType>("people");
|
|
const [quality, setQuality] = useState<Quality>("balanced");
|
|
const [isPassport, setIsPassport] = useState(true);
|
|
|
|
// Background
|
|
const [bgType, setBgType] = useState<BackgroundType>("transparent");
|
|
const [bgColor, setBgColor] = useState("#FFFFFF");
|
|
const [gradColor1, setGradColor1] = useState("#667eea");
|
|
const [gradColor2, setGradColor2] = useState("#764ba2");
|
|
const [gradAngle, setGradAngle] = useState(180);
|
|
const [bgImageFile, setBgImageFile] = useState<File | null>(null);
|
|
|
|
// Effects
|
|
const [blurEnabled, setBlurEnabled] = useState(false);
|
|
const [blurIntensity, setBlurIntensity] = useState(50);
|
|
const [shadowEnabled, setShadowEnabled] = useState(false);
|
|
const [shadowOpacity, setShadowOpacity] = useState(35);
|
|
|
|
// Expandable sections
|
|
const [effectsOpen, setEffectsOpen] = useState(false);
|
|
|
|
// Filter quality options based on subject (Ultra only for People)
|
|
const qualityOptions = ALL_QUALITY_OPTIONS.filter(
|
|
(opt) => !opt.peopleOnly || subject === "people",
|
|
);
|
|
|
|
// If switching away from People while on Ultra, fall back to Best
|
|
const effectiveQuality = quality === "ultra" && subject !== "people" ? "best" : quality;
|
|
|
|
const model =
|
|
isPassport && subject === "people"
|
|
? "birefnet-portrait"
|
|
: MODEL_MAP[subject][effectiveQuality] || "birefnet-general";
|
|
|
|
const onChangeRef = useRef(onChange);
|
|
useEffect(() => {
|
|
onChangeRef.current = onChange;
|
|
});
|
|
|
|
// Sync settings on every control change
|
|
useEffect(() => {
|
|
const next: Record<string, unknown> = { model, backgroundType: bgType };
|
|
|
|
if (bgType === "color") next.backgroundColor = bgColor;
|
|
if (bgType === "gradient") {
|
|
next.gradientColor1 = gradColor1;
|
|
next.gradientColor2 = gradColor2;
|
|
next.gradientAngle = gradAngle;
|
|
}
|
|
|
|
// Blur: enabled as effect on transparent bg means "blur original background"
|
|
if (blurEnabled) {
|
|
next.blurEnabled = true;
|
|
next.blurIntensity = blurIntensity;
|
|
}
|
|
if (shadowEnabled) {
|
|
next.shadowEnabled = true;
|
|
next.shadowOpacity = shadowOpacity;
|
|
}
|
|
|
|
// Pass bgImageFile reference for the standalone wrapper to include in FormData
|
|
if (bgType === "image" && bgImageFile) {
|
|
next._bgImageFile = bgImageFile;
|
|
}
|
|
|
|
onChangeRef.current(next);
|
|
}, [
|
|
model,
|
|
bgType,
|
|
bgColor,
|
|
gradColor1,
|
|
gradColor2,
|
|
gradAngle,
|
|
bgImageFile,
|
|
blurEnabled,
|
|
blurIntensity,
|
|
shadowEnabled,
|
|
shadowOpacity,
|
|
]);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Subject type */}
|
|
<SectionLabel>Subject</SectionLabel>
|
|
<div className="grid grid-cols-3 gap-1.5">
|
|
{SUBJECT_OPTIONS.map((opt) => {
|
|
const Icon = opt.icon;
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => {
|
|
setSubject(opt.value);
|
|
if (opt.value !== "people") setIsPassport(false);
|
|
else setIsPassport(true);
|
|
}}
|
|
className={`flex flex-col items-center gap-1 py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
|
|
subject === opt.value
|
|
? "border-primary bg-primary/10 text-primary"
|
|
: "border-border text-muted-foreground hover:border-primary/50"
|
|
}`}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
{opt.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Passport checkbox - only for people, default ON */}
|
|
{subject === "people" && (
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={isPassport}
|
|
onChange={(e) => setIsPassport(e.target.checked)}
|
|
className="rounded border-border accent-primary"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">Passport / ID photo</span>
|
|
</label>
|
|
)}
|
|
|
|
{/* Quality */}
|
|
<SectionLabel>Quality</SectionLabel>
|
|
<div className={`grid gap-1.5 ${qualityOptions.length > 3 ? "grid-cols-4" : "grid-cols-3"}`}>
|
|
{qualityOptions.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => setQuality(opt.value)}
|
|
className={`py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
|
|
effectiveQuality === opt.value
|
|
? "border-primary bg-primary/10 text-primary"
|
|
: "border-border text-muted-foreground hover:border-primary/50"
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Background */}
|
|
<SectionLabel>Background</SectionLabel>
|
|
<div className="space-y-2">
|
|
{/* Type buttons */}
|
|
<div className="flex gap-1.5 flex-wrap">
|
|
<BgTypeButton
|
|
active={bgType === "transparent"}
|
|
onClick={() => setBgType("transparent")}
|
|
checkerboard
|
|
label="Transparent"
|
|
/>
|
|
<BgTypeButton
|
|
active={bgType === "color"}
|
|
onClick={() => setBgType("color")}
|
|
color={bgColor}
|
|
label="Color"
|
|
/>
|
|
<BgTypeButton
|
|
active={bgType === "gradient"}
|
|
onClick={() => setBgType("gradient")}
|
|
gradient={{ color1: gradColor1, color2: gradColor2 }}
|
|
label="Gradient"
|
|
/>
|
|
<BgTypeButton
|
|
active={bgType === "image"}
|
|
onClick={() => setBgType("image")}
|
|
label="Image"
|
|
isImage
|
|
/>
|
|
</div>
|
|
|
|
{/* Color options */}
|
|
{bgType === "color" && (
|
|
<div className="space-y-2 pl-1">
|
|
<div className="flex gap-1.5 flex-wrap">
|
|
{COLOR_PRESETS.map((preset) => (
|
|
<button
|
|
key={preset.color}
|
|
type="button"
|
|
onClick={() => setBgColor(preset.color)}
|
|
className={`w-7 h-7 rounded border-2 transition-all ${
|
|
bgColor === preset.color ? "border-primary scale-110" : "border-border"
|
|
}`}
|
|
style={{ backgroundColor: preset.color }}
|
|
title={preset.label}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={bgColor}
|
|
onChange={(e) => setBgColor(e.target.value)}
|
|
className="w-7 h-7 rounded border border-border cursor-pointer"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={bgColor}
|
|
onChange={(e) => setBgColor(e.target.value)}
|
|
placeholder="#FF5500"
|
|
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gradient options */}
|
|
{bgType === "gradient" && (
|
|
<div className="space-y-2 pl-1">
|
|
<div className="flex gap-1.5 flex-wrap">
|
|
{GRADIENT_PRESETS.map((preset) => (
|
|
<button
|
|
key={preset.label}
|
|
type="button"
|
|
onClick={() => {
|
|
setGradColor1(preset.color1);
|
|
setGradColor2(preset.color2);
|
|
}}
|
|
className={`w-7 h-7 rounded border-2 transition-all ${
|
|
gradColor1 === preset.color1 && gradColor2 === preset.color2
|
|
? "border-primary scale-110"
|
|
: "border-border"
|
|
}`}
|
|
style={{
|
|
background: `linear-gradient(180deg, ${preset.color1}, ${preset.color2})`,
|
|
}}
|
|
title={preset.label}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={gradColor1}
|
|
onChange={(e) => setGradColor1(e.target.value)}
|
|
className="w-7 h-7 rounded border border-border cursor-pointer"
|
|
title="Start color"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">to</span>
|
|
<input
|
|
type="color"
|
|
value={gradColor2}
|
|
onChange={(e) => setGradColor2(e.target.value)}
|
|
className="w-7 h-7 rounded border border-border cursor-pointer"
|
|
title="End color"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-xs text-muted-foreground">Direction</span>
|
|
<span className="text-xs font-mono text-foreground">{gradAngle}°</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={360}
|
|
value={gradAngle}
|
|
onChange={(e) => setGradAngle(Number(e.target.value))}
|
|
className="w-full mt-0.5"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Image upload */}
|
|
{bgType === "image" && (
|
|
<div className="pl-1">
|
|
{bgImageFile ? (
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="text-foreground truncate flex-1">{bgImageFile.name}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setBgImageFile(null)}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-border text-xs text-muted-foreground cursor-pointer hover:border-primary/50 hover:text-foreground transition-colors">
|
|
<Upload className="h-3.5 w-3.5" />
|
|
Choose background image
|
|
<input
|
|
type="file"
|
|
accept="image/*,.heic,.heif,.hif"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) setBgImageFile(file);
|
|
}}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Effects */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setEffectsOpen(!effectsOpen)}
|
|
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground w-full pt-1"
|
|
>
|
|
{effectsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
Effects
|
|
{(blurEnabled || shadowEnabled) && (
|
|
<span className="ml-auto text-primary text-[10px] normal-case font-normal">active</span>
|
|
)}
|
|
</button>
|
|
|
|
{effectsOpen && (
|
|
<div className="space-y-3 pl-1">
|
|
{/* Blur */}
|
|
<div>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={blurEnabled}
|
|
onChange={(e) => setBlurEnabled(e.target.checked)}
|
|
className="rounded border-border accent-primary"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">Blur Background</span>
|
|
</label>
|
|
{blurEnabled && (
|
|
<div className="mt-1.5 pl-5">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-xs text-muted-foreground">Intensity</span>
|
|
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
|
|
{blurIntensity}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={1}
|
|
max={100}
|
|
value={blurIntensity}
|
|
onChange={(e) => setBlurIntensity(Number(e.target.value))}
|
|
className="w-full mt-0.5"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Shadow */}
|
|
<div>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={shadowEnabled}
|
|
onChange={(e) => setShadowEnabled(e.target.checked)}
|
|
className="rounded border-border accent-primary"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">Add Shadow</span>
|
|
</label>
|
|
{shadowEnabled && (
|
|
<div className="mt-1.5 pl-5">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-xs text-muted-foreground">Opacity</span>
|
|
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
|
|
{shadowOpacity}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={1}
|
|
max={100}
|
|
value={shadowOpacity}
|
|
onChange={(e) => setShadowOpacity(Number(e.target.value))}
|
|
className="w-full mt-0.5"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Background type button ──
|
|
|
|
function BgTypeButton({
|
|
active,
|
|
onClick,
|
|
label,
|
|
color,
|
|
gradient,
|
|
checkerboard,
|
|
isImage,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
label: string;
|
|
color?: string;
|
|
gradient?: { color1: string; color2: string };
|
|
checkerboard?: boolean;
|
|
isImage?: boolean;
|
|
}) {
|
|
let swatchStyle: React.CSSProperties = {};
|
|
if (checkerboard) {
|
|
swatchStyle = {
|
|
backgroundImage:
|
|
"linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)",
|
|
backgroundSize: "8px 8px",
|
|
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
|
|
};
|
|
} else if (gradient) {
|
|
swatchStyle = {
|
|
background: `linear-gradient(180deg, ${gradient.color1}, ${gradient.color2})`,
|
|
};
|
|
} else if (color) {
|
|
swatchStyle = { backgroundColor: color };
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors ${
|
|
active
|
|
? "border-primary bg-primary/10 text-primary"
|
|
: "border-border text-muted-foreground hover:border-primary/50"
|
|
}`}
|
|
>
|
|
{isImage ? (
|
|
<ImageIcon className="w-4 h-4" />
|
|
) : (
|
|
<span className="w-4 h-4 rounded-sm border border-border shrink-0" style={swatchStyle} />
|
|
)}
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ── Standalone tool page wrapper (two-phase flow) ──
|
|
|
|
interface RemoveBgSettingsProps {
|
|
onBgPreview?: (state: import("@/components/common/image-viewer").BgPreviewState | null) => void;
|
|
}
|
|
|
|
export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|
const { files } = useFileStore();
|
|
const {
|
|
processFiles,
|
|
processAllFiles,
|
|
processing,
|
|
error,
|
|
downloadUrl,
|
|
originalSize,
|
|
processedSize,
|
|
progress,
|
|
} = useToolProcessor("remove-background");
|
|
|
|
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
|
|
|
// Two-phase state: after Phase 1 (bg removal), store job info for Phase 2 (effects)
|
|
const [bgJobId, setBgJobId] = useState<string | null>(null);
|
|
const [bgFilename, setBgFilename] = useState<string | null>(null);
|
|
const [bgOriginalUrl, setBgOriginalUrl] = useState<string | null>(null);
|
|
const [effectsDownloadUrl, setEffectsDownloadUrl] = useState<string | null>(null);
|
|
const [applyingEffects, setApplyingEffects] = useState(false);
|
|
const [effectsError, setEffectsError] = useState<string | null>(null);
|
|
|
|
// Create a blob URL for the uploaded background image (for CSS preview).
|
|
// HEIC/HEIF files can't be displayed by browsers, so we decode them via the
|
|
// server preview endpoint first.
|
|
const [bgImageBlobUrl, setBgImageBlobUrl] = useState<string | null>(null);
|
|
const bgImageFileRef = useRef<File | null>(null);
|
|
useEffect(() => {
|
|
const file = settings._bgImageFile as File | undefined;
|
|
if (file && file !== bgImageFileRef.current) {
|
|
bgImageFileRef.current = file;
|
|
let revoke: (() => void) | null = null;
|
|
|
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
|
const isHeic = ext === "heic" || ext === "heif" || ext === "hif";
|
|
|
|
if (isHeic) {
|
|
// Decode HEIC via server preview endpoint
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
import("@/lib/api").then(({ formatHeaders }) => {
|
|
fetch("/api/v1/preview", {
|
|
method: "POST",
|
|
headers: formatHeaders(),
|
|
body: formData,
|
|
})
|
|
.then((res) => (res.ok ? res.blob() : null))
|
|
.then((blob) => {
|
|
if (blob && bgImageFileRef.current === file) {
|
|
const url = URL.createObjectURL(blob);
|
|
revoke = () => URL.revokeObjectURL(url);
|
|
setBgImageBlobUrl(url);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
});
|
|
} else {
|
|
const url = URL.createObjectURL(file);
|
|
revoke = () => URL.revokeObjectURL(url);
|
|
setBgImageBlobUrl(url);
|
|
}
|
|
|
|
return () => revoke?.();
|
|
}
|
|
if (!file && bgImageFileRef.current) {
|
|
bgImageFileRef.current = null;
|
|
setBgImageBlobUrl(null);
|
|
}
|
|
}, [settings._bgImageFile]);
|
|
|
|
const hasFile = files.length > 0;
|
|
const bgRemoved = bgJobId !== null && !processing;
|
|
|
|
// Build CSS preview state from current settings and send to tool-page
|
|
useEffect(() => {
|
|
if (!bgRemoved || !onBgPreview) return;
|
|
|
|
const bgType = (settings.backgroundType as string) || "transparent";
|
|
const blurEnabled = settings.blurEnabled as boolean;
|
|
const blurIntensity = (settings.blurIntensity as number) ?? 50;
|
|
const shadowEnabled = settings.shadowEnabled as boolean;
|
|
const shadowOpacity = (settings.shadowOpacity as number) ?? 35;
|
|
|
|
// When no effects are active and background is transparent, show the
|
|
// before/after slider instead of the CSS preview (pass null).
|
|
const hasAnyEffect = blurEnabled || shadowEnabled || bgType !== "transparent";
|
|
if (!hasAnyEffect) {
|
|
onBgPreview(null);
|
|
return;
|
|
}
|
|
|
|
const preview: import("@/components/common/image-viewer").BgPreviewState = {};
|
|
const sigma = 1 + (blurIntensity / 100) * 49;
|
|
|
|
// Determine background source and blur
|
|
if (bgType === "image" && bgImageBlobUrl) {
|
|
preview.backgroundSrc = bgImageBlobUrl;
|
|
if (blurEnabled) {
|
|
preview.backgroundBlur = `blur(${sigma}px)`;
|
|
}
|
|
} else if (blurEnabled && (bgType === "transparent" || bgType === "blur")) {
|
|
preview.backgroundSrc = bgOriginalUrl || undefined;
|
|
preview.backgroundBlur = `blur(${sigma}px)`;
|
|
} else if (bgType === "color") {
|
|
preview.containerBackground = (settings.backgroundColor as string) || "#FFFFFF";
|
|
} else if (bgType === "gradient") {
|
|
const c1 = (settings.gradientColor1 as string) || "#667eea";
|
|
const c2 = (settings.gradientColor2 as string) || "#764ba2";
|
|
const angle = (settings.gradientAngle as number) ?? 180;
|
|
preview.containerBackground = `linear-gradient(${angle}deg, ${c1}, ${c2})`;
|
|
} else {
|
|
preview.showCheckerboard = true;
|
|
}
|
|
|
|
// Shadow
|
|
if (shadowEnabled) {
|
|
const alpha = Math.round((shadowOpacity / 100) * 255)
|
|
.toString(16)
|
|
.padStart(2, "0");
|
|
preview.dropShadow = `drop-shadow(0px 10px 15px #000000${alpha})`;
|
|
}
|
|
|
|
onBgPreview(preview);
|
|
}, [
|
|
bgRemoved,
|
|
settings.backgroundType,
|
|
settings.backgroundColor,
|
|
settings.gradientColor1,
|
|
settings.gradientColor2,
|
|
settings.gradientAngle,
|
|
settings.blurEnabled,
|
|
settings.blurIntensity,
|
|
settings.shadowEnabled,
|
|
settings.shadowOpacity,
|
|
bgOriginalUrl,
|
|
bgImageBlobUrl,
|
|
onBgPreview,
|
|
]);
|
|
|
|
// Clear bg preview when no bg removal is active
|
|
useEffect(() => {
|
|
if (!bgRemoved && onBgPreview) onBgPreview(null);
|
|
}, [bgRemoved, onBgPreview]);
|
|
|
|
// Phase 1: Run AI background removal
|
|
const handleRemoveBg = () => {
|
|
// Reset Phase 2 state
|
|
setBgJobId(null);
|
|
setBgFilename(null);
|
|
setBgOriginalUrl(null);
|
|
setEffectsDownloadUrl(null);
|
|
|
|
if (files.length > 1) {
|
|
processAllFiles(files, settings);
|
|
return;
|
|
}
|
|
|
|
// Custom XHR to capture the extended response (jobId, maskUrl, originalUrl)
|
|
const formData = new FormData();
|
|
formData.append("file", files[0]);
|
|
|
|
const cleanSettings = { ...settings };
|
|
delete cleanSettings._bgImageFile;
|
|
formData.append("settings", JSON.stringify({ model: cleanSettings.model }));
|
|
|
|
const clientJobId = `bg-${Date.now()}`;
|
|
formData.append("clientJobId", clientJobId);
|
|
|
|
// Use processFiles for the progress/SSE flow - it handles everything
|
|
// But we need the extended response. Override via a fetch after processFiles completes.
|
|
// Actually, let's use processFiles and then fetch the job info.
|
|
processFiles(files, { model: settings.model });
|
|
};
|
|
|
|
// After processFiles completes, extract jobId from downloadUrl
|
|
useEffect(() => {
|
|
if (!downloadUrl || processing) return;
|
|
// downloadUrl format: /api/v1/download/{jobId}/{filename}
|
|
const parts = downloadUrl.split("/");
|
|
const jobId = parts[4]; // [0]='' [1]='api' [2]='v1' [3]='download' [4]=jobId [5]=filename
|
|
const filename = decodeURIComponent(parts[5] || "");
|
|
if (jobId && filename) {
|
|
setBgJobId(jobId);
|
|
// Derive the cached filenames from the mask filename
|
|
const baseName = filename.replace(/_mask\.png$|_nobg\.png$/, "");
|
|
setBgFilename(baseName || filename.replace(/\.[^.]+$/, ""));
|
|
// Build original URL from the job
|
|
const origFilename = `${baseName || filename.replace(/\.[^.]+$/, "")}_original.png`;
|
|
setBgOriginalUrl(`/api/v1/download/${jobId}/${encodeURIComponent(origFilename)}`);
|
|
}
|
|
}, [downloadUrl, processing]);
|
|
|
|
// Phase 2: Apply effects and download
|
|
const handleDownloadWithEffects = async () => {
|
|
if (!bgJobId || !bgFilename) return;
|
|
|
|
setApplyingEffects(true);
|
|
try {
|
|
const formData = new FormData();
|
|
const effectSettings: Record<string, unknown> = {
|
|
jobId: bgJobId,
|
|
filename: `${bgFilename}.png`,
|
|
backgroundType: settings.backgroundType,
|
|
backgroundColor: settings.backgroundColor,
|
|
gradientColor1: settings.gradientColor1,
|
|
gradientColor2: settings.gradientColor2,
|
|
gradientAngle: settings.gradientAngle,
|
|
blurEnabled: settings.blurEnabled,
|
|
blurIntensity: settings.blurIntensity,
|
|
shadowEnabled: settings.shadowEnabled,
|
|
shadowOpacity: settings.shadowOpacity,
|
|
};
|
|
formData.append("settings", JSON.stringify(effectSettings));
|
|
|
|
const bgImageFile = settings._bgImageFile as File | undefined;
|
|
if (bgImageFile) {
|
|
formData.append("backgroundImage", bgImageFile);
|
|
}
|
|
|
|
const headers = (await import("@/lib/api")).formatHeaders();
|
|
const response = await fetch("/api/v1/tools/remove-background/effects", {
|
|
method: "POST",
|
|
headers,
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => null);
|
|
throw new Error(body?.details || body?.error || `Effects failed: ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
setEffectsDownloadUrl(result.downloadUrl);
|
|
setEffectsError(null);
|
|
|
|
// Auto-trigger download
|
|
const a = document.createElement("a");
|
|
a.href = result.downloadUrl;
|
|
a.download = "";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
} catch (err) {
|
|
setEffectsError(err instanceof Error ? err.message : "Effects processing failed");
|
|
} finally {
|
|
setApplyingEffects(false);
|
|
}
|
|
};
|
|
|
|
const hasEffectsToApply =
|
|
settings.blurEnabled ||
|
|
settings.shadowEnabled ||
|
|
((settings.backgroundType as string) || "transparent") !== "transparent";
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<RemoveBgControls settings={settings} onChange={setSettings} />
|
|
|
|
{/* Errors */}
|
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
{effectsError && <p className="text-xs text-red-500">{effectsError}</p>}
|
|
|
|
{/* Size info */}
|
|
{originalSize != null && processedSize != null && !processing && (
|
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
|
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
|
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Phase 1: Remove Background button */}
|
|
{processing ? (
|
|
<ProgressCard
|
|
active={processing}
|
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
|
label="Removing background"
|
|
stage={progress.stage}
|
|
percent={progress.percent}
|
|
elapsed={progress.elapsed}
|
|
/>
|
|
) : !bgRemoved ? (
|
|
<button
|
|
type="button"
|
|
data-testid="remove-background-submit"
|
|
onClick={handleRemoveBg}
|
|
disabled={!hasFile || processing}
|
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{files.length > 1 ? `Remove Background (${files.length} files)` : "Remove Background"}
|
|
</button>
|
|
) : null}
|
|
|
|
{/* Phase 2: Single smart download button */}
|
|
{bgRemoved && files.length <= 1 && (
|
|
<div className="space-y-2">
|
|
{hasEffectsToApply ? (
|
|
<button
|
|
type="button"
|
|
data-testid="remove-background-download-effects"
|
|
onClick={handleDownloadWithEffects}
|
|
disabled={applyingEffects}
|
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
{applyingEffects ? "Rendering..." : "Download"}
|
|
</button>
|
|
) : (
|
|
<a
|
|
href={downloadUrl || ""}
|
|
download
|
|
data-testid="remove-background-download"
|
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 hover:bg-primary/90"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
Download
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|