mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user