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
+11
View File
@@ -352,6 +352,17 @@ async function main() {
"animated-simpsons.gif",
);
// ── Animated APNG (multi-frame, full alpha) for remove-gif-background ──
// The committed file is a 4-frame RGBA APNG. This regenerates a small animated
// APNG only if the fixture is missing (bytes will differ from the committed one).
console.log("Animated APNG (for remove-gif-background):");
const apngOut = join(IMAGE_VALID, "animated.apng");
ffIfMissing(
apngOut,
`-f lavfi -i "testsrc=duration=1:size=48x48:rate=4" -pix_fmt rgba -plays 0 -f apng -y "${apngOut}"`,
"animated.apng",
);
// ── Synthetic audio/video (A) ──
// NOTE: media-30s.mp4 and media-30s.wav are no longer generated here.
// media-30s.mp4 is now a real Big Buck Bunny CC-BY hero clip (committed).
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -62,6 +62,7 @@ export const fixtures = {
animated: {
gif: p("image/valid/animated.gif"),
webp: p("image/valid/animated.webp"),
apng: p("image/valid/animated.apng"),
real: p("image/valid/animated-simpsons.gif"),
},
ocr: {
+3 -3
View File
@@ -120,13 +120,13 @@ describe("API docs", () => {
const deployment = readFileSync(join(root, "apps/docs/guide/deployment.md"), "utf8");
const architecture = readFileSync(join(root, "apps/docs/guide/architecture.md"), "utf8");
expect(gettingStarted).toContain("| **Image** | 105 |");
expect(gettingStarted).toContain("| **Image** | 106 |");
expect(gettingStarted).toContain("| **Video** | 57 |");
expect(gettingStarted).toContain("| **Audio** | 27 |");
expect(gettingStarted).toContain("| **PDF / Document** | 42 |");
expect(gettingStarted).toContain("| **Files** | 10 |");
expect(deployment).not.toContain("All 138 non-AI tools");
expect(architecture).toContain("241 tool routes");
expect(architecture).toContain("242 tool routes");
});
it("serves an LLM summary with live catalog tools", async () => {
@@ -136,7 +136,7 @@ describe("API docs", () => {
});
expect(res.statusCode).toBe(200);
expect(res.body).toContain("## Tools");
expect(res.body).toContain("- Image (105 tools)");
expect(res.body).toContain("- Image (106 tools)");
expect(res.body).toContain("Resize - Resize by pixels");
expect(res.body).toContain("Sign PDF -");
});
@@ -0,0 +1,141 @@
/**
* Integration tests for remove-gif-background
* (/api/v1/tools/image/remove-gif-background).
*
* This tool needs the background-removal bundle + rembg models, which CI does
* not have. To exercise the route's own logic (animation detection, frame cap,
* background-image requirement) we mark the bundle installed in an isolated
* DATA_DIR (the passport-photo-bundle-guard pattern). Those branches all reject
* BEFORE enqueue, so no sidecar runs. One happy-path case asserts the 202 accept
* contract; the actual per-frame removal is verified live on a GPU box.
*
* GIF_BG_MAX_FRAMES is pinned to 3 so the 4-frame APNG fixture trips the cap
* while the 3-frame GIF/WebP fixtures pass.
*/
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
// ── Isolated DATA_DIR + config (set before any config/feature-status import) ──
const testRoot = join(tmpdir(), `snapotter-gifbg-${randomUUID()}`);
const aiDir = join(testRoot, "ai");
const installedPath = join(aiDir, "installed.json");
process.env.DATA_DIR = testRoot;
process.env.FEATURE_MANIFEST_PATH = join(process.cwd(), "docker/feature-manifest.json");
process.env.GIF_BG_MAX_FRAMES = "3";
mkdirSync(join(aiDir, "models"), { recursive: true });
writeFileSync(installedPath, JSON.stringify({ bundles: {} }), "utf-8");
// ── Dynamic imports (after env is set) ───────────────────────────────
const { invalidateCache } = await import("../../../../apps/api/src/lib/feature-status.js");
const { fixtures, readFixture } = await import("../../../fixtures/index.js");
const { buildTestApp, createMultipartPayload, loginAsAdmin } = await import("../../test-server.js");
type TestAppType = Awaited<ReturnType<typeof buildTestApp>>;
const GIF = readFixture(fixtures.image.animated.gif); // 3 frames
const WEBP = readFixture(fixtures.image.animated.webp); // 3 frames
const APNG = readFixture(fixtures.image.animated.apng); // 4 frames
const STILL_PNG = readFixture(fixtures.image.base.png200); // still
let testApp: TestAppType;
let app: TestAppType["app"];
let adminToken: string;
/** Overwrite installed.json so exactly the given bundles read as installed. */
function setInstalled(bundleIds: string[]): void {
const bundles: Record<string, { version: string; installedAt: string; models: string[] }> = {};
for (const id of bundleIds) {
bundles[id] = { version: "1.0.0-test", installedAt: "2026-01-01T00:00:00.000Z", models: [] };
}
writeFileSync(installedPath, JSON.stringify({ bundles }), "utf-8");
invalidateCache();
}
function post(
content: Buffer,
filename: string,
contentType: string,
settings: Record<string, unknown> = {},
) {
const { body, contentType: ct } = createMultipartPayload([
{ name: "file", filename, contentType, content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return app.inject({
method: "POST",
url: "/api/v1/tools/image/remove-gif-background",
headers: { authorization: `Bearer ${adminToken}`, "content-type": ct },
body,
});
}
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
rmSync(testRoot, { recursive: true, force: true });
}, 15_000);
describe("Remove GIF Background (#496)", () => {
it("returns 501 naming background-removal when the bundle is not installed", async () => {
setInstalled([]);
const res = await post(GIF, "a.gif", "image/gif");
expect(res.statusCode).toBe(501);
const json = JSON.parse(res.body);
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
expect(json.feature).toBe("background-removal");
});
it("rejects a still image with NOT_ANIMATED once installed", async () => {
setInstalled(["background-removal"]);
const res = await post(STILL_PNG, "still.png", "image/png");
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).code).toBe("NOT_ANIMATED");
});
it("rejects an over-cap animation with TOO_MANY_FRAMES", async () => {
setInstalled(["background-removal"]);
// Cap is 3; the APNG fixture has 4 frames.
const res = await post(APNG, "a.apng", "image/apng");
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).code).toBe("TOO_MANY_FRAMES");
});
it("requires a background image for backgroundType 'image' (animated WebP)", async () => {
setInstalled(["background-removal"]);
// WebP also passes detection + cap, then trips the background-image check.
const res = await post(WEBP, "a.webp", "image/webp", { backgroundType: "image" });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/background image/i);
});
it("rejects invalid settings JSON", async () => {
setInstalled(["background-removal"]);
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "a.gif", contentType: "image/gif", content: GIF },
{ name: "settings", content: "not valid json{{{" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/remove-gif-background",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/json/i);
});
// The 202 accept path (valid in-cap animation -> enqueue) is intentionally not
// asserted here: it would enqueue a job the CI worker can't process (no models),
// producing async noise. It is verified live on a GPU box. These reject-before-
// enqueue cases fully exercise the route's own logic.
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { detectAnimation } from "../../../apps/api/src/lib/animation-detect.js";
import { fixtures, readFixture } from "../../fixtures/index.js";
describe("detectAnimation", () => {
it("counts GIF frames via Sharp", async () => {
const r = await detectAnimation(readFixture(fixtures.image.animated.gif), "animated.gif");
expect(r.animated).toBe(true);
expect(r.frames).toBeGreaterThan(1);
});
it("counts animated WebP frames via Sharp", async () => {
const r = await detectAnimation(readFixture(fixtures.image.animated.webp), "animated.webp");
expect(r.animated).toBe(true);
expect(r.frames).toBeGreaterThan(1);
});
it("counts APNG frames via the acTL chunk", async () => {
const r = await detectAnimation(readFixture(fixtures.image.animated.apng), "animated.apng");
expect(r.animated).toBe(true);
expect(r.frames).toBe(4);
});
it("treats a still PNG as not animated", async () => {
const r = await detectAnimation(readFixture(fixtures.image.base.png200), "still.png");
expect(r.animated).toBe(false);
expect(r.frames).toBe(1);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { apngFrameCount } from "../../../apps/api/src/lib/apng.js";
import { fixtures, readFixture } from "../../fixtures/index.js";
describe("apngFrameCount", () => {
it("returns null for a non-PNG buffer", () => {
expect(apngFrameCount(Buffer.from("GIF89a-not-a-png-file"))).toBeNull();
});
it("returns 1 for a still PNG (no acTL)", () => {
expect(apngFrameCount(readFixture(fixtures.image.base.png200))).toBe(1);
});
it("returns the frame count for a multi-frame APNG", () => {
expect(apngFrameCount(readFixture(fixtures.image.animated.apng))).toBe(4);
});
});
+1 -1
View File
@@ -19,7 +19,7 @@ describe("toolSection", () => {
TOOLS.filter((t) => toolSection(t) === s)
.map((t) => t.id)
.sort();
expect(TOOLS.filter((t) => toolSection(t) === "image")).toHaveLength(105);
expect(TOOLS.filter((t) => toolSection(t) === "image")).toHaveLength(106);
expect(TOOLS.filter((t) => toolSection(t) === "video")).toHaveLength(57);
expect(TOOLS.filter((t) => toolSection(t) === "audio")).toHaveLength(27);
expect(bySection("pdf")).toHaveLength(29);