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"),
|
||||
|
||||
Reference in New Issue
Block a user