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"),