feat: overhaul remove-background with effects pipeline, consolidate color tools

Remove Background:
- Two-phase flow: AI removes bg once, then effects adjust instantly
- Blur background effect with real-time CSS preview (portrait mode)
- Drop shadow effect with opacity control
- Gradient backgrounds with presets, custom colors, and angle
- Custom background image upload (including HEIC/HEIF)
- Solid color backgrounds moved from Python to Node.js/Sharp
- Effects-only API endpoint for instant re-renders without AI re-run
- HEIC/HEIF input support (decoded before passing to Python/rembg)
- Passport/ID photo checkbox defaults ON for People subject
- Before/after slider preserved when no effects active
- 15 comprehensive Playwright e2e tests

Color Tools:
- Consolidated 4 tools (brightness-contrast, saturation, color-channels,
  color-effects) into single "Adjust Colors" tool
- Added exposure, temperature, tint, hue, sharpness controls
- SVG filter-based live preview for all adjustments
- Backward-compatible URL redirects from old tool paths

Other fixes:
- Favicon tool: download button instead of auto-download
- Batch processing: HEIC filename extension fix
- File store: processedFilename field for proper batch downloads
This commit is contained in:
Siddharth Kumar Sah
2026-04-12 17:53:16 +08:00
parent dde70f70ad
commit 6c58f12262
28 changed files with 2236 additions and 525 deletions
+8
View File
@@ -106,6 +106,14 @@ export function App() {
<Route path="/files" element={<FilesPage />} />
<Route path="/fullscreen" element={<FullscreenGridPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
{/* Redirects: old color tools consolidated into adjust-colors */}
<Route
path="/brightness-contrast"
element={<Navigate to="/adjust-colors" replace />}
/>
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
@@ -2,6 +2,19 @@ import { Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { formatFileSize } from "@/lib/download";
export interface BgPreviewState {
/** URL of the original image (for blur background) */
backgroundSrc?: string;
/** CSS blur filter value for the background, e.g. "blur(15px)" */
backgroundBlur?: string;
/** CSS background for the container (color, gradient), e.g. "#FFFFFF" or "linear-gradient(...)" */
containerBackground?: string;
/** CSS drop-shadow filter for the subject */
dropShadow?: string;
/** Whether to show checkered (transparent) background */
showCheckerboard?: boolean;
}
interface ImageViewerProps {
src: string;
filename: string;
@@ -10,6 +23,7 @@ interface ImageViewerProps {
cssFlipH?: boolean;
cssFlipV?: boolean;
cssFilter?: string;
bgPreview?: BgPreviewState;
}
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200, 300];
@@ -23,6 +37,7 @@ export function ImageViewer({
cssFlipH,
cssFlipV,
cssFilter,
bgPreview,
}: ImageViewerProps) {
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
@@ -155,7 +170,13 @@ export function ImageViewer({
{/* Image area */}
<div
ref={containerRef}
className="flex-1 flex items-center justify-center overflow-auto bg-muted/20 p-4"
className="flex-1 flex items-center justify-center overflow-auto p-4"
style={{
background: bgPreview?.showCheckerboard
? "repeating-conic-gradient(#d0d0d0 0% 25%, #f0f0f0 0% 50%) 0 0 / 20px 20px"
: undefined,
backgroundColor: !bgPreview?.showCheckerboard ? "hsl(var(--muted) / 0.2)" : undefined,
}}
>
{loadError ? (
<div className="flex flex-col items-center gap-2 text-center">
@@ -164,6 +185,71 @@ export function ImageViewer({
This format cannot be displayed in the browser
</p>
</div>
) : bgPreview?.backgroundSrc || bgPreview?.containerBackground ? (
/* Layered bg-removal preview: background layer + subject layer */
<div
className="relative rounded-sm overflow-hidden"
style={{
...(fitMode === "fit"
? { maxWidth: "100%", maxHeight: "100%" }
: { transform: `scale(${zoom / 100})`, transformOrigin: "center center" }),
display: "inline-block",
}}
>
{/* Background layer: blurred original or solid/gradient */}
{bgPreview.backgroundSrc ? (
<img
src={bgPreview.backgroundSrc}
alt="background"
className="block select-none"
style={{
...(fitMode === "fit"
? { maxWidth: "100%", maxHeight: "100%", objectFit: "contain" as const }
: {}),
filter: bgPreview.backgroundBlur || undefined,
transition: "filter 0.15s ease",
}}
draggable={false}
/>
) : (
/* Solid color or gradient - use subject dimensions */
<img
src={src}
alt="background-sizer"
className="block select-none invisible"
style={
fitMode === "fit"
? { maxWidth: "100%", maxHeight: "100%", objectFit: "contain" as const }
: {}
}
draggable={false}
/>
)}
{/* Container background (color or gradient) behind subject but on top of bg image */}
{bgPreview.containerBackground && !bgPreview.backgroundSrc && (
<div
className="absolute inset-0"
style={{ background: bgPreview.containerBackground }}
/>
)}
{/* Subject layer: transparent PNG with optional drop shadow */}
<img
ref={imgRef}
src={src}
alt={filename}
onLoad={handleImageLoad}
onError={handleImageError}
className="absolute inset-0 w-full h-full select-none"
style={{
objectFit: "contain" as const,
filter: bgPreview.dropShadow || undefined,
transition: "filter 0.15s ease",
}}
draggable={false}
/>
</div>
) : (
<img
ref={imgRef}
+212 -115
View File
@@ -1,10 +1,9 @@
import { Download } from "lucide-react";
import { ChevronDown, ChevronRight, Download } 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 Tab = "basic" | "channels" | "effects";
type Effect = "none" | "grayscale" | "sepia" | "invert";
interface ColorControlsProps {
@@ -14,21 +13,25 @@ interface ColorControlsProps {
}
export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorControlsProps) {
const [tab, setTab] = useState<Tab>(() => {
if (toolId === "color-channels") return "channels";
if (toolId === "color-effects") return "effects";
return "basic";
});
// Basic adjustments
// Light
const [brightness, setBrightness] = useState(0);
const [contrast, setContrast] = useState(0);
const [saturation, setSaturation] = useState(0);
const [exposure, setExposure] = useState(0);
// Color channels
// Color
const [saturation, setSaturation] = useState(0);
const [temperature, setTemperature] = useState(0);
const [tint, setTint] = useState(0);
const [hue, setHue] = useState(0);
// Detail
const [sharpness, setSharpness] = useState(0);
// Channels
const [red, setRed] = useState(100);
const [green, setGreen] = useState(100);
const [blue, setBlue] = useState(100);
const [channelsOpen, setChannelsOpen] = useState(false);
// Effects
const [effect, setEffect] = useState<Effect>("none");
@@ -36,20 +39,51 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Report settings on change
useEffect(() => {
onChangeRef.current?.({ brightness, contrast, saturation, red, green, blue, effect });
}, [brightness, contrast, saturation, red, green, blue, effect]);
onChangeRef.current?.({
brightness,
contrast,
exposure,
saturation,
temperature,
tint,
hue,
sharpness,
red,
green,
blue,
effect,
});
}, [
brightness,
contrast,
exposure,
saturation,
temperature,
tint,
hue,
sharpness,
red,
green,
blue,
effect,
]);
// Emit CSS filter for live preview
// CSS filter preview
const hasChannelChanges = red !== 100 || green !== 100 || blue !== 100;
const hasTempTint = temperature !== 0 || tint !== 0;
useEffect(() => {
if (!onPreviewFilter) return;
const parts: string[] = [];
if (brightness !== 0) parts.push(`brightness(${1 + brightness / 100})`);
if (contrast !== 0) parts.push(`contrast(${1 + contrast / 100})`);
if (exposure !== 0) parts.push(`brightness(${1 + exposure / 200})`);
if (saturation !== 0) parts.push(`saturate(${1 + saturation / 100})`);
if (hue !== 0) parts.push(`hue-rotate(${hue}deg)`);
if (hasTempTint) parts.push("url(#stirling-temp-tint-filter)");
if (hasChannelChanges) parts.push("url(#stirling-channel-filter)");
if (sharpness > 0) parts.push("url(#stirling-sharpen-filter)");
if (effect === "grayscale") parts.push("grayscale(1)");
if (effect === "sepia") parts.push("sepia(1)");
if (effect === "invert") parts.push("invert(1)");
@@ -57,33 +91,49 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro
}, [
brightness,
contrast,
exposure,
saturation,
red,
green,
blue,
effect,
temperature,
tint,
hue,
sharpness,
hasChannelChanges,
hasTempTint,
effect,
onPreviewFilter,
]);
const hasChanges =
brightness !== 0 ||
contrast !== 0 ||
exposure !== 0 ||
saturation !== 0 ||
temperature !== 0 ||
tint !== 0 ||
hue !== 0 ||
sharpness !== 0 ||
red !== 100 ||
green !== 100 ||
blue !== 100 ||
effect !== "none";
const tabs: { id: Tab; label: string }[] = [
{ id: "basic", label: "Basic" },
{ id: "channels", label: "Channels" },
{ id: "effects", label: "Effects" },
];
// Build SVG filter matrices
const tempT = temperature / 100;
const tintN = tint / 100;
return (
<>
{/* Hidden SVG filter for color channel preview */}
{/* Hidden SVG filters for live preview */}
{hasTempTint && (
<svg width="0" height="0" style={{ position: "absolute" }}>
<filter id="stirling-temp-tint-filter" colorInterpolationFilters="sRGB">
<feColorMatrix
type="matrix"
values={`${1 + tempT * 0.15 + tintN * 0.1} 0 0 0 0 0 ${1 + tempT * 0.05 - tintN * 0.15} 0 0 0 0 0 ${1 - tempT * 0.15 + tintN * 0.1} 0 0 0 0 0 1 0`}
/>
</filter>
</svg>
)}
{hasChannelChanges && (
<svg width="0" height="0" style={{ position: "absolute" }}>
<filter id="stirling-channel-filter" colorInterpolationFilters="sRGB">
@@ -94,52 +144,116 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro
</filter>
</svg>
)}
{/* Tabs */}
<div className="flex gap-1">
{tabs.map((t) => (
{sharpness > 0 && (
<svg width="0" height="0" style={{ position: "absolute" }}>
<filter id="stirling-sharpen-filter" colorInterpolationFilters="sRGB">
<feConvolveMatrix
order="3"
preserveAlpha="true"
kernelMatrix={`0 ${-sharpness / 100} 0 ${-sharpness / 100} ${1 + (4 * sharpness) / 100} ${-sharpness / 100} 0 ${-sharpness / 100} 0`}
/>
</filter>
</svg>
)}
{/* Light section */}
<SectionLabel>Light</SectionLabel>
<div className="space-y-2">
<SliderControl
label="Brightness"
value={brightness}
onChange={setBrightness}
min={-100}
max={100}
/>
<SliderControl
label="Contrast"
value={contrast}
onChange={setContrast}
min={-100}
max={100}
/>
<SliderControl
label="Exposure"
value={exposure}
onChange={setExposure}
min={-100}
max={100}
/>
</div>
{/* Color section */}
<SectionLabel>Color</SectionLabel>
<div className="space-y-2">
<SliderControl
label="Saturation"
value={saturation}
onChange={setSaturation}
min={-100}
max={100}
/>
<SliderControl
label="Temperature"
value={temperature}
onChange={setTemperature}
min={-100}
max={100}
hint="cool / warm"
/>
<SliderControl
label="Tint"
value={tint}
onChange={setTint}
min={-100}
max={100}
hint="green / magenta"
/>
<SliderControl label="Hue" value={hue} onChange={setHue} min={-180} max={180} />
</div>
{/* Detail section */}
<SectionLabel>Detail</SectionLabel>
<div className="space-y-2">
<SliderControl
label="Sharpness"
value={sharpness}
onChange={setSharpness}
min={0}
max={100}
/>
</div>
{/* Effects section */}
<SectionLabel>Effects</SectionLabel>
<div className="grid grid-cols-2 gap-1">
{(["none", "grayscale", "sepia", "invert"] as const).map((e) => (
<button
type="button"
key={t.id}
onClick={() => setTab(t.id)}
className={`flex-1 text-xs py-1.5 rounded ${
tab === t.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
key={e}
onClick={() => setEffect(e)}
className={`text-xs py-2 rounded capitalize transition-colors ${
effect === e
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
}`}
>
{t.label}
{e}
</button>
))}
</div>
{/* Basic Adjustments */}
{tab === "basic" && (
<div className="space-y-3">
<SliderControl
label="Brightness"
value={brightness}
onChange={setBrightness}
min={-100}
max={100}
/>
<SliderControl
label="Contrast"
value={contrast}
onChange={setContrast}
min={-100}
max={100}
/>
<SliderControl
label="Saturation"
value={saturation}
onChange={setSaturation}
min={-100}
max={100}
/>
</div>
)}
{/* Color Channels */}
{tab === "channels" && (
<div className="space-y-3">
{/* Color Channels (expandable) */}
<button
type="button"
onClick={() => setChannelsOpen(!channelsOpen)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground w-full"
>
{channelsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Color Channels
{hasChannelChanges && <span className="ml-auto text-primary text-[10px]">modified</span>}
</button>
{channelsOpen && (
<div className="space-y-2 pl-1">
<SliderControl
label="Red"
value={red}
@@ -167,37 +281,19 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro
</div>
)}
{/* Effects */}
{tab === "effects" && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Color Effect</p>
<div className="grid grid-cols-2 gap-1">
{(["none", "grayscale", "sepia", "invert"] as const).map((e) => (
<button
type="button"
key={e}
onClick={() => setEffect(e)}
className={`text-xs py-2 rounded capitalize transition-colors ${
effect === e
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
}`}
>
{e}
</button>
))}
</div>
</div>
)}
{/* Reset button */}
{/* Reset */}
{hasChanges && (
<button
type="button"
onClick={() => {
setBrightness(0);
setContrast(0);
setExposure(0);
setSaturation(0);
setTemperature(0);
setTint(0);
setHue(0);
setSharpness(0);
setRed(100);
setGreen(100);
setBlue(100);
@@ -212,8 +308,9 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro
);
}
// ── ColorSettings wrapper (handles processing + download) ─────────
interface ColorSettingsProps {
/** The specific tool ID to use for processing */
toolId: string;
onPreviewFilter?: (filter: string) => void;
}
@@ -231,15 +328,7 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
progress,
} = useToolProcessor(toolId);
const [settings, setSettings] = useState<Record<string, unknown>>({
brightness: 0,
contrast: 0,
saturation: 0,
red: 100,
green: 100,
blue: 100,
effect: "none",
});
const [settings, setSettings] = useState<Record<string, unknown>>({});
const handleProcess = () => {
if (files.length > 1) {
@@ -250,14 +339,11 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
};
const hasFile = files.length > 0;
const hasChanges =
settings.brightness !== 0 ||
settings.contrast !== 0 ||
settings.saturation !== 0 ||
settings.red !== 100 ||
settings.green !== 100 ||
settings.blue !== 100 ||
settings.effect !== "none";
const hasChanges = Object.entries(settings).some(([key, val]) => {
if (key === "effect") return val !== "none";
if (key === "red" || key === "green" || key === "blue") return val !== 100;
return val !== 0;
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -265,13 +351,11 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-3">
<ColorControls toolId={toolId} onChange={setSettings} onPreviewFilter={onPreviewFilter} />
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Size info */}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
@@ -279,7 +363,6 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
</div>
)}
{/* Process */}
{processing ? (
<ProgressCard
active={processing}
@@ -292,7 +375,7 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
) : (
<button
type="submit"
data-testid={`${toolId}-submit`}
data-testid="adjust-colors-submit"
disabled={!hasFile || !hasChanges || 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"
>
@@ -300,12 +383,12 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
</button>
)}
{/* Download */}
{downloadUrl && (
{/* Download (single-file only - batch uses Download All ZIP in tool-page) */}
{downloadUrl && files.length <= 1 && (
<a
href={downloadUrl}
download
data-testid={`${toolId}-download`}
data-testid="adjust-colors-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
@@ -316,7 +399,16 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
);
}
/** Reusable slider control */
// ── Shared sub-components ─────────────────────────────────────────
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
{children}
</p>
);
}
function SliderControl({
label,
value,
@@ -324,6 +416,7 @@ function SliderControl({
min,
max,
color,
hint,
}: {
label: string;
value: number;
@@ -331,6 +424,7 @@ function SliderControl({
min: number;
max: number;
color?: string;
hint?: string;
}) {
const id = `color-slider-${label.toLowerCase()}`;
return (
@@ -338,8 +432,11 @@ function SliderControl({
<div className="flex justify-between items-center">
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{label}
{hint && <span className="text-[10px] text-muted-foreground/60 ml-1">({hint})</span>}
</label>
<span className="text-xs font-mono text-foreground">{value}</span>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
{value}
</span>
</div>
<input
id={id}
@@ -1,5 +1,7 @@
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
import { Download } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { ProgressCard } from "@/components/common/progress-card";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
@@ -14,57 +16,124 @@ const SIZES = [
];
export function FaviconSettings() {
const { files, processing, error, setProcessing, setError } = useFileStore();
const [downloadReady, setDownloadReady] = useState(false);
const { files, error, setProcessing, setError } = useFileStore();
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState({
phase: "idle" as "idle" | "uploading" | "processing" | "complete",
percent: 0,
elapsed: 0,
});
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const xhrRef = useRef<XMLHttpRequest | null>(null);
const handleProcess = async () => {
if (files.length === 0) return;
useEffect(() => {
return () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (xhrRef.current) xhrRef.current.abort();
};
}, []);
setProcessing(true);
setError(null);
setDownloadReady(false);
try {
const formData = new FormData();
formData.append("file", files[0]);
const res = await fetch("/api/v1/tools/favicon", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Failed: ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "favicons.zip";
a.click();
URL.revokeObjectURL(url);
setDownloadReady(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Generation failed");
} finally {
setProcessing(false);
}
const cleanup = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
elapsedRef.current = null;
processingTimerRef.current = null;
setBusy(false);
setProcessing(false);
};
const hasFile = files.length > 0;
const handleProcess = useCallback(() => {
if (files.length === 0) return;
flushSync(() => {
setBusy(true);
setProcessing(true);
setError(null);
if (downloadUrl) {
URL.revokeObjectURL(downloadUrl);
setDownloadUrl(null);
}
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
});
const startTime = Date.now();
elapsedRef.current = setInterval(() => {
setProgress((prev) => ({ ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) }));
}, 1000);
const formData = new FormData();
for (const file of files) {
formData.append("file", file);
}
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
xhr.responseType = "blob";
xhr.timeout = 180_000;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const uploadPercent = (event.loaded / event.total) * 40;
setProgress((prev) =>
prev.phase === "uploading" ? { ...prev, percent: uploadPercent } : prev,
);
}
};
xhr.upload.onload = () => {
setProgress((prev) => ({ ...prev, phase: "processing", percent: 40 }));
const step = (95 - 40) / 90;
processingTimerRef.current = setInterval(() => {
setProgress((prev) => {
if (prev.phase !== "processing") return prev;
return { ...prev, percent: Math.min(95, prev.percent + step) };
});
}, 500);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const blob = xhr.response as Blob;
setDownloadUrl(URL.createObjectURL(blob));
setProgress((prev) => ({ ...prev, phase: "complete", percent: 100 }));
} else {
setError(`Favicon generation failed: ${xhr.status}`);
}
cleanup();
};
xhr.onerror = () => {
setError("Network error during favicon generation");
cleanup();
};
xhr.ontimeout = () => {
setError("Request timed out - the server may be overloaded");
cleanup();
};
xhr.open("POST", "/api/v1/tools/favicon");
formatHeaders().forEach((value, key) => {
xhr.setRequestHeader(key, value);
});
xhr.send(formData);
}, [files, setProcessing, setError, downloadUrl]);
const hasFiles = files.length > 0;
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
Upload a square image (recommended 512x512 or larger) to generate all favicon and app icon
sizes.
Upload square images (recommended 512x512 or larger) to generate all favicon and app icon
sizes.{" "}
{files.length > 1 && `Each of the ${files.length} images gets its own folder in the ZIP.`}
</p>
<div>
<p className="text-xs font-medium text-muted-foreground">Generated Sizes</p>
<p className="text-xs font-medium text-muted-foreground">Generated Sizes (per image)</p>
<div className="mt-1 space-y-0.5">
{SIZES.map((s) => (
<div key={s.name} className="flex justify-between text-xs text-foreground">
@@ -78,21 +147,41 @@ export function FaviconSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
data-testid="favicon-submit"
onClick={handleProcess}
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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Generating..." : "Generate Favicons"}
</button>
{busy ? (
<ProgressCard
active={busy}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Generating Favicons"
stage={
progress.phase === "uploading"
? "Uploading images..."
: `Processing ${files.length} image${files.length !== 1 ? "s" : ""}...`
}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="favicon-submit"
onClick={handleProcess}
disabled={!hasFiles || busy}
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"
>
Generate Favicons ({files.length} image{files.length !== 1 ? "s" : ""})
</button>
)}
{downloadReady && (
<p className="text-xs text-green-600 flex items-center gap-1">
<Download className="h-3 w-3" /> ZIP downloaded successfully
</p>
{downloadUrl && (
<a
href={downloadUrl}
download="favicons.zip"
data-testid="favicon-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download Favicons ZIP
</a>
)}
</div>
);
@@ -15,12 +15,7 @@ import { TextOverlayControls } from "./text-overlay-settings";
import { UpscaleControls } from "./upscale-settings";
import { WatermarkTextControls } from "./watermark-text-settings";
const COLOR_TOOL_IDS = new Set([
"brightness-contrast",
"saturation",
"color-channels",
"color-effects",
]);
const COLOR_TOOL_IDS = new Set(["adjust-colors"]);
interface PipelineStepSettingsProps {
toolId: string;
@@ -1,4 +1,12 @@
import { Download, ImageIcon, Package, User } from "lucide-react";
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";
@@ -6,6 +14,7 @@ import { useFileStore } from "@/stores/file-store";
type SubjectType = "people" | "products" | "general";
type Quality = "fast" | "balanced" | "best";
type BackgroundType = "transparent" | "color" | "gradient" | "image";
type BgModel =
| "birefnet-general"
@@ -32,15 +41,33 @@ const QUALITY_OPTIONS: { value: Quality; label: string }[] = [
{ value: "best", label: "Best" },
];
const BG_PRESETS = [
{ color: "", label: "Transparent", preview: "checkerboard" },
{ color: "#FFFFFF", label: "White", preview: "#FFFFFF" },
{ color: "#000000", label: "Black", preview: "#000000" },
{ color: "#FF0000", label: "Red", preview: "#FF0000" },
{ color: "#00FF00", label: "Green", preview: "#00FF00" },
{ color: "#0000FF", label: "Blue", preview: "#0000FF" },
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 {
@@ -51,10 +78,27 @@ export interface RemoveBgControlsProps {
export function RemoveBgControls({ settings, onChange }: RemoveBgControlsProps) {
const [subject, setSubject] = useState<SubjectType>("people");
const [quality, setQuality] = useState<Quality>("balanced");
const [isPassport, setIsPassport] = useState(false);
const [bgColor, setBgColor] = useState((settings.backgroundColor as string) || "");
const [isPassport, setIsPassport] = useState(true);
const model = isPassport ? "birefnet-portrait" : MODEL_MAP[subject][quality];
// 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);
const model =
isPassport && subject === "people" ? "birefnet-portrait" : MODEL_MAP[subject][quality];
const onChangeRef = useRef(onChange);
useEffect(() => {
@@ -63,42 +107,75 @@ export function RemoveBgControls({ settings, onChange }: RemoveBgControlsProps)
// Sync settings on every control change
useEffect(() => {
const next: Record<string, unknown> = { model };
if (bgColor) next.backgroundColor = bgColor;
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, bgColor]);
}, [
model,
bgType,
bgColor,
gradColor1,
gradColor2,
gradAngle,
bgImageFile,
blurEnabled,
blurIntensity,
shadowEnabled,
shadowOpacity,
]);
return (
<div className="space-y-4">
<div className="space-y-3">
{/* Subject type */}
<div>
<p className="text-sm font-medium text-muted-foreground">What's in the photo?</p>
<div className="grid grid-cols-3 gap-1.5 mt-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);
}}
className={`flex flex-col items-center gap-1 py-2.5 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>
<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 */}
{/* Passport checkbox - only for people, default ON */}
{subject === "people" && (
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -112,101 +189,586 @@ export function RemoveBgControls({ settings, onChange }: RemoveBgControlsProps)
)}
{/* Quality */}
<div>
<p className="text-sm font-medium text-muted-foreground">Quality</p>
<div className="grid grid-cols-3 gap-1.5 mt-1.5">
{QUALITY_OPTIONS.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 ${
quality === opt.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
{opt.label}
</button>
))}
</div>
<SectionLabel>Quality</SectionLabel>
<div className="grid grid-cols-3 gap-1.5">
{QUALITY_OPTIONS.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 ${
quality === opt.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
{opt.label}
</button>
))}
</div>
{/* Background color - intuitive preset buttons */}
<div>
<p className="text-sm font-medium text-muted-foreground">Output Background</p>
<div className="flex gap-1.5 mt-1.5 flex-wrap">
{BG_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setBgColor(preset.color)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors ${
bgColor === preset.color
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
<span
className="w-4 h-4 rounded-sm border border-border shrink-0"
style={
preset.preview === "checkerboard"
? {
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",
}
: { backgroundColor: preset.preview }
}
{/* 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"
/>
{preset.label}
</button>
))}
</div>
<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>
)}
{/* Custom color picker */}
<div className="flex items-center gap-2 mt-2">
<input
type="color"
value={bgColor || "#ffffff"}
onChange={(e) => setBgColor(e.target.value)}
className="w-8 h-8 rounded border border-border cursor-pointer"
/>
<input
type="text"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
placeholder="Custom hex (#FF5500)"
className="flex-1 px-2 py-1.5 rounded border border-border bg-background text-xs text-foreground"
/>
</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>
);
}
// ── Standalone tool page wrapper ──────────────────────────────────────
// ── Background type button ──
export function RemoveBgSettings() {
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, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("remove-background");
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor("remove-background");
const [settings, setSettings] = useState<Record<string, unknown>>({});
const handleProcess = () => {
processFiles(files, settings);
};
// 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} />
{/* Error */}
{/* 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 && (
@@ -216,7 +778,7 @@ export function RemoveBgSettings() {
</div>
)}
{/* Process button */}
{/* Phase 1: Remove Background button */}
{processing ? (
<ProgressCard
active={processing}
@@ -226,29 +788,44 @@ export function RemoveBgSettings() {
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
) : !bgRemoved ? (
<button
type="button"
data-testid="remove-background-submit"
onClick={handleProcess}
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"
>
Remove Background
{files.length > 1 ? `Remove Background (${files.length} files)` : "Remove Background"}
</button>
)}
) : null}
{/* Download */}
{downloadUrl && !processing && (
<a
href={downloadUrl}
download
data-testid="remove-background-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
{/* 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>
);
+10 -2
View File
@@ -124,10 +124,17 @@ export function useToolProcessor(toolId: string) {
}
}
// Build form data
// Build form data - extract any File objects from settings before JSON serialization
const cleanSettings = { ...settings };
const bgImageFile = cleanSettings._bgImageFile as File | undefined;
delete cleanSettings._bgImageFile;
const formData = new FormData();
formData.append("file", files[0]);
formData.append("settings", JSON.stringify(settings));
formData.append("settings", JSON.stringify(cleanSettings));
if (bgImageFile) {
formData.append("backgroundImage", bgImageFile);
}
if (isAiTool) {
formData.append("clientJobId", clientJobId);
}
@@ -368,6 +375,7 @@ export function useToolProcessor(toolId: string) {
const blob = new Blob([extracted[processedName] as BlobPart]);
updateEntry(i, {
processedUrl: URL.createObjectURL(blob),
processedFilename: processedName,
processedSize: blob.size,
status: "completed",
error: null,
+1 -4
View File
@@ -5,10 +5,7 @@ const TOOL_SUGGESTIONS: Record<string, string[]> = {
convert: ["compress", "strip-metadata", "watermark-text"],
compress: ["convert", "strip-metadata", "watermark-text"],
"strip-metadata": ["compress", "convert"],
"brightness-contrast": ["compress", "convert", "resize"],
saturation: ["compress", "convert", "resize"],
"color-channels": ["compress", "convert"],
"color-effects": ["compress", "convert", "resize"],
"adjust-colors": ["compress", "convert", "resize"],
"replace-color": ["compress", "convert"],
"remove-background": ["resize", "compress", "convert"],
upscale: ["compress", "convert"],
+11 -12
View File
@@ -7,6 +7,7 @@
import type React from "react";
import { lazy } from "react";
import type { Crop } from "react-image-crop";
import type { BgPreviewState } from "@/components/common/image-viewer";
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
import type { PreviewTransform } from "@/components/tools/rotate-settings";
@@ -53,6 +54,7 @@ export interface ToolRegistryEntry {
Settings: React.ComponentType<{
onPreviewTransform?: (t: PreviewTransform) => void;
onPreviewFilter?: (filter: string) => void;
onBgPreview?: (state: BgPreviewState | null) => void;
cropProps?: CropProps;
eraserProps?: EraserProps;
}>;
@@ -253,18 +255,15 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
["strip-metadata", { displayMode: "no-comparison", Settings: StripMetadataSettings }],
["edit-metadata", { displayMode: "no-comparison", Settings: EditMetadataSettings }],
// Color adjustments (all share ColorSettings with different toolId)
...(["brightness-contrast", "saturation", "color-channels", "color-effects"] as const).map(
(id) =>
[
id,
{
displayMode: "live-preview" as DisplayMode,
livePreview: true,
Settings: makeColorSettingsComponent(id) as never,
},
] as const,
),
// Color adjustments (consolidated)
[
"adjust-colors",
{
displayMode: "live-preview" as DisplayMode,
livePreview: true,
Settings: makeColorSettingsComponent("adjust-colors") as never,
},
],
// Watermark & Overlay
["watermark-text", { displayMode: "before-after", Settings: WatermarkTextSettings }],
+41 -23
View File
@@ -6,7 +6,7 @@ import type { Crop } from "react-image-crop";
import { useParams } from "react-router-dom";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { Dropzone } from "@/components/common/dropzone";
import { ImageViewer } from "@/components/common/image-viewer";
import { type BgPreviewState, ImageViewer } from "@/components/common/image-viewer";
import { ReviewPanel } from "@/components/common/review-panel";
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
@@ -33,8 +33,10 @@ const BROWSER_PREVIEWABLE_EXTS = new Set([
"avif",
]);
function canBrowserPreview(url: string): boolean {
const ext = decodeURIComponent(url).split(".").pop()?.toLowerCase() ?? "";
function canBrowserPreview(url: string, filename?: string | null): boolean {
// For blob URLs from batch processing, check the real filename instead
const source = filename ?? url;
const ext = decodeURIComponent(source).split(".").pop()?.toLowerCase() ?? "";
return BROWSER_PREVIEWABLE_EXTS.has(ext);
}
@@ -137,6 +139,7 @@ export function ToolPage() {
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
const [previewFilter, setPreviewFilter] = useState<string>("");
const [bgPreview, setBgPreview] = useState<BgPreviewState | null>(null);
const [cropCrop, setCropCrop] = useState<Crop>({
unit: "%",
@@ -227,12 +230,17 @@ export function ToolPage() {
const isNoDropzone = displayMode === "no-dropzone";
const isLivePreview = registryEntry.livePreview ?? false;
// Derive processed file info from the actual download URL (has correct extension)
const processedFileName = processedUrl
? decodeURIComponent(processedUrl.split("/").pop() ?? "processed-image")
: "processed-image";
// Derive processed file info: use stored filename for batch results (blob URLs),
// fall back to parsing the download URL for single-file results
const processedFileName =
currentEntry?.processedFilename ??
(processedUrl
? decodeURIComponent(processedUrl.split("/").pop() ?? "processed-image")
: "processed-image");
const processedFileType = processedFileName.split(".").pop()?.toUpperCase() || "IMAGE";
const isProcessedPreviewable = processedUrl ? canBrowserPreview(processedUrl) : false;
const isProcessedPreviewable = processedUrl
? canBrowserPreview(processedUrl, currentEntry?.processedFilename)
: false;
// Use server-generated preview for non-previewable formats (HEIC, TIFF).
// Always a string when hasProcessed is true (processedUrl is non-null).
const displayUrl = (processedPreviewUrl ?? processedUrl) as string;
@@ -241,6 +249,7 @@ export function ToolPage() {
const settingsProps = {
onPreviewTransform: isLivePreview ? setPreviewTransform : undefined,
onPreviewFilter: isLivePreview ? setPreviewFilter : undefined,
onBgPreview: setBgPreview,
cropProps:
displayMode === "interactive-crop"
? {
@@ -360,6 +369,18 @@ export function ToolPage() {
}
if (hasProcessed && originalBlobUrl) {
// When bg preview state is set (remove-background effects mode),
// show the ImageViewer with layered CSS preview instead of before/after slider
if (bgPreview) {
return (
<ImageViewer
src={displayUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
bgPreview={bgPreview}
/>
);
}
return (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
@@ -460,6 +481,18 @@ export function ToolPage() {
</Suspense>
</div>
{/* Batch download — shown right after settings for easy access */}
{entries.length > 1 && hasProcessed && batchZipBlob && (
<button
type="button"
onClick={handleDownloadAll}
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download All (ZIP)
</button>
)}
{hasProcessed && processedSize != null && (
<ReviewPanel
filename={processedFileName}
@@ -540,21 +573,6 @@ export function ToolPage() {
</div>
{renderSettingsContent()}
{/* Batch download */}
{entries.length > 1 && hasProcessed && batchZipBlob && (
<div className="space-y-2">
<div className="border-t border-border pt-2" />
<button
type="button"
onClick={handleDownloadAll}
className="w-full py-2 rounded-lg bg-primary text-primary-foreground flex items-center justify-center gap-1.5 text-xs font-medium hover:bg-primary/90"
>
<Download className="h-3.5 w-3.5" />
Download All (ZIP)
</button>
</div>
)}
</div>
{/* Main area: image viewer */}
+5
View File
@@ -7,6 +7,7 @@ export interface FileEntry {
previewLoading: boolean;
processedUrl: string | null;
processedPreviewUrl: string | null;
processedFilename: string | null;
processedSize: number | null;
originalSize: number;
status: "pending" | "processing" | "completed" | "failed";
@@ -25,6 +26,7 @@ function createEntry(file: File): FileEntry {
previewLoading: needsServerPreview(file),
processedUrl: null,
processedPreviewUrl: null,
processedFilename: null,
processedSize: null,
originalSize: file.size,
status: "pending",
@@ -280,6 +282,7 @@ export const useFileStore = create<FileState>((set, get) => ({
...updated[selectedIndex],
processedUrl: url,
processedPreviewUrl: previewUrl ?? null,
processedFilename: null,
status: "completed",
};
} else {
@@ -287,6 +290,7 @@ export const useFileStore = create<FileState>((set, get) => ({
...updated[selectedIndex],
processedUrl: null,
processedPreviewUrl: null,
processedFilename: null,
status: "pending",
};
}
@@ -314,6 +318,7 @@ export const useFileStore = create<FileState>((set, get) => ({
...e,
processedUrl: null,
processedPreviewUrl: null,
processedFilename: null,
processedSize: null,
status: "pending" as const,
error: null,