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
+2
View File
@@ -14,6 +14,8 @@ MAX_UPLOAD_SIZE_MB=0
MAX_BATCH_SIZE=0
CONCURRENT_JOBS=0
MAX_MEGAPIXELS=0
# Max frames processed by animated background removal (0 = unlimited)
GIF_BG_MAX_FRAMES=150
# Rate limiting (0 = disabled)
RATE_LIMIT_PER_MIN=0
+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(() => {});
}
},
});
}
+1 -1
View File
@@ -49,7 +49,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
### API (`apps/api`) {#api-apps-api}
A Fastify v5 server exposing 241 tool routes across five modalities (image, video, audio, PDF, file) that handles:
A Fastify v5 server exposing 242 tool routes across five modalities (image, video, audio, PDF, file) that handles:
- File uploads, temporary workspace management, and persistent file storage
- User file library with version chains (`user_files` table) - each processed result links back to its source file and records which tool was applied, with auto-generated thumbnails for the Files page
- Tool execution (routes each tool request to the image engine or AI bridge)
+1 -1
View File
@@ -125,7 +125,7 @@ pnpm dev
| Modality | Count | Example Tools |
|----------|-------|---------------|
| **Image** | 105 | Resize, Crop, Compress, Convert, Remove Background, Upscale, OCR, Watermark, Collage, Colorize, GIF Tools, format presets |
| **Image** | 106 | Resize, Crop, Compress, Convert, Remove Background, Upscale, OCR, Watermark, Collage, Colorize, GIF Tools, format presets |
| **Video** | 57 | Trim, Crop, Compress, Convert, Merge, Extract Audio, Auto Subtitles, Video to GIF, Resize, Stabilize, format presets |
| **Audio** | 27 | Trim, Merge, Convert, Normalize, Noise Reduction, Transcribe, Pitch Shift, Fade, Ringtone Maker, format presets |
| **PDF / Document** | 42 | Merge, Split, Compress, OCR, Watermark, Redact, Word to PDF, Excel to PDF, Rotate, Protect, Repair |
+32
View File
@@ -3023,6 +3023,38 @@ export const TOOL_SEO: Record<string, ToolSeo> = {
},
],
},
"remove-gif-background": {
searchTitle: "Remove Background from Animated GIF - Private AI",
longDescription:
"Remove the background from an animated GIF, WebP, or APNG. SnapOtter runs AI matting on every frame locally, keeps the original timing and loop, and reassembles a transparent animation. Output as animated WebP or APNG for smooth full-alpha edges, or GIF when you need the classic format. Nothing is uploaded.",
useCases: [
"Make a looping sticker or emote with a transparent background",
"Drop an animated logo onto any colored or image background",
"Turn a screen-recorded clip into a transparent overlay",
"Prep animated assets for slide decks, sites, and chat apps",
],
features: [
"Per-frame AI matting (rembg) running 100% locally",
"Transparent animated WebP, APNG, or GIF output",
"Preserves frame timing and loop count",
"Optional solid color, gradient, blur, image background, or drop shadow",
"No data sent to external APIs or cloud services",
],
faqs: [
{
q: "Which output format keeps the cleanest transparency?",
a: "Animated WebP and APNG both store full 8-bit alpha, so edges stay smooth. GIF supports only 1-bit transparency, which gives hard, sometimes haloed edges. Pick WebP or APNG unless you specifically need a .gif file.",
},
{
q: "Will the animation still loop and keep its speed?",
a: "Yes. The original per-frame delays and loop count are read from the source and written back into the output, so timing and looping match the input.",
},
{
q: "Why is a long GIF slow to process?",
a: "Every frame runs through the AI model, so cost scales with frame count. Short clips are quick; long or high-resolution animations take longer. An NVIDIA GPU speeds this up substantially.",
},
],
},
upscale: {
searchTitle: "Upscale Image with AI - Enhance Resolution",
longDescription:
@@ -0,0 +1,316 @@
import { Download, ImageIcon, Upload } from "lucide-react";
import { type ReactNode, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Quality = "fast" | "balanced" | "best";
type OutputFormat = "webp" | "apng" | "gif";
type BackgroundType = "transparent" | "color" | "gradient" | "blur" | "image";
// Animation multiplies per-frame cost, so default to the fast model.
const QUALITY_MODEL: Record<Quality, string> = {
fast: "u2net",
balanced: "birefnet-general-lite",
best: "birefnet-general",
};
function SectionLabel({ children }: { children: ReactNode }) {
return <p className="mb-1.5 text-xs font-medium text-muted-foreground">{children}</p>;
}
function OptionRow<T extends string>({
options,
value,
onChange,
}: {
options: { value: T; label: string }[];
value: T;
onChange: (v: T) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((o) => (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${
value === o.value
? "border-primary bg-primary text-primary-foreground"
: "border-border hover:bg-muted"
}`}
>
{o.label}
</button>
))}
</div>
);
}
export function RemoveGifBackgroundSettings() {
const { t } = useTranslation();
const s = t.toolSettings["remove-gif-background"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("remove-gif-background");
const [quality, setQuality] = useState<Quality>("fast");
const [outputFormat, setOutputFormat] = useState<OutputFormat>("webp");
const [backgroundType, setBackgroundType] = useState<BackgroundType>("transparent");
const [backgroundColor, setBackgroundColor] = useState("#ffffff");
const [gradientColor1, setGradientColor1] = useState("#4f46e5");
const [gradientColor2, setGradientColor2] = useState("#ec4899");
const [gradientAngle, setGradientAngle] = useState(90);
const [blurIntensity, setBlurIntensity] = useState(30);
const [shadowEnabled, setShadowEnabled] = useState(false);
const [shadowOpacity, setShadowOpacity] = useState(50);
const [edgeRefine, setEdgeRefine] = useState(0);
const [decontaminate, setDecontaminate] = useState(false);
const [bgFile, setBgFile] = useState<File | null>(null);
const bgInputRef = useRef<HTMLInputElement>(null);
const hasFile = files.length > 0;
const needsBgImage = backgroundType === "image" && !bgFile;
const handleProcess = () => {
const settings: Record<string, unknown> = {
model: QUALITY_MODEL[quality],
outputFormat,
backgroundType,
edgeRefine,
decontaminate,
};
if (backgroundType === "color") settings.backgroundColor = backgroundColor;
if (backgroundType === "gradient") {
settings.gradientColor1 = gradientColor1;
settings.gradientColor2 = gradientColor2;
settings.gradientAngle = gradientAngle;
}
if (backgroundType === "blur") settings.blurIntensity = blurIntensity;
if (backgroundType === "image" && bgFile) settings._bgImageFile = bgFile;
if (shadowEnabled) {
settings.shadowEnabled = true;
settings.shadowOpacity = shadowOpacity;
}
if (files.length > 1) processAllFiles(files, settings);
else processFiles(files, settings);
};
return (
<div className="space-y-4">
<div>
<SectionLabel>{s.quality}</SectionLabel>
<OptionRow<Quality>
value={quality}
onChange={setQuality}
options={[
{ value: "fast", label: s.qualityFast },
{ value: "balanced", label: s.qualityBalanced },
{ value: "best", label: s.qualityBest },
]}
/>
<p className="mt-1.5 text-xs text-muted-foreground">{s.qualityNote}</p>
</div>
<div>
<SectionLabel>{s.outputFormat}</SectionLabel>
<OptionRow<OutputFormat>
value={outputFormat}
onChange={setOutputFormat}
options={[
{ value: "webp", label: s.formatWebp },
{ value: "apng", label: s.formatApng },
{ value: "gif", label: s.formatGif },
]}
/>
<p className="mt-1.5 text-xs text-muted-foreground">
{outputFormat === "gif" ? s.gifCaveat : s.formatWebpHint}
</p>
</div>
<div>
<SectionLabel>{s.background}</SectionLabel>
<OptionRow<BackgroundType>
value={backgroundType}
onChange={setBackgroundType}
options={[
{ value: "transparent", label: s.bgTransparent },
{ value: "color", label: s.bgColor },
{ value: "gradient", label: s.bgGradient },
{ value: "blur", label: s.bgBlur },
{ value: "image", label: s.bgImage },
]}
/>
{backgroundType === "color" && (
<label className="mt-2 flex items-center gap-2 text-xs">
<input
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="h-8 w-12 cursor-pointer rounded border border-border bg-transparent"
/>
<span className="text-muted-foreground">{backgroundColor}</span>
</label>
)}
{backgroundType === "gradient" && (
<div className="mt-2 space-y-2">
<div className="flex items-center gap-3 text-xs">
<label className="flex items-center gap-1.5">
<input
type="color"
value={gradientColor1}
onChange={(e) => setGradientColor1(e.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-border bg-transparent"
/>
{s.gradientStart}
</label>
<label className="flex items-center gap-1.5">
<input
type="color"
value={gradientColor2}
onChange={(e) => setGradientColor2(e.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-border bg-transparent"
/>
{s.gradientEnd}
</label>
</div>
<label className="block text-xs text-muted-foreground">
{format(s.gradientAngle, { deg: gradientAngle })}
<input
type="range"
min={0}
max={360}
value={gradientAngle}
onChange={(e) => setGradientAngle(Number(e.target.value))}
className="mt-1 w-full"
/>
</label>
</div>
)}
{backgroundType === "blur" && (
<label className="mt-2 block text-xs text-muted-foreground">
{format(s.blurStrength, { value: blurIntensity })}
<input
type="range"
min={0}
max={100}
value={blurIntensity}
onChange={(e) => setBlurIntensity(Number(e.target.value))}
className="mt-1 w-full"
/>
</label>
)}
{backgroundType === "image" && (
<div className="mt-2">
<input
ref={bgInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => setBgFile(e.target.files?.[0] ?? null)}
/>
<button
type="button"
onClick={() => bgInputRef.current?.click()}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-border px-3 py-2 text-xs font-medium hover:bg-muted"
>
{bgFile ? <ImageIcon className="h-4 w-4" /> : <Upload className="h-4 w-4" />}
{bgFile ? bgFile.name : s.uploadBg}
</button>
</div>
)}
</div>
<div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={shadowEnabled}
onChange={(e) => setShadowEnabled(e.target.checked)}
/>
{s.shadow}
</label>
{shadowEnabled && (
<label className="mt-1.5 block text-xs text-muted-foreground">
{format(s.shadowStrength, { value: shadowOpacity })}
<input
type="range"
min={0}
max={100}
value={shadowOpacity}
onChange={(e) => setShadowOpacity(Number(e.target.value))}
className="mt-1 w-full"
/>
</label>
)}
</div>
<div className="space-y-2">
<SectionLabel>{s.advanced}</SectionLabel>
<label className="block text-xs text-muted-foreground">
{edgeRefine === 0 ? s.edgeRefineOff : format(s.edgeRefine, { level: edgeRefine })}
<input
type="range"
min={0}
max={3}
value={edgeRefine}
onChange={(e) => setEdgeRefine(Number(e.target.value))}
className="mt-1 w-full"
/>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={decontaminate}
onChange={(e) => setDecontaminate(e.target.checked)}
/>
{s.decontaminate}
</label>
</div>
<p className="text-xs text-muted-foreground">{s.frameNote}</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="remove-gif-background-submit"
onClick={handleProcess}
disabled={!hasFile || needsBgImage}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary py-2.5 font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
{files.length > 1 ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
{downloadUrl && !processing && files.length <= 1 && (
<a
href={downloadUrl}
download
data-testid="remove-gif-background-download"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary py-2.5 font-medium text-primary-foreground hover:bg-primary/90"
>
<Download className="h-4 w-4" />
{s.download}
</a>
)}
</div>
);
}
+1
View File
@@ -89,6 +89,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
// AI tools
"remove-background": "before-after",
"remove-gif-background": "before-after",
upscale: "before-after",
ocr: "before-after",
"blur-faces": "before-after",
+6
View File
@@ -271,6 +271,11 @@ const RemoveBgSettings = lazy(() =>
default: m.RemoveBgSettings,
})),
);
const RemoveGifBackgroundSettings = lazy(() =>
import("@/components/tools/remove-gif-background-settings").then((m) => ({
default: m.RemoveGifBackgroundSettings,
})),
);
const UpscaleSettings = lazy(() =>
import("@/components/tools/upscale-settings").then((m) => ({ default: m.UpscaleSettings })),
);
@@ -994,6 +999,7 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
// AI Tools
["remove-background", { Settings: RemoveBgSettings }],
["remove-gif-background", { Settings: RemoveGifBackgroundSettings }],
["upscale", { Settings: UpscaleSettings }],
["ocr", { Settings: OcrSettings }],
["ocr-pdf", { accept: ".pdf", Settings: OcrPdfSettings, ResultsPanel: OcrPdfView }],
+1
View File
@@ -87,6 +87,7 @@
"smokeImports": ["rembg", "onnxruntime"],
"enablesTools": [
"remove-background",
"remove-gif-background",
"passport-photo",
"transparency-fixer",
"background-replace",
+2
View File
@@ -62,6 +62,7 @@ ALLOWED_SCRIPTS = {
"detect_faces",
"enhance_faces",
"face_landmarks",
"gif_remove_bg",
"inpaint",
"install_feature",
"noise_removal",
@@ -110,6 +111,7 @@ MODELS_DIR = os.path.join(os.environ.get("DATA_DIR", "/data"), "ai", "models")
TOOL_BUNDLE_MAP = {
"remove_bg": "background-removal",
"gif_remove_bg": "background-removal",
"detect_faces": "face-detection",
"face_landmarks": "face-detection",
"red_eye_removal": "face-detection",
+342
View File
@@ -0,0 +1,342 @@
"""Background removal for animated images (GIF, animated WebP, APNG).
Reads every frame with disposal-aware coalescing, runs one warm rembg session
over all frames, applies the chosen effect per frame, and re-encodes an animated
transparent (or composited) output via Pillow. Reuses remove_bg.py's model
registration and edge-refinement helpers so the still and animated paths share
the same matte behaviour.
"""
import io
import json
import os
import sys
MAX_REMBG_PX = int(os.environ.get("MAX_REMBG_PX", "2048"))
_OOM_MARKERS = (
"out of memory",
"failed to allocate",
"cudaerrormemoryallocation",
"cublas_status_alloc_failed",
"bad_alloc",
)
def emit_progress(percent, stage):
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
def _is_oom(err):
m = str(err).lower()
return any(s in m for s in _OOM_MARKERS)
def _read_frames(path):
"""Disposal-aware coalescing. Returns (frames[RGBA], durations[ms], loop).
Naive ``seek()`` yields partial/garbled frames on disposal-optimized GIFs and
animated WebP, so composite each frame onto a running canvas. ``copy()`` is
mandatory: ``ImageSequence.Iterator`` mutates the same underlying object.
"""
from PIL import Image, ImageSequence
im = Image.open(path)
try:
loop = int(im.info.get("loop", 0) or 0)
except (TypeError, ValueError):
loop = 0
frames, durations = [], []
canvas = None
for frame in ImageSequence.Iterator(im):
raw = frame.info.get("duration", im.info.get("duration", 100))
try:
dur = max(20, int(raw))
except (TypeError, ValueError):
dur = 100
disposal = frame.info.get("disposal", 0)
rgba = frame.convert("RGBA")
if canvas is None:
canvas = rgba.copy()
elif disposal == 2:
canvas = rgba.copy() # restore-to-background: don't smear the prior frame
else:
canvas = Image.alpha_composite(canvas, rgba)
frames.append(canvas.copy())
durations.append(dur)
return frames, durations, loop
def _scale_target(size):
w, h = size
longest = max(w, h)
if longest <= MAX_REMBG_PX:
return None
scale = MAX_REMBG_PX / longest
return (max(1, round(w * scale)), max(1, round(h * scale)))
def _hex_to_rgba(value, default=(255, 255, 255, 255)):
if not value:
return default
s = str(value).lstrip("#")
try:
if len(s) == 3:
s = "".join(c * 2 for c in s)
if len(s) == 6:
return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), 255)
if len(s) == 8:
return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16))
except ValueError:
pass
return default
def _gradient(w, h, settings):
import math
import numpy as np
from PIL import Image
c1 = np.array(_hex_to_rgba(settings.get("gradientColor1", "#000000")), dtype=np.float32)
c2 = np.array(_hex_to_rgba(settings.get("gradientColor2", "#ffffff")), dtype=np.float32)
angle = math.radians(float(settings.get("gradientAngle", 0)) % 360.0)
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
proj = xx * math.cos(angle) + yy * math.sin(angle)
lo, hi = float(proj.min()), float(proj.max())
t = (proj - lo) / (hi - lo) if hi > lo else np.zeros_like(proj)
t = t[:, :, None]
arr = (c1 * (1.0 - t) + c2 * t).astype(np.uint8)
return Image.fromarray(arr, "RGBA")
def _cover(img, w, h):
from PIL import Image
iw, ih = img.size
scale = max(w / iw, h / ih)
nw, nh = max(1, round(iw * scale)), max(1, round(ih * scale))
resized = img.resize((nw, nh), Image.LANCZOS)
left, top = (nw - w) // 2, (nh - h) // 2
return resized.crop((left, top, left + w, top + h)).convert("RGBA")
def _apply_shadow(base, cutout, settings):
import numpy as np
from PIL import Image, ImageFilter
opacity = max(0.0, min(1.0, float(settings.get("shadowOpacity", 50)) / 100.0))
w, h = cutout.size
alpha = cutout.split()[3]
shadow = Image.new("RGBA", (w, h), (0, 0, 0, 0))
shadow.paste(Image.new("RGBA", (w, h), (0, 0, 0, 255)), (0, 0), alpha)
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=max(2.0, w * 0.02)))
sa = np.array(shadow, dtype=np.float32)
sa[:, :, 3] *= opacity
shadow = Image.fromarray(sa.astype(np.uint8), "RGBA")
offset = max(2, int(w * 0.015))
shifted = Image.new("RGBA", (w, h), (0, 0, 0, 0))
shifted.paste(shadow, (offset, offset), shadow)
return Image.alpha_composite(base, shifted)
def _apply_effect(cutout, original, settings, bg_img):
from PIL import Image, ImageFilter
bg_type = settings.get("backgroundType", "transparent")
w, h = cutout.size
if bg_type == "color":
base = Image.new("RGBA", (w, h), _hex_to_rgba(settings.get("backgroundColor", "#ffffff")))
elif bg_type == "gradient":
base = _gradient(w, h, settings)
elif bg_type == "blur":
radius = max(1.0, float(settings.get("blurIntensity", 20)) / 3.0)
base = original.convert("RGBA").filter(ImageFilter.GaussianBlur(radius=radius))
elif bg_type == "image" and bg_img is not None:
base = _cover(bg_img, w, h)
else:
base = Image.new("RGBA", (w, h), (0, 0, 0, 0))
if settings.get("shadowEnabled"):
base = _apply_shadow(base, cutout, settings)
return Image.alpha_composite(base, cutout)
def _to_gif_frame(rgba):
"""RGBA -> (P-mode image, transparent_index) for a transparent animated GIF.
GIF alpha is 1-bit: threshold at 50%, quantize RGB to 255 colours, and
reserve palette index 255 for transparency.
"""
from PIL import Image
alpha = rgba.split()[3]
p = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT)
transparent_mask = alpha.point(lambda a: 255 if a < 128 else 0)
p.paste(255, transparent_mask)
return p, 255
def _encode(frames, durations, loop, fmt, out_path):
if fmt == "apng":
frames[0].save(
out_path, "PNG", save_all=True, append_images=frames[1:],
duration=durations, loop=loop, disposal=1,
)
elif fmt == "gif":
pal = [_to_gif_frame(f) for f in frames]
first, tidx = pal[0]
first.save(
out_path, "GIF", save_all=True, append_images=[p for p, _ in pal[1:]],
duration=durations, loop=loop, transparency=tidx, disposal=2, optimize=False,
)
else: # webp
frames[0].save(
out_path, "WEBP", save_all=True, append_images=frames[1:],
duration=durations, loop=loop, quality=90, method=6,
)
def _create_session(model, providers, device):
from rembg import new_session
from rembg.sessions import sessions_class
from remove_bg import _register_hr_matting_session, _register_matting_session
_register_matting_session(sessions_class)
_register_hr_matting_session(sessions_class)
try:
return new_session(model, providers=providers), device
except Exception:
if "CUDAExecutionProvider" in providers:
return new_session(model, providers=["CPUExecutionProvider"]), "cpu"
raise
def _remove_one(frame_rgba, session, use_alpha, settings, target, orig_size):
from rembg import remove
from PIL import Image
src = frame_rgba if target is None else frame_rgba.resize(target, Image.LANCZOS)
buf = io.BytesIO()
src.save(buf, format="PNG")
data = buf.getvalue()
try:
out = remove(
data, session=session, alpha_matting=use_alpha,
alpha_matting_foreground_threshold=240, alpha_matting_background_threshold=10,
)
except Exception as e:
if use_alpha and not _is_oom(e):
out = remove(data, session=session, alpha_matting=False)
else:
raise
edge_refine = settings.get("edgeRefine", 0)
if edge_refine and int(edge_refine) > 0:
from remove_bg import _refine_edges
out = _refine_edges(out, int(edge_refine))
if settings.get("decontaminate"):
from remove_bg import _decontaminate_edges
out = _decontaminate_edges(out)
cut = Image.open(io.BytesIO(out)).convert("RGBA")
if target is not None:
cut = cut.resize(orig_size, Image.LANCZOS)
return cut
def main():
input_path = sys.argv[1]
output_path = sys.argv[2]
settings = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {}
fmt = settings.get("outputFormat", "webp")
if fmt not in ("webp", "gif", "apng"):
fmt = "webp"
cancel_file = settings.get("cancelFile")
bg_path = settings.get("bgImagePath")
# Redirect stdout to stderr so rembg/onnx/pooch output cannot contaminate the
# JSON result. Restored only to write the final line.
stdout_fd = os.dup(1)
os.dup2(2, 1)
canceled = False
try:
from PIL import Image
from gpu import onnx_providers
from remove_bg import ALLOWED_MODELS
model = settings.get("model", "u2net")
if model not in ALLOWED_MODELS:
model = "u2net"
emit_progress(3, "Reading frames")
frames, durations, loop = _read_frames(input_path)
n = len(frames)
if n == 0:
raise RuntimeError("no frames found in input")
orig_size = frames[0].size
target = _scale_target(orig_size)
bg_img = Image.open(bg_path).convert("RGBA") if bg_path else None
# Offline guard for the chosen model (mirrors remove_bg.py home resolution).
model_home = os.path.expanduser(
os.getenv("U2NET_HOME", os.path.join(os.getenv("XDG_DATA_HOME", "~"), ".u2net"))
)
if not os.path.exists(os.path.join(model_home, f"{model}.onnx")):
from offline_guard import ensure_download_allowed
ensure_download_allowed(f"Background removal model '{model}'")
emit_progress(5, "Loading model")
providers, device = onnx_providers()
session, device = _create_session(model, providers, device)
use_alpha = device != "cpu"
# Probe frame 0 to settle model (OOM -> lighter model for the WHOLE
# animation, never per-frame) and matting viability once. Switching model
# or matting mid-animation would flicker.
try:
_remove_one(frames[0], session, use_alpha, settings, target, orig_size)
except Exception as e:
if _is_oom(e) and model.startswith("birefnet"):
model = "u2net"
emit_progress(5, "Retrying with a lighter model")
session, device = _create_session(model, providers, device)
use_alpha = device != "cpu"
elif use_alpha:
use_alpha = False # matting not viable on this device/model
else:
raise
out_frames = []
for i, frame in enumerate(frames):
if cancel_file and os.path.exists(cancel_file):
canceled = True
break
cut = _remove_one(frame, session, use_alpha, settings, target, orig_size)
out_frames.append(_apply_effect(cut, frame, settings, bg_img))
emit_progress(int(5 + 90 * (i + 1) / n), f"Frame {i + 1}/{n}")
if canceled:
result = json.dumps({"success": False, "error": "canceled"})
else:
emit_progress(97, "Encoding animation")
_encode(out_frames, durations, loop, fmt, output_path)
result = json.dumps(
{"success": True, "model": model, "device": device, "frames": n, "format": fmt}
)
except ImportError as e:
print(f"[gif-remove-bg] Import failed: {e}", file=sys.stderr, flush=True)
result = json.dumps({"success": False, "error": f"import failed: {e}"})
except Exception as e: # noqa: BLE001
result = json.dumps({"success": False, "error": str(e)})
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
sys.stdout.write(result + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()
@@ -0,0 +1,114 @@
import { randomUUID } from "node:crypto";
import { readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
export type GifBgFormat = "webp" | "gif" | "apng";
const FORMAT_CONTENT_TYPE: Record<GifBgFormat, string> = {
webp: "image/webp",
gif: "image/gif",
apng: "image/apng",
};
const FORMAT_EXT: Record<GifBgFormat, string> = {
webp: "webp",
gif: "gif",
apng: "png",
};
export interface RemoveBackgroundAnimatedOptions {
model?: string;
outputFormat?: GifBgFormat;
backgroundType?: "transparent" | "color" | "gradient" | "blur" | "image";
backgroundColor?: string;
gradientColor1?: string;
gradientColor2?: string;
gradientAngle?: number;
blurIntensity?: number;
shadowEnabled?: boolean;
shadowOpacity?: number;
edgeRefine?: number;
decontaminate?: boolean;
/** Frame count from route detection; scales the sidecar timeout. */
frames?: number;
/** Path polled between frames; its presence aborts the loop early. */
cancelFile?: string;
/** Path to a staged background image for backgroundType "image". */
bgImagePath?: string;
/** Original input extension so the temp file round-trips its format. */
inputExt?: string;
}
export interface AnimatedRemovalResult {
buffer: Buffer;
format: GifBgFormat;
contentType: string;
ext: string;
}
/** Thrown when the job was canceled mid-run (the Python loop saw the sentinel). */
export class AnimatedRemovalCanceledError extends Error {
constructor() {
super("canceled");
this.name = "AnimatedRemovalCanceledError";
}
}
export function resolveGifBgFormat(value: string | undefined): GifBgFormat {
return value === "gif" || value === "apng" ? value : "webp";
}
export function gifBgContentType(format: GifBgFormat): string {
return FORMAT_CONTENT_TYPE[format];
}
export function gifBgExt(format: GifBgFormat): string {
return FORMAT_EXT[format];
}
/**
* Scale the sidecar call timeout by frame count. The dispatcher's default 600s
* ceiling would SIGTERM a long single-call animation (and take the shared
* dispatcher down with it), so size the budget to the whole loop and cap it at
* the 2h app-job ceiling.
*/
export function animatedTimeoutMs(frames: number, model: string | undefined): number {
const perFrameMs = model?.startsWith("birefnet") ? 20_000 : 6_000;
const n = frames > 0 ? frames : 150;
return Math.min(7_200_000, Math.max(300_000, n * perFrameMs));
}
export async function removeBackgroundAnimated(
inputBuffer: Buffer,
outputDir: string,
options: RemoveBackgroundAnimatedOptions = {},
onProgress?: ProgressCallback,
): Promise<AnimatedRemovalResult> {
const id = randomUUID();
const format = resolveGifBgFormat(options.outputFormat);
const inExt = (options.inputExt || "gif").replace(/^\./, "").toLowerCase();
const inputPath = join(outputDir, `gifbg_in_${id}.${inExt}`);
const outputPath = join(outputDir, `gifbg_out_${id}.${FORMAT_EXT[format]}`);
await writeFile(inputPath, inputBuffer);
const timeout = animatedTimeoutMs(options.frames ?? 0, options.model);
try {
const { stdout } = await runPythonWithProgress(
"gif_remove_bg.py",
[inputPath, outputPath, JSON.stringify(options)],
{ onProgress, timeout },
);
const result = parseStdoutJson(stdout);
if (!result.success) {
if (result.error === "canceled") throw new AnimatedRemovalCanceledError();
throw new Error((result.error as string) || "Animated background removal failed");
}
const buffer = await readFile(outputPath);
return { buffer, format, contentType: FORMAT_CONTENT_TYPE[format], ext: FORMAT_EXT[format] };
} finally {
await unlink(inputPath).catch(() => {});
await unlink(outputPath).catch(() => {});
}
}
+1
View File
@@ -12,6 +12,7 @@ import { join } from "node:path";
*/
export const SCRIPT_BUNDLE_MAP: Record<string, string> = {
remove_bg: "background-removal",
gif_remove_bg: "background-removal",
detect_faces: "face-detection",
face_landmarks: "face-detection",
red_eye_removal: "face-detection",
+10
View File
@@ -1,4 +1,14 @@
export { isMemoryAllocError, removeBackground } from "./background-removal.js";
export {
AnimatedRemovalCanceledError,
animatedTimeoutMs,
type GifBgFormat,
gifBgContentType,
gifBgExt,
type RemoveBackgroundAnimatedOptions,
removeBackgroundAnimated,
resolveGifBgFormat,
} from "./background-removal-animated.js";
export type { DispatcherStatus } from "./bridge.js";
export {
getDispatcherStatus,
+24
View File
@@ -226,6 +226,29 @@ const BASE_TOOLS: Tool[] = [
acceptedInputs: IMAGE_INPUTS,
executionHint: "long",
},
{
id: "remove-gif-background",
name: "Remove GIF Background",
description: "AI background removal for animated GIFs, WebP, and APNG",
category: "enhance",
icon: "Film",
route: "/remove-gif-background",
modality: "image",
acceptedInputs: [".gif", ".webp", ".apng", ".png"],
executionHint: "long",
keywords: [
"gif",
"animated",
"transparent gif",
"animated webp",
"apng",
"remove gif background",
"gif background",
"animated background removal",
"transparent background gif",
"gif cutout",
],
},
{
id: "upscale",
name: "Image Upscaling",
@@ -2635,6 +2658,7 @@ export const APP_VERSION = "2.1.0";
*/
export const PYTHON_SIDECAR_TOOLS = [
"remove-background",
"remove-gif-background",
"upscale",
"blur-faces",
"erase-object",
+1
View File
@@ -35,6 +35,7 @@ export const FEATURE_BUNDLES: Record<string, FeatureBundleInfo> = {
estimatedSize: "4-5 GB",
enablesTools: [
"remove-background",
"remove-gif-background",
"passport-photo",
"transparency-fixer",
"background-replace",
+36
View File
@@ -1505,6 +1505,42 @@ export const ar: TranslationKeys = {
submitBatch: "إزالة الخلفية ({count} ملف)",
progressLabel: "جاري إزالة الخلفية",
},
"remove-gif-background": {
quality: "الجودة",
qualityFast: "سريع",
qualityBalanced: "متوازن",
qualityBest: "الأفضل",
qualityNote: "يمر كل إطار عبر النموذج، لذا فإن الجودة الأعلى أبطأ بكثير في المقاطع الطويلة.",
outputFormat: "صيغة المخرجات",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "يحافظ WebP وAPNG على شفافية كاملة وسلسة.",
gifCaveat: "يدعم GIF شفافية بمقدار بت واحد فقط، لذا تبدو الحواف حادة.",
background: "الخلفية",
bgTransparent: "شفاف",
bgColor: "لون",
bgGradient: "تدرج",
bgBlur: "تمويه",
bgImage: "صورة",
gradientStart: "البداية",
gradientEnd: "النهاية",
gradientAngle: "الزاوية ({deg}°)",
blurStrength: "قوة التمويه ({value})",
shadow: "إضافة ظل",
shadowStrength: "شفافية الظل ({value})",
advanced: "متقدم",
edgeRefine: "تحسين الحواف (المستوى {level})",
edgeRefineOff: "تحسين الحواف: إيقاف",
decontaminate: "إزالة تسرب الألوان",
uploadBg: "رفع صورة خلفية",
frameNote:
"تتم معالجة كل إطار، لذا فإن الرسوم المتحركة الطويلة بطيئة. يُنصح باستخدام بطاقة رسوميات.",
submit: "إزالة الخلفية",
submitBatch: "إزالة الخلفية ({count} ملف)",
progressLabel: "جاري إزالة الخلفية من الإطارات",
download: "تنزيل",
},
upscale: {
scaleFactor: "عامل التكبير",
quality: "الجودة",
+37
View File
@@ -1521,6 +1521,43 @@ export const de: TranslationKeys = {
submitBatch: "Hintergrund entfernen ({count} Dateien)",
progressLabel: "Hintergrund wird entfernt",
},
"remove-gif-background": {
quality: "Qualität",
qualityFast: "Schnell",
qualityBalanced: "Ausgewogen",
qualityBest: "Beste",
qualityNote:
"Jedes Bild durchläuft das Modell, daher ist höhere Qualität bei langen Clips deutlich langsamer.",
outputFormat: "Ausgabeformat",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP und APNG behalten weiche, vollständige Transparenz.",
gifCaveat: "GIF unterstützt nur 1-Bit-Transparenz, daher wirken die Kanten hart.",
background: "Hintergrund",
bgTransparent: "Transparent",
bgColor: "Farbe",
bgGradient: "Verlauf",
bgBlur: "Weichzeichnen",
bgImage: "Bild",
gradientStart: "Anfang",
gradientEnd: "Ende",
gradientAngle: "Winkel ({deg}°)",
blurStrength: "Weichzeichnungsstärke ({value})",
shadow: "Schatten hinzufügen",
shadowStrength: "Schattendeckkraft ({value})",
advanced: "Erweitert",
edgeRefine: "Kantenverfeinerung (Stufe {level})",
edgeRefineOff: "Kantenverfeinerung: aus",
decontaminate: "Farbüberlauf entfernen",
uploadBg: "Hintergrundbild hochladen",
frameNote:
"Jedes Bild wird verarbeitet, daher sind lange Animationen langsam. Eine GPU wird empfohlen.",
submit: "Hintergrund entfernen",
submitBatch: "Hintergrund entfernen ({count} Dateien)",
progressLabel: "Hintergrund wird aus Bildern entfernt",
download: "Herunterladen",
},
upscale: {
scaleFactor: "Skalierungsfaktor",
quality: "Qualität",
+36
View File
@@ -1469,6 +1469,42 @@ export const en = {
submitBatch: "Remove Background ({count} files)",
progressLabel: "Removing background",
},
"remove-gif-background": {
quality: "Quality",
qualityFast: "Fast",
qualityBalanced: "Balanced",
qualityBest: "Best",
qualityNote:
"Every frame runs through the model, so higher quality is much slower on long clips.",
outputFormat: "Output format",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP and APNG keep smooth, full transparency.",
gifCaveat: "GIF supports only 1-bit transparency, so edges look hard.",
background: "Background",
bgTransparent: "Transparent",
bgColor: "Color",
bgGradient: "Gradient",
bgBlur: "Blur",
bgImage: "Image",
gradientStart: "Start",
gradientEnd: "End",
gradientAngle: "Angle ({deg}°)",
blurStrength: "Blur strength ({value})",
shadow: "Add drop shadow",
shadowStrength: "Shadow opacity ({value})",
advanced: "Advanced",
edgeRefine: "Edge refine (level {level})",
edgeRefineOff: "Edge refine: off",
decontaminate: "Remove color spill",
uploadBg: "Upload background image",
frameNote: "Processes every frame, so long animations are slow. A GPU is recommended.",
submit: "Remove Background",
submitBatch: "Remove Background ({count} files)",
progressLabel: "Removing background from frames",
download: "Download",
},
upscale: {
scaleFactor: "Scale Factor",
quality: "Quality",
+37
View File
@@ -1504,6 +1504,43 @@ export const es: TranslationKeys = {
submitBatch: "Eliminar fondo ({count} archivos)",
progressLabel: "Eliminando fondo",
},
"remove-gif-background": {
quality: "Calidad",
qualityFast: "Rápido",
qualityBalanced: "Equilibrado",
qualityBest: "Mejor",
qualityNote:
"Cada fotograma pasa por el modelo, así que una calidad mayor es mucho más lenta en clips largos.",
outputFormat: "Formato de salida",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP y APNG mantienen una transparencia suave y completa.",
gifCaveat: "GIF solo admite transparencia de 1 bit, así que los bordes se ven duros.",
background: "Fondo",
bgTransparent: "Transparente",
bgColor: "Color",
bgGradient: "Degradado",
bgBlur: "Desenfoque",
bgImage: "Imagen",
gradientStart: "Inicio",
gradientEnd: "Fin",
gradientAngle: "Ángulo ({deg}°)",
blurStrength: "Intensidad del desenfoque ({value})",
shadow: "Agregar sombra",
shadowStrength: "Opacidad de la sombra ({value})",
advanced: "Avanzado",
edgeRefine: "Refinar bordes (nivel {level})",
edgeRefineOff: "Refinar bordes: desactivado",
decontaminate: "Eliminar derrame de color",
uploadBg: "Subir imagen de fondo",
frameNote:
"Procesa cada fotograma, así que las animaciones largas son lentas. Se recomienda una GPU.",
submit: "Eliminar fondo",
submitBatch: "Eliminar fondo ({count} archivos)",
progressLabel: "Eliminando fondo de los fotogramas",
download: "Descargar",
},
upscale: {
scaleFactor: "Factor de escala",
quality: "Calidad",
+38
View File
@@ -1527,6 +1527,44 @@ export const fr: TranslationKeys = {
submitBatch: "Supprimer l'arrière-plan ({count} fichiers)",
progressLabel: "Suppression de l'arrière-plan",
},
"remove-gif-background": {
quality: "Qualité",
qualityFast: "Rapide",
qualityBalanced: "Équilibré",
qualityBest: "Meilleure",
qualityNote:
"Chaque image passe par le modèle, donc une meilleure qualité est bien plus lente sur les clips longs.",
outputFormat: "Format de sortie",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP et APNG conservent une transparence complète et fluide.",
gifCaveat:
"GIF ne prend en charge qu'une transparence de 1 bit, donc les bords paraissent nets.",
background: "Arrière-plan",
bgTransparent: "Transparent",
bgColor: "Couleur",
bgGradient: "Dégradé",
bgBlur: "Flou",
bgImage: "Image",
gradientStart: "Début",
gradientEnd: "Fin",
gradientAngle: "Angle ({deg}°)",
blurStrength: "Intensité du flou ({value})",
shadow: "Ajouter une ombre",
shadowStrength: "Opacité de l'ombre ({value})",
advanced: "Avancé",
edgeRefine: "Affinage des bords (niveau {level})",
edgeRefineOff: "Affinage des bords : désactivé",
decontaminate: "Supprimer la diffusion de couleur",
uploadBg: "Téléverser une image d'arrière-plan",
frameNote:
"Traite chaque image, donc les longues animations sont lentes. Un GPU est recommandé.",
submit: "Supprimer l'arrière-plan",
submitBatch: "Supprimer l'arrière-plan ({count} fichiers)",
progressLabel: "Suppression de l'arrière-plan des images",
download: "Télécharger",
},
upscale: {
scaleFactor: "Facteur d'échelle",
quality: "Qualité",
+35
View File
@@ -1336,6 +1336,41 @@ export const hi: TranslationKeys = {
submitBatch: "बैकग्राउंड हटाएं ({count} फाइलें)",
progressLabel: "बैकग्राउंड हटाया जा रहा है",
},
"remove-gif-background": {
quality: "क्वालिटी",
qualityFast: "तेज़",
qualityBalanced: "संतुलित",
qualityBest: "सर्वोत्तम",
qualityNote: "हर फ्रेम मॉडल से गुज़रता है, इसलिए लंबी क्लिप पर ज़्यादा क्वालिटी काफ़ी धीमी होती है।",
outputFormat: "आउटपुट फ़ॉर्मेट",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP और APNG स्मूद, पूर्ण पारदर्शिता बनाए रखते हैं।",
gifCaveat: "GIF केवल 1-बिट पारदर्शिता को सपोर्ट करता है, इसलिए किनारे कठोर दिखते हैं।",
background: "बैकग्राउंड",
bgTransparent: "पारदर्शी",
bgColor: "रंग",
bgGradient: "ग्रेडिएंट",
bgBlur: "ब्लर",
bgImage: "इमेज",
gradientStart: "शुरुआत",
gradientEnd: "अंत",
gradientAngle: "कोण ({deg}°)",
blurStrength: "ब्लर की तीव्रता ({value})",
shadow: "ड्रॉप शैडो जोड़ें",
shadowStrength: "शैडो ओपेसिटी ({value})",
advanced: "उन्नत",
edgeRefine: "एज रिफाइन (स्तर {level})",
edgeRefineOff: "एज रिफाइन: बंद",
decontaminate: "कलर स्पिल हटाएं",
uploadBg: "बैकग्राउंड इमेज अपलोड करें",
frameNote: "हर फ्रेम प्रोसेस होता है, इसलिए लंबी एनिमेशन धीमी होती हैं। GPU की सलाह दी जाती है।",
submit: "बैकग्राउंड हटाएं",
submitBatch: "बैकग्राउंड हटाएं ({count} फाइलें)",
progressLabel: "फ्रेम से बैकग्राउंड हटाया जा रहा है",
download: "डाउनलोड",
},
upscale: {
scaleFactor: "स्केल फैक्टर",
quality: "क्वालिटी",
+36
View File
@@ -1515,6 +1515,42 @@ export const id: TranslationKeys = {
submitBatch: "Hapus Latar Belakang ({count} file)",
progressLabel: "Menghapus latar belakang",
},
"remove-gif-background": {
quality: "Kualitas",
qualityFast: "Cepat",
qualityBalanced: "Seimbang",
qualityBest: "Terbaik",
qualityNote:
"Setiap frame diproses melalui model, jadi kualitas lebih tinggi jauh lebih lambat pada klip panjang.",
outputFormat: "Format Output",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP dan APNG mempertahankan transparansi penuh yang halus.",
gifCaveat: "GIF hanya mendukung transparansi 1-bit, jadi tepinya terlihat kasar.",
background: "Latar Belakang",
bgTransparent: "Transparan",
bgColor: "Warna",
bgGradient: "Gradien",
bgBlur: "Blur",
bgImage: "Gambar",
gradientStart: "Mulai",
gradientEnd: "Akhir",
gradientAngle: "Sudut ({deg}°)",
blurStrength: "Kekuatan blur ({value})",
shadow: "Tambah bayangan",
shadowStrength: "Opasitas bayangan ({value})",
advanced: "Lanjutan",
edgeRefine: "Perhalus tepi (level {level})",
edgeRefineOff: "Perhalus tepi: mati",
decontaminate: "Hapus rembesan warna",
uploadBg: "Unggah gambar latar",
frameNote: "Memproses setiap frame, jadi animasi panjang lambat. GPU disarankan.",
submit: "Hapus Latar Belakang",
submitBatch: "Hapus Latar Belakang ({count} file)",
progressLabel: "Menghapus latar belakang dari frame",
download: "Unduh",
},
upscale: {
scaleFactor: "Faktor Skala",
quality: "Kualitas",
+37
View File
@@ -1519,6 +1519,43 @@ export const it: TranslationKeys = {
submitBatch: "Rimuovi sfondo ({count} file)",
progressLabel: "Rimozione sfondo",
},
"remove-gif-background": {
quality: "Qualità",
qualityFast: "Veloce",
qualityBalanced: "Bilanciata",
qualityBest: "Migliore",
qualityNote:
"Ogni fotogramma passa attraverso il modello, quindi una qualità più alta è molto più lenta sulle clip lunghe.",
outputFormat: "Formato di output",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP e APNG mantengono una trasparenza piena e uniforme.",
gifCaveat: "GIF supporta solo la trasparenza a 1 bit, quindi i bordi risultano netti.",
background: "Sfondo",
bgTransparent: "Trasparente",
bgColor: "Colore",
bgGradient: "Sfumatura",
bgBlur: "Sfocatura",
bgImage: "Immagine",
gradientStart: "Inizio",
gradientEnd: "Fine",
gradientAngle: "Angolo ({deg}°)",
blurStrength: "Intensità sfocatura ({value})",
shadow: "Aggiungi ombra",
shadowStrength: "Opacità ombra ({value})",
advanced: "Avanzate",
edgeRefine: "Rifinitura bordi (livello {level})",
edgeRefineOff: "Rifinitura bordi: disattivata",
decontaminate: "Rimuovi sbavature di colore",
uploadBg: "Carica immagine di sfondo",
frameNote:
"Elabora ogni fotogramma, quindi le animazioni lunghe sono lente. Si consiglia una GPU.",
submit: "Rimuovi sfondo",
submitBatch: "Rimuovi sfondo ({count} file)",
progressLabel: "Rimozione sfondo dai fotogrammi",
download: "Scarica",
},
upscale: {
scaleFactor: "Fattore di scala",
quality: "Qualità",
+37
View File
@@ -1476,6 +1476,43 @@ export const ja: TranslationKeys = {
submitBatch: "背景を除去({count}ファイル)",
progressLabel: "背景を除去中",
},
"remove-gif-background": {
quality: "品質",
qualityFast: "高速",
qualityBalanced: "バランス",
qualityBest: "最高",
qualityNote:
"すべてのフレームがモデルを通るため、長いクリップでは品質を上げると大幅に遅くなります。",
outputFormat: "出力形式",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebPとAPNGは滑らかで完全な透過を保ちます。",
gifCaveat: "GIFは1ビットの透過のみ対応のため、エッジが硬く見えます。",
background: "背景",
bgTransparent: "透明",
bgColor: "カラー",
bgGradient: "グラデーション",
bgBlur: "ぼかし",
bgImage: "画像",
gradientStart: "開始",
gradientEnd: "終了",
gradientAngle: "角度({deg}°)",
blurStrength: "ぼかしの強さ({value}",
shadow: "ドロップシャドウを追加",
shadowStrength: "シャドウの不透明度({value}",
advanced: "詳細設定",
edgeRefine: "エッジ補正(レベル{level}",
edgeRefineOff: "エッジ補正:オフ",
decontaminate: "色にじみを除去",
uploadBg: "背景画像をアップロード",
frameNote:
"すべてのフレームを処理するため、長いアニメーションは遅くなります。GPUを推奨します。",
submit: "背景を除去",
submitBatch: "背景を除去({count}ファイル)",
progressLabel: "フレームから背景を除去中",
download: "ダウンロード",
},
upscale: {
scaleFactor: "拡大倍率",
quality: "品質",
+35
View File
@@ -1460,6 +1460,41 @@ export const ko: TranslationKeys = {
submitBatch: "배경 제거 ({count}개 파일)",
progressLabel: "배경 제거 중",
},
"remove-gif-background": {
quality: "품질",
qualityFast: "빠름",
qualityBalanced: "균형",
qualityBest: "최고",
qualityNote: "모든 프레임이 모델을 거치므로, 긴 클립에서는 품질이 높을수록 훨씬 느립니다.",
outputFormat: "출력 형식",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP와 APNG는 매끄럽고 완전한 투명도를 유지합니다.",
gifCaveat: "GIF는 1비트 투명도만 지원해서 가장자리가 딱딱하게 보입니다.",
background: "배경",
bgTransparent: "투명",
bgColor: "색상",
bgGradient: "그라데이션",
bgBlur: "블러",
bgImage: "이미지",
gradientStart: "시작",
gradientEnd: "끝",
gradientAngle: "각도 ({deg}°)",
blurStrength: "블러 강도 ({value})",
shadow: "그림자 추가",
shadowStrength: "그림자 불투명도 ({value})",
advanced: "고급",
edgeRefine: "가장자리 다듬기 (레벨 {level})",
edgeRefineOff: "가장자리 다듬기: 끄기",
decontaminate: "색상 번짐 제거",
uploadBg: "배경 이미지 업로드",
frameNote: "모든 프레임을 처리하므로 긴 애니메이션은 느립니다. GPU를 권장합니다.",
submit: "배경 제거",
submitBatch: "배경 제거 ({count}개 파일)",
progressLabel: "프레임에서 배경 제거 중",
download: "다운로드",
},
upscale: {
scaleFactor: "확대 배율",
quality: "품질",
+36
View File
@@ -1519,6 +1519,42 @@ export const nl: TranslationKeys = {
submitBatch: "Achtergrond verwijderen ({count} bestanden)",
progressLabel: "Achtergrond verwijderen",
},
"remove-gif-background": {
quality: "Kwaliteit",
qualityFast: "Snel",
qualityBalanced: "Gebalanceerd",
qualityBest: "Beste",
qualityNote:
"Elk frame gaat door het model, dus hogere kwaliteit is veel trager bij lange clips.",
outputFormat: "Uitvoerformaat",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP en APNG behouden vloeiende, volledige transparantie.",
gifCaveat: "GIF ondersteunt alleen 1-bits transparantie, waardoor randen hard ogen.",
background: "Achtergrond",
bgTransparent: "Transparant",
bgColor: "Kleur",
bgGradient: "Verloop",
bgBlur: "Vervaging",
bgImage: "Afbeelding",
gradientStart: "Start",
gradientEnd: "Eind",
gradientAngle: "Hoek ({deg}°)",
blurStrength: "Vervagingssterkte ({value})",
shadow: "Slagschaduw toevoegen",
shadowStrength: "Schaduwdekking ({value})",
advanced: "Geavanceerd",
edgeRefine: "Randverfijning (niveau {level})",
edgeRefineOff: "Randverfijning: uit",
decontaminate: "Kleurranden verwijderen",
uploadBg: "Achtergrondafbeelding uploaden",
frameNote: "Verwerkt elk frame, dus lange animaties zijn traag. Een GPU wordt aanbevolen.",
submit: "Achtergrond verwijderen",
submitBatch: "Achtergrond verwijderen ({count} bestanden)",
progressLabel: "Achtergrond uit frames verwijderen",
download: "Downloaden",
},
upscale: {
scaleFactor: "Schaalfactor",
quality: "Kwaliteit",
+37
View File
@@ -1519,6 +1519,43 @@ export const pl: TranslationKeys = {
submitBatch: "Usuń tło ({count} plików)",
progressLabel: "Usuwanie tła",
},
"remove-gif-background": {
quality: "Jakość",
qualityFast: "Szybko",
qualityBalanced: "Zrównoważona",
qualityBest: "Najlepsza",
qualityNote:
"Każda klatka przechodzi przez model, więc wyższa jakość jest znacznie wolniejsza przy długich klipach.",
outputFormat: "Format wyjściowy",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP i APNG zachowują płynną, pełną przezroczystość.",
gifCaveat: "GIF obsługuje tylko 1-bitową przezroczystość, więc krawędzie wyglądają twardo.",
background: "Tło",
bgTransparent: "Przezroczyste",
bgColor: "Kolor",
bgGradient: "Gradient",
bgBlur: "Rozmycie",
bgImage: "Obraz",
gradientStart: "Początek",
gradientEnd: "Koniec",
gradientAngle: "Kąt ({deg}°)",
blurStrength: "Siła rozmycia ({value})",
shadow: "Dodaj cień",
shadowStrength: "Krycie cienia ({value})",
advanced: "Zaawansowane",
edgeRefine: "Wygładzanie krawędzi (poziom {level})",
edgeRefineOff: "Wygładzanie krawędzi: wył.",
decontaminate: "Usuń przebarwienia kolorów",
uploadBg: "Prześlij obraz tła",
frameNote:
"Przetwarza każdą klatkę, więc długie animacje są wolne. Zalecany jest procesor graficzny.",
submit: "Usuń tło",
submitBatch: "Usuń tło ({count} plików)",
progressLabel: "Usuwanie tła z klatek",
download: "Pobierz",
},
upscale: {
scaleFactor: "Współczynnik skali",
quality: "Jakość",
+36
View File
@@ -1518,6 +1518,42 @@ export const ptBR: TranslationKeys = {
submitBatch: "Remover fundo ({count} arquivos)",
progressLabel: "Removendo fundo",
},
"remove-gif-background": {
quality: "Qualidade",
qualityFast: "Rápido",
qualityBalanced: "Equilibrado",
qualityBest: "Melhor",
qualityNote:
"Cada quadro passa pelo modelo, então qualidade maior fica bem mais lenta em clipes longos.",
outputFormat: "Formato de saída",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP e APNG mantêm transparência total e suave.",
gifCaveat: "O GIF suporta apenas transparência de 1 bit, então as bordas ficam duras.",
background: "Fundo",
bgTransparent: "Transparente",
bgColor: "Cor",
bgGradient: "Degradado",
bgBlur: "Desfoque",
bgImage: "Imagem",
gradientStart: "Início",
gradientEnd: "Fim",
gradientAngle: "Ângulo ({deg}°)",
blurStrength: "Intensidade do desfoque ({value})",
shadow: "Adicionar sombra",
shadowStrength: "Opacidade da sombra ({value})",
advanced: "Avançado",
edgeRefine: "Refino de bordas (nível {level})",
edgeRefineOff: "Refino de bordas: desligado",
decontaminate: "Remover vazamento de cor",
uploadBg: "Enviar imagem de fundo",
frameNote: "Processa cada quadro, então animações longas são lentas. Recomenda-se uma GPU.",
submit: "Remover fundo",
submitBatch: "Remover fundo ({count} arquivos)",
progressLabel: "Removendo fundo dos quadros",
download: "Baixar",
},
upscale: {
scaleFactor: "Fator de escala",
quality: "Qualidade",
+36
View File
@@ -1515,6 +1515,42 @@ export const ru: TranslationKeys = {
submitBatch: "Удалить фон ({count} файлов)",
progressLabel: "Удаление фона",
},
"remove-gif-background": {
quality: "Качество",
qualityFast: "Быстро",
qualityBalanced: "Сбалансированно",
qualityBest: "Наилучшее",
qualityNote:
"Каждый кадр проходит через модель, поэтому более высокое качество значительно медленнее на длинных клипах.",
outputFormat: "Формат вывода",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP и APNG сохраняют плавную полную прозрачность.",
gifCaveat: "GIF поддерживает только 1-битную прозрачность, поэтому края выглядят жёсткими.",
background: "Фон",
bgTransparent: "Прозрачный",
bgColor: "Цвет",
bgGradient: "Градиент",
bgBlur: "Размытие",
bgImage: "Изображение",
gradientStart: "Начало",
gradientEnd: "Конец",
gradientAngle: "Угол ({deg}°)",
blurStrength: "Сила размытия ({value})",
shadow: "Добавить тень",
shadowStrength: "Непрозрачность тени ({value})",
advanced: "Дополнительно",
edgeRefine: "Уточнение краёв (уровень {level})",
edgeRefineOff: "Уточнение краёв: выкл.",
decontaminate: "Убрать цветовой ореол",
uploadBg: "Загрузить фоновое изображение",
frameNote: "Обрабатывает каждый кадр, поэтому длинные анимации медленные. Рекомендуется GPU.",
submit: "Удалить фон",
submitBatch: "Удалить фон ({count} файлов)",
progressLabel: "Удаление фона с кадров",
download: "Скачать",
},
upscale: {
scaleFactor: "Коэффициент масштабирования",
quality: "Качество",
+37
View File
@@ -1514,6 +1514,43 @@ export const sv: TranslationKeys = {
submitBatch: "Ta bort bakgrund ({count} filer)",
progressLabel: "Tar bort bakgrund",
},
"remove-gif-background": {
quality: "Kvalitet",
qualityFast: "Snabb",
qualityBalanced: "Balanserad",
qualityBest: "Bäst",
qualityNote:
"Varje bildruta körs genom modellen, så högre kvalitet är mycket långsammare på långa klipp.",
outputFormat: "Utdataformat",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP och APNG behåller mjuk, full transparens.",
gifCaveat: "GIF stöder bara 1-bitars transparens, så kanterna ser hårda ut.",
background: "Bakgrund",
bgTransparent: "Transparent",
bgColor: "Färg",
bgGradient: "Tonad",
bgBlur: "Oskärpa",
bgImage: "Bild",
gradientStart: "Start",
gradientEnd: "Slut",
gradientAngle: "Vinkel ({deg}°)",
blurStrength: "Oskärpans styrka ({value})",
shadow: "Lägg till skugga",
shadowStrength: "Skuggans opacitet ({value})",
advanced: "Avancerat",
edgeRefine: "Kantförfining (nivå {level})",
edgeRefineOff: "Kantförfining: av",
decontaminate: "Ta bort färgspill",
uploadBg: "Ladda upp bakgrundsbild",
frameNote:
"Bearbetar varje bildruta, så långa animationer är långsamma. En GPU rekommenderas.",
submit: "Ta bort bakgrund",
submitBatch: "Ta bort bakgrund ({count} filer)",
progressLabel: "Tar bort bakgrund från bildrutor",
download: "Ladda ner",
},
upscale: {
scaleFactor: "Skalningsfaktor",
quality: "Kvalitet",
+35
View File
@@ -1497,6 +1497,41 @@ export const th: TranslationKeys = {
submitBatch: "ลบพื้นหลัง ({count} ไฟล์)",
progressLabel: "กำลังลบพื้นหลัง",
},
"remove-gif-background": {
quality: "คุณภาพ",
qualityFast: "เร็ว",
qualityBalanced: "สมดุล",
qualityBest: "ดีที่สุด",
qualityNote: "ทุกเฟรมจะผ่านการประมวลผลด้วยโมเดล ดังนั้นคุณภาพที่สูงขึ้นจะช้ากว่ามากในคลิปยาว",
outputFormat: "รูปแบบเอาต์พุต",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP และ APNG คงความโปร่งใสเต็มรูปแบบและเนียนตา",
gifCaveat: "GIF รองรับความโปร่งใสแบบ 1 บิตเท่านั้น ขอบจึงดูแข็ง",
background: "พื้นหลัง",
bgTransparent: "โปร่งใส",
bgColor: "สี",
bgGradient: "ไล่สี",
bgBlur: "เบลอ",
bgImage: "ภาพ",
gradientStart: "เริ่มต้น",
gradientEnd: "สิ้นสุด",
gradientAngle: "มุม ({deg}°)",
blurStrength: "ความเข้มของการเบลอ ({value})",
shadow: "เพิ่มเงา",
shadowStrength: "ความทึบของเงา ({value})",
advanced: "ขั้นสูง",
edgeRefine: "ปรับขอบให้ละเอียด (ระดับ {level})",
edgeRefineOff: "ปรับขอบให้ละเอียด: ปิด",
decontaminate: "กำจัดสีขอบ",
uploadBg: "อัปโหลดภาพพื้นหลัง",
frameNote: "ประมวลผลทุกเฟรม แอนิเมชันยาวจึงช้า แนะนำให้ใช้ GPU",
submit: "ลบพื้นหลัง",
submitBatch: "ลบพื้นหลัง ({count} ไฟล์)",
progressLabel: "กำลังลบพื้นหลังจากเฟรม",
download: "ดาวน์โหลด",
},
upscale: {
scaleFactor: "ตัวคูณขยาย",
quality: "คุณภาพ",
+36
View File
@@ -1518,6 +1518,42 @@ export const tr: TranslationKeys = {
submitBatch: "Arka Planı Kaldır ({count} dosya)",
progressLabel: "Arka plan kaldırılıyor",
},
"remove-gif-background": {
quality: "Kalite",
qualityFast: "Hızlı",
qualityBalanced: "Dengeli",
qualityBest: "En iyi",
qualityNote:
"Her kare modelden geçer, bu yüzden yüksek kalite uzun kliplerde çok daha yavaştır.",
outputFormat: "Çıktı Formatı",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP ve APNG, pürüzsüz ve tam saydamlığı korur.",
gifCaveat: "GIF yalnızca 1 bit saydamlığı destekler, bu yüzden kenarlar sert görünür.",
background: "Arka Plan",
bgTransparent: "Saydam",
bgColor: "Renk",
bgGradient: "Gradyan",
bgBlur: "Bulanıklık",
bgImage: "Görüntü",
gradientStart: "Başlangıç",
gradientEnd: "Bitiş",
gradientAngle: "Açı ({deg}°)",
blurStrength: "Bulanıklık gücü ({value})",
shadow: "Gölge Ekle",
shadowStrength: "Gölge opaklığı ({value})",
advanced: "Gelişmiş",
edgeRefine: "Kenar iyileştirme (seviye {level})",
edgeRefineOff: "Kenar iyileştirme: kapalı",
decontaminate: "Renk taşmasını kaldır",
uploadBg: "Arka plan görüntüsü yükle",
frameNote: "Her kareyi işler, bu yüzden uzun animasyonlar yavaştır. GPU önerilir.",
submit: "Arka Planı Kaldır",
submitBatch: "Arka Planı Kaldır ({count} dosya)",
progressLabel: "Karelerden arka plan kaldırılıyor",
download: "İndir",
},
upscale: {
scaleFactor: "Ölçek Faktörü",
quality: "Kalite",
+36
View File
@@ -1518,6 +1518,42 @@ export const uk: TranslationKeys = {
submitBatch: "Видалити фон ({count} файлів)",
progressLabel: "Видалення фону",
},
"remove-gif-background": {
quality: "Якість",
qualityFast: "Швидко",
qualityBalanced: "Збалансовано",
qualityBest: "Найкраще",
qualityNote:
"Кожен кадр проходить через модель, тож вища якість значно повільніша на довгих кліпах.",
outputFormat: "Формат виводу",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP і APNG зберігають плавну, повну прозорість.",
gifCaveat: "GIF підтримує лише 1-бітну прозорість, тому краї виглядають різкими.",
background: "Фон",
bgTransparent: "Прозорий",
bgColor: "Колір",
bgGradient: "Градієнт",
bgBlur: "Розмиття",
bgImage: "Зображення",
gradientStart: "Початок",
gradientEnd: "Кінець",
gradientAngle: "Кут ({deg}°)",
blurStrength: "Сила розмиття ({value})",
shadow: "Додати тінь",
shadowStrength: "Непрозорість тіні ({value})",
advanced: "Розширені",
edgeRefine: "Уточнення країв (рівень {level})",
edgeRefineOff: "Уточнення країв: вимкнено",
decontaminate: "Прибрати перетікання кольору",
uploadBg: "Завантажити фонове зображення",
frameNote: "Обробляє кожен кадр, тож довгі анімації повільні. Рекомендується GPU.",
submit: "Видалити фон",
submitBatch: "Видалити фон ({count} файлів)",
progressLabel: "Видалення фону з кадрів",
download: "Завантажити",
},
upscale: {
scaleFactor: "Коефіцієнт масштабування",
quality: "Якість",
+36
View File
@@ -1518,6 +1518,42 @@ export const vi: TranslationKeys = {
submitBatch: "Xóa nền ({count} tệp)",
progressLabel: "Đang xóa nền",
},
"remove-gif-background": {
quality: "Chất lượng",
qualityFast: "Nhanh",
qualityBalanced: "Cân bằng",
qualityBest: "Tốt nhất",
qualityNote:
"Mọi khung hình đều chạy qua mô hình, nên chất lượng cao hơn sẽ chậm hơn nhiều với các clip dài.",
outputFormat: "Định dạng đầu ra",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP và APNG giữ được độ trong suốt đầy đủ, mượt mà.",
gifCaveat: "GIF chỉ hỗ trợ độ trong suốt 1-bit, nên viền trông sắc cứng.",
background: "Nền",
bgTransparent: "Trong suốt",
bgColor: "Màu nền",
bgGradient: "Chuyển sắc",
bgBlur: "Làm mờ",
bgImage: "Hình ảnh",
gradientStart: "Bắt đầu",
gradientEnd: "Kết thúc",
gradientAngle: "Góc ({deg}°)",
blurStrength: "Cường độ làm mờ ({value})",
shadow: "Thêm bóng đổ",
shadowStrength: "Độ mờ của bóng ({value})",
advanced: "Nâng cao",
edgeRefine: "Tinh chỉnh viền (mức {level})",
edgeRefineOff: "Tinh chỉnh viền: tắt",
decontaminate: "Khử màu tràn viền",
uploadBg: "Tải lên ảnh nền",
frameNote: "Xử lý mọi khung hình, nên ảnh động dài sẽ chậm. Khuyến nghị dùng GPU.",
submit: "Xóa nền",
submitBatch: "Xóa nền ({count} tệp)",
progressLabel: "Đang xóa nền khỏi các khung hình",
download: "Tải xuống",
},
upscale: {
scaleFactor: "Hệ số phóng đại",
quality: "Chất lượng",
+35
View File
@@ -1285,6 +1285,41 @@ export const zhCN: TranslationKeys = {
submitBatch: "移除背景({count} 个文件)",
progressLabel: "正在移除背景",
},
"remove-gif-background": {
quality: "质量",
qualityFast: "快速",
qualityBalanced: "均衡",
qualityBest: "最佳",
qualityNote: "每一帧都会经过模型处理,因此质量越高,处理长片段就越慢。",
outputFormat: "输出格式",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP 和 APNG 可保留平滑、完整的透明度。",
gifCaveat: "GIF 仅支持 1 位透明度,因此边缘看起来生硬。",
background: "背景",
bgTransparent: "透明",
bgColor: "颜色",
bgGradient: "渐变",
bgBlur: "模糊",
bgImage: "图片",
gradientStart: "起点",
gradientEnd: "终点",
gradientAngle: "角度({deg}°)",
blurStrength: "模糊强度({value}",
shadow: "添加阴影",
shadowStrength: "阴影不透明度({value}",
advanced: "高级",
edgeRefine: "边缘细化(级别 {level}",
edgeRefineOff: "边缘细化:关闭",
decontaminate: "去除颜色溢出",
uploadBg: "上传背景图片",
frameNote: "会处理每一帧,因此长动画较慢。建议使用 GPU。",
submit: "移除背景",
submitBatch: "移除背景({count} 个文件)",
progressLabel: "正在从帧中移除背景",
download: "下载",
},
upscale: {
scaleFactor: "放大倍数",
quality: "质量",
+35
View File
@@ -1285,6 +1285,41 @@ export const zhTW: TranslationKeys = {
submitBatch: "移除背景({count}個檔案)",
progressLabel: "正在移除背景",
},
"remove-gif-background": {
quality: "品質",
qualityFast: "快速",
qualityBalanced: "平衡",
qualityBest: "最佳",
qualityNote: "每一格都會經過模型處理,因此品質越高,處理長片段就越慢。",
outputFormat: "輸出格式",
formatWebp: "WebP",
formatApng: "APNG",
formatGif: "GIF",
formatWebpHint: "WebP 和 APNG 可保留平滑、完整的透明度。",
gifCaveat: "GIF 僅支援 1 位元透明度,因此邊緣看起來生硬。",
background: "背景",
bgTransparent: "透明",
bgColor: "色彩",
bgGradient: "漸層",
bgBlur: "模糊",
bgImage: "影像",
gradientStart: "起點",
gradientEnd: "終點",
gradientAngle: "角度({deg}°)",
blurStrength: "模糊強度({value}",
shadow: "加入陰影",
shadowStrength: "陰影不透明度({value}",
advanced: "進階",
edgeRefine: "邊緣細化(等級 {level}",
edgeRefineOff: "邊緣細化:關閉",
decontaminate: "移除色彩溢出",
uploadBg: "上傳背景影像",
frameNote: "會處理每一格,因此長動畫較慢。建議使用 GPU。",
submit: "移除背景",
submitBatch: "移除背景({count}個檔案)",
progressLabel: "正在從影格中移除背景",
download: "下載",
},
upscale: {
scaleFactor: "放大倍數",
quality: "品質",
+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);