feat(tools): remove background from animated GIFs (WebP, APNG) (#502)

Adds a dedicated remove-gif-background AI tool: removes the background from an animated GIF, WebP, or APNG frame by frame and reassembles a transparent (or composited) animation in WebP, APNG, or GIF, with full per-frame effects. Reuses the background-removal bundle. Verified end-to-end with the real rembg model.

Closes #496.
This commit is contained in:
SnapOtter
2026-07-11 19:53:00 +08:00
committed by GitHub
parent e7cfc00fe1
commit cb5db59f77
50 changed files with 2240 additions and 6 deletions
+27
View File
@@ -0,0 +1,27 @@
import { extname } from "node:path";
import sharp from "sharp";
import { apngFrameCount } from "./apng.js";
export interface AnimationInfo {
animated: boolean;
frames: number;
}
/**
* Detect whether an uploaded image is a multi-frame animation and count frames.
*
* Format-routed because Sharp/libvips is blind to APNG: GIF and animated WebP
* expose `metadata().pages`, but PNG/APNG must be read via the `acTL` chunk
* (`apngFrameCount`). Used by the remove-gif-background route to reject stills
* and enforce the frame cap before enqueuing.
*/
export async function detectAnimation(buf: Buffer, filename: string): Promise<AnimationInfo> {
const ext = extname(filename).toLowerCase();
if (ext === ".png" || ext === ".apng") {
const n = apngFrameCount(buf) ?? 1;
return { animated: n > 1, frames: n };
}
const meta = await sharp(buf, { animated: true }).metadata();
const pages = meta.pages ?? 1;
return { animated: pages > 1, frames: pages };
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Count frames in a PNG buffer by reading the APNG `acTL` (animation control)
* chunk. Sharp/libvips cannot see APNG frames at all (`metadata().pages` is
* undefined for an APNG, indistinguishable from a still PNG), so parsing the
* chunk stream is the only way to detect animation for .png/.apng inputs.
*
* Returns:
* - `null` if the buffer is not a PNG at all,
* - `1` for a still PNG (no `acTL` before the first `IDAT`),
* - the `num_frames` value from `acTL` for an APNG.
*/
const PNG_SIGNATURE = 0x89504e47; // first 4 bytes of the 8-byte PNG signature
export function apngFrameCount(input: Buffer | Uint8Array): number | null {
const b = Buffer.isBuffer(input) ? input : Buffer.from(input);
if (b.length < 8 || b.readUInt32BE(0) !== PNG_SIGNATURE) return null;
let off = 8; // skip the 8-byte signature
while (off + 8 <= b.length) {
const len = b.readUInt32BE(off);
const type = b.toString("ascii", off + 4, off + 8);
if (type === "acTL") {
// acTL data: num_frames (uint32) + num_plays (uint32)
if (off + 12 > b.length) return 1;
return b.readUInt32BE(off + 8);
}
if (type === "IDAT") return 1; // pixel data before any acTL => still PNG
off += 12 + len; // 4 length + 4 type + len data + 4 CRC
}
return 1;
}
+1
View File
@@ -32,6 +32,7 @@ const envSchema = z
MAX_BATCH_SIZE: z.coerce.number().default(100),
CONCURRENT_JOBS: z.coerce.number().default(0),
MAX_MEGAPIXELS: z.coerce.number().default(0),
GIF_BG_MAX_FRAMES: z.coerce.number().default(150),
RATE_LIMIT_PER_MIN: z.coerce.number().default(1000),
API_KEYS_RATE_LIMIT_PER_MIN: z.coerce.number().default(30),
DATABASE_URL: z.string().default("postgres://snapotter:snapotter@localhost:5432/snapotter"),
+75
View File
@@ -3347,6 +3347,81 @@ paths:
schema:
$ref: "#/components/schemas/UnauthorizedError"
/api/v1/tools/image/remove-gif-background:
post:
operationId: removeGifBackground
tags: [Tools]
summary: Remove background from an animated image
description: Remove the background from an animated GIF, WebP, or APNG frame by frame using AI (rembg). Runs locally.
security:
- bearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: Animated image file (GIF, animated WebP, or APNG)
settings:
type: string
description: |
JSON string with options:
- `model` (string, optional) - AI model name
- `outputFormat` (string, optional) - One of: webp, apng, gif
- `backgroundType` (string, optional) - One of: transparent, color, gradient, blur, image
- `backgroundColor` (string, optional) - Hex color for the color background
- `gradientColor1` (string, optional) - First gradient color
- `gradientColor2` (string, optional) - Second gradient color
- `gradientAngle` (number, optional) - Gradient angle in degrees
- `blurIntensity` (number 0-100, optional) - Blur strength for the blur background
- `shadowEnabled` (boolean, optional) - Enable drop shadow
- `shadowOpacity` (number 0-100, optional) - Shadow opacity
- `edgeRefine` (number 0-3, optional) - Edge refinement level
- `decontaminate` (boolean, optional) - Remove background color spill
backgroundImage:
type: string
format: binary
description: Background image, required when backgroundType is "image"
clientJobId:
type: string
description: Client-provided job ID for SSE progress tracking
responses:
"202":
description: Accepted for async processing. Track progress via SSE at /api/v1/jobs/{jobId}/progress.
content:
application/json:
schema:
type: object
properties:
jobId:
type: string
async:
type: boolean
example: true
"400":
description: Invalid input (not animated, over the frame cap, or bad settings)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Authentication required
content:
application/json:
schema:
$ref: "#/components/schemas/UnauthorizedError"
"501":
description: Feature not installed
content:
application/json:
schema:
$ref: "#/components/schemas/FeatureNotInstalledError"
/api/v1/tools/image/remove-background:
post:
operationId: removeBackground
+2
View File
@@ -110,6 +110,7 @@ import { registerQrGenerate } from "./qr-generate.js";
import { registerRedEyeRemoval } from "./red-eye-removal.js";
import { registerRedactPdf } from "./redact-pdf.js";
import { registerRemoveBackground } from "./remove-background.js";
import { registerRemoveGifBackground } from "./remove-gif-background.js";
import { registerRemovePages } from "./remove-pages.js";
import { registerRepairPdf } from "./repair-pdf.js";
import { registerReplaceAudio } from "./replace-audio.js";
@@ -360,6 +361,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "background-replace", register: registerBackgroundReplace },
{ id: "blur-background", register: registerBlurBackground },
{ id: "remove-background", register: registerRemoveBackground },
{ id: "remove-gif-background", register: registerRemoveGifBackground },
{ id: "upscale", register: registerUpscale },
{ id: "ocr", register: registerOcr },
{ id: "ocr-pdf", register: registerOcrPdf },
@@ -0,0 +1,289 @@
import { randomUUID } from "node:crypto";
import { writeFileSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { extname, join } from "node:path";
import { removeBackgroundAnimated } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { detectAnimation } from "../../lib/animation-detect.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
import { buildAsyncAcceptedPayload } from "../async-response.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
model: z.string().optional(),
outputFormat: z.enum(["webp", "gif", "apng"]).optional(),
backgroundType: z.enum(["transparent", "color", "gradient", "blur", "image"]).optional(),
backgroundColor: z.string().optional(),
gradientColor1: z.string().optional(),
gradientColor2: z.string().optional(),
gradientAngle: z.number().optional(),
blurIntensity: z.number().min(0).max(100).optional(),
shadowEnabled: z.boolean().optional(),
shadowOpacity: z.number().min(0).max(100).optional(),
edgeRefine: z.number().int().min(0).max(3).optional(),
decontaminate: z.boolean().optional(),
});
// The worker also receives the staged background-image storage key (never
// persisted to the audit row — passed via dbSettings redaction on enqueue).
const jobSettingsSchema = settingsSchema.extend({ bgImageKey: z.string().optional() });
type Settings = z.infer<typeof settingsSchema>;
function toWrapperOptions(s: z.infer<typeof jobSettingsSchema>, frames: number, filename: string) {
return {
model: s.model,
outputFormat: s.outputFormat,
backgroundType: s.backgroundType,
backgroundColor: s.backgroundColor,
gradientColor1: s.gradientColor1,
gradientColor2: s.gradientColor2,
gradientAngle: s.gradientAngle,
blurIntensity: s.blurIntensity,
shadowEnabled: s.shadowEnabled,
shadowOpacity: s.shadowOpacity,
edgeRefine: s.edgeRefine,
decontaminate: s.decontaminate,
frames,
inputExt: extname(filename).replace(/^\./, "").toLowerCase() || "gif",
};
}
// ── AI job handler (runs inside the BullMQ worker) ────────────────
registerAiJobHandler("remove-gif-background", async (input, data, ctx) => {
const settings = jobSettingsSchema.parse(data.settings);
// ctx.signal is not plumbed to Python; drop a sentinel the loop polls between
// frames so a cancel stops the run instead of wasting the whole animation.
const cancelFile = join(ctx.scratchDir, "cancel.flag");
const writeCancel = () => {
try {
writeFileSync(cancelFile, "1");
} catch {
// best effort
}
};
if (ctx.signal.aborted) writeCancel();
else ctx.signal.addEventListener("abort", writeCancel, { once: true });
let bgImagePath: string | undefined;
if (settings.bgImageKey) {
const bg = await getObjectBuffer(settings.bgImageKey);
if (bg && bg.length > 0) {
bgImagePath = join(ctx.scratchDir, "bg-input");
await writeFile(bgImagePath, bg);
}
}
const { frames } = await detectAnimation(input, data.filename);
const result = await removeBackgroundAnimated(
input,
ctx.scratchDir,
{ ...toWrapperOptions(settings, frames, data.filename), cancelFile, bgImagePath },
(percent, stage) => ctx.report(percent, stage),
);
const base = data.filename.replace(/\.[^.]+$/, "");
return {
buffer: result.buffer,
filename: `${base}-nobg.${result.ext}`,
contentType: result.contentType,
};
});
/**
* AI background removal for animated images (GIF, animated WebP, APNG). Unlike
* the still remove-background tool this is a one-shot flow: every frame is
* matted and the chosen effect is baked in a single pass, then re-encoded to
* the requested animated format. Returns 202 and streams progress over SSE.
*/
export function registerRemoveGifBackground(app: FastifyInstance) {
app.post(
"/api/v1/tools/image/remove-gif-background",
async (request: FastifyRequest, reply: FastifyReply) => {
const toolId = "remove-gif-background";
if (!isToolInstalled(toolId)) {
const bundle = getBundleForTool(toolId);
return reply.status(501).send({
error: "Feature not installed",
code: "FEATURE_NOT_INSTALLED",
feature: TOOL_BUNDLE_MAP[toolId],
featureName: bundle?.name ?? toolId,
estimatedSize: bundle?.estimatedSize ?? "unknown",
});
}
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID();
let filename = "image.gif";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null;
let bgBuffer: Buffer | null = null;
let bgName = "background";
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file" && part.fieldname === "backgroundImage") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) chunks.push(chunk);
bgBuffer = Buffer.concat(chunks);
bgName = sanitizeFilename(part.filename ?? "background");
} else if (part.type === "file") {
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
const fileBuffer = await getObjectBuffer(inputKey);
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
// Detect animation WITHOUT decoding/orienting (those flatten an animation
// to one frame). Reject stills and enforce the frame cap before enqueue.
let animation: { animated: boolean; frames: number };
try {
animation = await detectAnimation(fileBuffer, filename);
} catch (err) {
return reply.status(400).send({
error: `Invalid image: ${stripInternalPaths(err instanceof Error ? err.message : "unreadable")}`,
});
}
if (!animation.animated) {
return reply.status(400).send({
error: "This tool only handles animated images. Use Remove Background for still images.",
code: "NOT_ANIMATED",
});
}
const cap = env.GIF_BG_MAX_FRAMES;
if (cap > 0 && animation.frames > cap) {
return reply.status(400).send({
error: `Animation has ${animation.frames} frames, over the ${cap}-frame limit.`,
code: "TOO_MANY_FRAMES",
});
}
let settings: Settings;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
// Stage the background image for the "image" effect.
let bgImageKey: string | undefined;
if (settings.backgroundType === "image") {
if (!bgBuffer || bgBuffer.length === 0) {
return reply
.status(400)
.send({ error: "A background image is required for the image background." });
}
const bgValidation = await validateImageBuffer(bgBuffer, bgName);
if (!bgValidation.valid) {
return reply
.status(400)
.send({ error: `Invalid background image: ${bgValidation.reason}` });
}
bgImageKey = `uploads/${jobId}/bg-${bgName}`;
await putObject(bgImageKey, bgBuffer);
}
await enqueueToolJob({
jobId,
toolId,
userId,
pool: "ai",
inputRefs: [inputKey],
filename,
settings: { ...settings, bgImageKey },
dbSettings: settings, // keep the internal bgImageKey out of the audit row
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
},
);
// ── Pipeline/batch registry ──────────────────────────────────────
registerToolProcessFn({
toolId: "remove-gif-background",
settingsSchema,
process: async (inputBuffer, settings, filename, ctx) => {
const s = settings as Settings;
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const animation = await detectAnimation(inputBuffer, filename);
if (!animation.animated) {
throw new Error("Input is not an animated image");
}
// Batch/pipeline has no per-request background image; "image" degrades
// to transparent in the pipeline (the Python side handles a null bg).
const result = await removeBackgroundAnimated(
inputBuffer,
scratchDir,
toWrapperOptions(s, animation.frames, filename),
);
const base = filename.replace(/\.[^.]+$/, "");
return {
buffer: result.buffer,
filename: `${base}-nobg.${result.ext}`,
contentType: result.contentType,
};
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
},
});
}
+1 -1
View File
@@ -49,7 +49,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
### API (`apps/api`) {#api-apps-api}
A Fastify v5 server exposing 241 tool routes across five modalities (image, video, audio, PDF, file) that handles:
A Fastify v5 server exposing 242 tool routes across five modalities (image, video, audio, PDF, file) that handles:
- File uploads, temporary workspace management, and persistent file storage
- User file library with version chains (`user_files` table) - each processed result links back to its source file and records which tool was applied, with auto-generated thumbnails for the Files page
- Tool execution (routes each tool request to the image engine or AI bridge)
+1 -1
View File
@@ -125,7 +125,7 @@ pnpm dev
| Modality | Count | Example Tools |
|----------|-------|---------------|
| **Image** | 105 | Resize, Crop, Compress, Convert, Remove Background, Upscale, OCR, Watermark, Collage, Colorize, GIF Tools, format presets |
| **Image** | 106 | Resize, Crop, Compress, Convert, Remove Background, Upscale, OCR, Watermark, Collage, Colorize, GIF Tools, format presets |
| **Video** | 57 | Trim, Crop, Compress, Convert, Merge, Extract Audio, Auto Subtitles, Video to GIF, Resize, Stabilize, format presets |
| **Audio** | 27 | Trim, Merge, Convert, Normalize, Noise Reduction, Transcribe, Pitch Shift, Fade, Ringtone Maker, format presets |
| **PDF / Document** | 42 | Merge, Split, Compress, OCR, Watermark, Redact, Word to PDF, Excel to PDF, Rotate, Protect, Repair |
+32
View File
@@ -3023,6 +3023,38 @@ export const TOOL_SEO: Record<string, ToolSeo> = {
},
],
},
"remove-gif-background": {
searchTitle: "Remove Background from Animated GIF - Private AI",
longDescription:
"Remove the background from an animated GIF, WebP, or APNG. SnapOtter runs AI matting on every frame locally, keeps the original timing and loop, and reassembles a transparent animation. Output as animated WebP or APNG for smooth full-alpha edges, or GIF when you need the classic format. Nothing is uploaded.",
useCases: [
"Make a looping sticker or emote with a transparent background",
"Drop an animated logo onto any colored or image background",
"Turn a screen-recorded clip into a transparent overlay",
"Prep animated assets for slide decks, sites, and chat apps",
],
features: [
"Per-frame AI matting (rembg) running 100% locally",
"Transparent animated WebP, APNG, or GIF output",
"Preserves frame timing and loop count",
"Optional solid color, gradient, blur, image background, or drop shadow",
"No data sent to external APIs or cloud services",
],
faqs: [
{
q: "Which output format keeps the cleanest transparency?",
a: "Animated WebP and APNG both store full 8-bit alpha, so edges stay smooth. GIF supports only 1-bit transparency, which gives hard, sometimes haloed edges. Pick WebP or APNG unless you specifically need a .gif file.",
},
{
q: "Will the animation still loop and keep its speed?",
a: "Yes. The original per-frame delays and loop count are read from the source and written back into the output, so timing and looping match the input.",
},
{
q: "Why is a long GIF slow to process?",
a: "Every frame runs through the AI model, so cost scales with frame count. Short clips are quick; long or high-resolution animations take longer. An NVIDIA GPU speeds this up substantially.",
},
],
},
upscale: {
searchTitle: "Upscale Image with AI - Enhance Resolution",
longDescription:
@@ -0,0 +1,316 @@
import { Download, ImageIcon, Upload } from "lucide-react";
import { type ReactNode, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Quality = "fast" | "balanced" | "best";
type OutputFormat = "webp" | "apng" | "gif";
type BackgroundType = "transparent" | "color" | "gradient" | "blur" | "image";
// Animation multiplies per-frame cost, so default to the fast model.
const QUALITY_MODEL: Record<Quality, string> = {
fast: "u2net",
balanced: "birefnet-general-lite",
best: "birefnet-general",
};
function SectionLabel({ children }: { children: ReactNode }) {
return <p className="mb-1.5 text-xs font-medium text-muted-foreground">{children}</p>;
}
function OptionRow<T extends string>({
options,
value,
onChange,
}: {
options: { value: T; label: string }[];
value: T;
onChange: (v: T) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((o) => (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${
value === o.value
? "border-primary bg-primary text-primary-foreground"
: "border-border hover:bg-muted"
}`}
>
{o.label}
</button>
))}
</div>
);
}
export function RemoveGifBackgroundSettings() {
const { t } = useTranslation();
const s = t.toolSettings["remove-gif-background"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("remove-gif-background");
const [quality, setQuality] = useState<Quality>("fast");
const [outputFormat, setOutputFormat] = useState<OutputFormat>("webp");
const [backgroundType, setBackgroundType] = useState<BackgroundType>("transparent");
const [backgroundColor, setBackgroundColor] = useState("#ffffff");
const [gradientColor1, setGradientColor1] = useState("#4f46e5");
const [gradientColor2, setGradientColor2] = useState("#ec4899");
const [gradientAngle, setGradientAngle] = useState(90);
const [blurIntensity, setBlurIntensity] = useState(30);
const [shadowEnabled, setShadowEnabled] = useState(false);
const [shadowOpacity, setShadowOpacity] = useState(50);
const [edgeRefine, setEdgeRefine] = useState(0);
const [decontaminate, setDecontaminate] = useState(false);
const [bgFile, setBgFile] = useState<File | null>(null);
const bgInputRef = useRef<HTMLInputElement>(null);
const hasFile = files.length > 0;
const needsBgImage = backgroundType === "image" && !bgFile;
const handleProcess = () => {
const settings: Record<string, unknown> = {
model: QUALITY_MODEL[quality],
outputFormat,
backgroundType,
edgeRefine,
decontaminate,
};
if (backgroundType === "color") settings.backgroundColor = backgroundColor;
if (backgroundType === "gradient") {
settings.gradientColor1 = gradientColor1;
settings.gradientColor2 = gradientColor2;
settings.gradientAngle = gradientAngle;
}
if (backgroundType === "blur") settings.blurIntensity = blurIntensity;
if (backgroundType === "image" && bgFile) settings._bgImageFile = bgFile;
if (shadowEnabled) {
settings.shadowEnabled = true;
settings.shadowOpacity = shadowOpacity;
}
if (files.length > 1) processAllFiles(files, settings);
else processFiles(files, settings);
};
return (
<div className="space-y-4">
<div>
<SectionLabel>{s.quality}</SectionLabel>
<OptionRow<Quality>
value={quality}
onChange={setQuality}
options={[
{ value: "fast", label: s.qualityFast },
{ value: "balanced", label: s.qualityBalanced },
{ value: "best", label: s.qualityBest },
]}
/>
<p className="mt-1.5 text-xs text-muted-foreground">{s.qualityNote}</p>
</div>
<div>
<SectionLabel>{s.outputFormat}</SectionLabel>
<OptionRow<OutputFormat>
value={outputFormat}
onChange={setOutputFormat}
options={[
{ value: "webp", label: s.formatWebp },
{ value: "apng", label: s.formatApng },
{ value: "gif", label: s.formatGif },
]}
/>
<p className="mt-1.5 text-xs text-muted-foreground">
{outputFormat === "gif" ? s.gifCaveat : s.formatWebpHint}
</p>
</div>
<div>
<SectionLabel>{s.background}</SectionLabel>
<OptionRow<BackgroundType>
value={backgroundType}
onChange={setBackgroundType}
options={[
{ value: "transparent", label: s.bgTransparent },
{ value: "color", label: s.bgColor },
{ value: "gradient", label: s.bgGradient },
{ value: "blur", label: s.bgBlur },
{ value: "image", label: s.bgImage },
]}
/>
{backgroundType === "color" && (
<label className="mt-2 flex items-center gap-2 text-xs">
<input
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="h-8 w-12 cursor-pointer rounded border border-border bg-transparent"
/>
<span className="text-muted-foreground">{backgroundColor}</span>
</label>
)}
{backgroundType === "gradient" && (
<div className="mt-2 space-y-2">
<div className="flex items-center gap-3 text-xs">
<label className="flex items-center gap-1.5">
<input
type="color"
value={gradientColor1}
onChange={(e) => setGradientColor1(e.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-border bg-transparent"
/>
{s.gradientStart}
</label>
<label className="flex items-center gap-1.5">
<input
type="color"
value={gradientColor2}
onChange={(e) => setGradientColor2(e.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-border bg-transparent"
/>
{s.gradientEnd}
</label>
</div>
<label className="block text-xs text-muted-foreground">
{format(s.gradientAngle, { deg: gradientAngle })}
<input
type="range"
min={0}
max={360}
value={gradientAngle}
onChange={(e) => setGradientAngle(Number(e.target.value))}
className="mt-1 w-full"
/>
</label>
</div>
)}
{backgroundType === "blur" && (
<label className="mt-2 block text-xs text-muted-foreground">
{format(s.blurStrength, { value: blurIntensity })}
<input
type="range"
min={0}
max={100}
value={blurIntensity}
onChange={(e) => setBlurIntensity(Number(e.target.value))}
className="mt-1 w-full"
/>
</label>
)}
{backgroundType === "image" && (
<div className="mt-2">
<input
ref={bgInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => setBgFile(e.target.files?.[0] ?? null)}
/>
<button
type="button"
onClick={() => bgInputRef.current?.click()}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-border px-3 py-2 text-xs font-medium hover:bg-muted"
>
{bgFile ? <ImageIcon className="h-4 w-4" /> : <Upload className="h-4 w-4" />}
{bgFile ? bgFile.name : s.uploadBg}
</button>
</div>
)}
</div>
<div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={shadowEnabled}
onChange={(e) => setShadowEnabled(e.target.checked)}
/>
{s.shadow}
</label>
{shadowEnabled && (
<label className="mt-1.5 block text-xs text-muted-foreground">
{format(s.shadowStrength, { value: shadowOpacity })}
<input
type="range"
min={0}
max={100}
value={shadowOpacity}
onChange={(e) => setShadowOpacity(Number(e.target.value))}
className="mt-1 w-full"
/>
</label>
)}
</div>
<div className="space-y-2">
<SectionLabel>{s.advanced}</SectionLabel>
<label className="block text-xs text-muted-foreground">
{edgeRefine === 0 ? s.edgeRefineOff : format(s.edgeRefine, { level: edgeRefine })}
<input
type="range"
min={0}
max={3}
value={edgeRefine}
onChange={(e) => setEdgeRefine(Number(e.target.value))}
className="mt-1 w-full"
/>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={decontaminate}
onChange={(e) => setDecontaminate(e.target.checked)}
/>
{s.decontaminate}
</label>
</div>
<p className="text-xs text-muted-foreground">{s.frameNote}</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="remove-gif-background-submit"
onClick={handleProcess}
disabled={!hasFile || needsBgImage}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary py-2.5 font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
{files.length > 1 ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
{downloadUrl && !processing && files.length <= 1 && (
<a
href={downloadUrl}
download
data-testid="remove-gif-background-download"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary py-2.5 font-medium text-primary-foreground hover:bg-primary/90"
>
<Download className="h-4 w-4" />
{s.download}
</a>
)}
</div>
);
}
+1
View File
@@ -89,6 +89,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
// AI tools
"remove-background": "before-after",
"remove-gif-background": "before-after",
upscale: "before-after",
ocr: "before-after",
"blur-faces": "before-after",
+6
View File
@@ -271,6 +271,11 @@ const RemoveBgSettings = lazy(() =>
default: m.RemoveBgSettings,
})),
);
const RemoveGifBackgroundSettings = lazy(() =>
import("@/components/tools/remove-gif-background-settings").then((m) => ({
default: m.RemoveGifBackgroundSettings,
})),
);
const UpscaleSettings = lazy(() =>
import("@/components/tools/upscale-settings").then((m) => ({ default: m.UpscaleSettings })),
);
@@ -994,6 +999,7 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
// AI Tools
["remove-background", { Settings: RemoveBgSettings }],
["remove-gif-background", { Settings: RemoveGifBackgroundSettings }],
["upscale", { Settings: UpscaleSettings }],
["ocr", { Settings: OcrSettings }],
["ocr-pdf", { accept: ".pdf", Settings: OcrPdfSettings, ResultsPanel: OcrPdfView }],