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