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(() => {});
}
},
});
}