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:
+11
@@ -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).
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Vendored
+1
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user