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:
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"smokeImports": ["rembg", "onnxruntime"],
|
||||
"enablesTools": [
|
||||
"remove-background",
|
||||
"remove-gif-background",
|
||||
"passport-photo",
|
||||
"transparency-fixer",
|
||||
"background-replace",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "الجودة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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: "क्वालिटी",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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à",
|
||||
|
||||
@@ -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: "品質",
|
||||
|
||||
@@ -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: "품질",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ść",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Качество",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "คุณภาพ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Якість",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "质量",
|
||||
|
||||
@@ -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
@@ -352,6 +352,17 @@ async function main() {
|
||||
"animated-simpsons.gif",
|
||||
);
|
||||
|
||||
// ── Animated APNG (multi-frame, full alpha) for remove-gif-background ──
|
||||
// The committed file is a 4-frame RGBA APNG. This regenerates a small animated
|
||||
// APNG only if the fixture is missing (bytes will differ from the committed one).
|
||||
console.log("Animated APNG (for remove-gif-background):");
|
||||
const apngOut = join(IMAGE_VALID, "animated.apng");
|
||||
ffIfMissing(
|
||||
apngOut,
|
||||
`-f lavfi -i "testsrc=duration=1:size=48x48:rate=4" -pix_fmt rgba -plays 0 -f apng -y "${apngOut}"`,
|
||||
"animated.apng",
|
||||
);
|
||||
|
||||
// ── Synthetic audio/video (A) ──
|
||||
// NOTE: media-30s.mp4 and media-30s.wav are no longer generated here.
|
||||
// media-30s.mp4 is now a real Big Buck Bunny CC-BY hero clip (committed).
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Vendored
+1
@@ -62,6 +62,7 @@ export const fixtures = {
|
||||
animated: {
|
||||
gif: p("image/valid/animated.gif"),
|
||||
webp: p("image/valid/animated.webp"),
|
||||
apng: p("image/valid/animated.apng"),
|
||||
real: p("image/valid/animated-simpsons.gif"),
|
||||
},
|
||||
ocr: {
|
||||
|
||||
@@ -120,13 +120,13 @@ describe("API docs", () => {
|
||||
const deployment = readFileSync(join(root, "apps/docs/guide/deployment.md"), "utf8");
|
||||
const architecture = readFileSync(join(root, "apps/docs/guide/architecture.md"), "utf8");
|
||||
|
||||
expect(gettingStarted).toContain("| **Image** | 105 |");
|
||||
expect(gettingStarted).toContain("| **Image** | 106 |");
|
||||
expect(gettingStarted).toContain("| **Video** | 57 |");
|
||||
expect(gettingStarted).toContain("| **Audio** | 27 |");
|
||||
expect(gettingStarted).toContain("| **PDF / Document** | 42 |");
|
||||
expect(gettingStarted).toContain("| **Files** | 10 |");
|
||||
expect(deployment).not.toContain("All 138 non-AI tools");
|
||||
expect(architecture).toContain("241 tool routes");
|
||||
expect(architecture).toContain("242 tool routes");
|
||||
});
|
||||
|
||||
it("serves an LLM summary with live catalog tools", async () => {
|
||||
@@ -136,7 +136,7 @@ describe("API docs", () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toContain("## Tools");
|
||||
expect(res.body).toContain("- Image (105 tools)");
|
||||
expect(res.body).toContain("- Image (106 tools)");
|
||||
expect(res.body).toContain("Resize - Resize by pixels");
|
||||
expect(res.body).toContain("Sign PDF -");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Integration tests for remove-gif-background
|
||||
* (/api/v1/tools/image/remove-gif-background).
|
||||
*
|
||||
* This tool needs the background-removal bundle + rembg models, which CI does
|
||||
* not have. To exercise the route's own logic (animation detection, frame cap,
|
||||
* background-image requirement) we mark the bundle installed in an isolated
|
||||
* DATA_DIR (the passport-photo-bundle-guard pattern). Those branches all reject
|
||||
* BEFORE enqueue, so no sidecar runs. One happy-path case asserts the 202 accept
|
||||
* contract; the actual per-frame removal is verified live on a GPU box.
|
||||
*
|
||||
* GIF_BG_MAX_FRAMES is pinned to 3 so the 4-frame APNG fixture trips the cap
|
||||
* while the 3-frame GIF/WebP fixtures pass.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
// ── Isolated DATA_DIR + config (set before any config/feature-status import) ──
|
||||
const testRoot = join(tmpdir(), `snapotter-gifbg-${randomUUID()}`);
|
||||
const aiDir = join(testRoot, "ai");
|
||||
const installedPath = join(aiDir, "installed.json");
|
||||
|
||||
process.env.DATA_DIR = testRoot;
|
||||
process.env.FEATURE_MANIFEST_PATH = join(process.cwd(), "docker/feature-manifest.json");
|
||||
process.env.GIF_BG_MAX_FRAMES = "3";
|
||||
|
||||
mkdirSync(join(aiDir, "models"), { recursive: true });
|
||||
writeFileSync(installedPath, JSON.stringify({ bundles: {} }), "utf-8");
|
||||
|
||||
// ── Dynamic imports (after env is set) ───────────────────────────────
|
||||
const { invalidateCache } = await import("../../../../apps/api/src/lib/feature-status.js");
|
||||
const { fixtures, readFixture } = await import("../../../fixtures/index.js");
|
||||
const { buildTestApp, createMultipartPayload, loginAsAdmin } = await import("../../test-server.js");
|
||||
|
||||
type TestAppType = Awaited<ReturnType<typeof buildTestApp>>;
|
||||
|
||||
const GIF = readFixture(fixtures.image.animated.gif); // 3 frames
|
||||
const WEBP = readFixture(fixtures.image.animated.webp); // 3 frames
|
||||
const APNG = readFixture(fixtures.image.animated.apng); // 4 frames
|
||||
const STILL_PNG = readFixture(fixtures.image.base.png200); // still
|
||||
|
||||
let testApp: TestAppType;
|
||||
let app: TestAppType["app"];
|
||||
let adminToken: string;
|
||||
|
||||
/** Overwrite installed.json so exactly the given bundles read as installed. */
|
||||
function setInstalled(bundleIds: string[]): void {
|
||||
const bundles: Record<string, { version: string; installedAt: string; models: string[] }> = {};
|
||||
for (const id of bundleIds) {
|
||||
bundles[id] = { version: "1.0.0-test", installedAt: "2026-01-01T00:00:00.000Z", models: [] };
|
||||
}
|
||||
writeFileSync(installedPath, JSON.stringify({ bundles }), "utf-8");
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
function post(
|
||||
content: Buffer,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
settings: Record<string, unknown> = {},
|
||||
) {
|
||||
const { body, contentType: ct } = createMultipartPayload([
|
||||
{ name: "file", filename, contentType, content },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/remove-gif-background",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": ct },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
rmSync(testRoot, { recursive: true, force: true });
|
||||
}, 15_000);
|
||||
|
||||
describe("Remove GIF Background (#496)", () => {
|
||||
it("returns 501 naming background-removal when the bundle is not installed", async () => {
|
||||
setInstalled([]);
|
||||
const res = await post(GIF, "a.gif", "image/gif");
|
||||
expect(res.statusCode).toBe(501);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
|
||||
expect(json.feature).toBe("background-removal");
|
||||
});
|
||||
|
||||
it("rejects a still image with NOT_ANIMATED once installed", async () => {
|
||||
setInstalled(["background-removal"]);
|
||||
const res = await post(STILL_PNG, "still.png", "image/png");
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).code).toBe("NOT_ANIMATED");
|
||||
});
|
||||
|
||||
it("rejects an over-cap animation with TOO_MANY_FRAMES", async () => {
|
||||
setInstalled(["background-removal"]);
|
||||
// Cap is 3; the APNG fixture has 4 frames.
|
||||
const res = await post(APNG, "a.apng", "image/apng");
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).code).toBe("TOO_MANY_FRAMES");
|
||||
});
|
||||
|
||||
it("requires a background image for backgroundType 'image' (animated WebP)", async () => {
|
||||
setInstalled(["background-removal"]);
|
||||
// WebP also passes detection + cap, then trips the background-image check.
|
||||
const res = await post(WEBP, "a.webp", "image/webp", { backgroundType: "image" });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/background image/i);
|
||||
});
|
||||
|
||||
it("rejects invalid settings JSON", async () => {
|
||||
setInstalled(["background-removal"]);
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "a.gif", contentType: "image/gif", content: GIF },
|
||||
{ name: "settings", content: "not valid json{{{" },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image/remove-gif-background",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/json/i);
|
||||
});
|
||||
|
||||
// The 202 accept path (valid in-cap animation -> enqueue) is intentionally not
|
||||
// asserted here: it would enqueue a job the CI worker can't process (no models),
|
||||
// producing async noise. It is verified live on a GPU box. These reject-before-
|
||||
// enqueue cases fully exercise the route's own logic.
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectAnimation } from "../../../apps/api/src/lib/animation-detect.js";
|
||||
import { fixtures, readFixture } from "../../fixtures/index.js";
|
||||
|
||||
describe("detectAnimation", () => {
|
||||
it("counts GIF frames via Sharp", async () => {
|
||||
const r = await detectAnimation(readFixture(fixtures.image.animated.gif), "animated.gif");
|
||||
expect(r.animated).toBe(true);
|
||||
expect(r.frames).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("counts animated WebP frames via Sharp", async () => {
|
||||
const r = await detectAnimation(readFixture(fixtures.image.animated.webp), "animated.webp");
|
||||
expect(r.animated).toBe(true);
|
||||
expect(r.frames).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("counts APNG frames via the acTL chunk", async () => {
|
||||
const r = await detectAnimation(readFixture(fixtures.image.animated.apng), "animated.apng");
|
||||
expect(r.animated).toBe(true);
|
||||
expect(r.frames).toBe(4);
|
||||
});
|
||||
|
||||
it("treats a still PNG as not animated", async () => {
|
||||
const r = await detectAnimation(readFixture(fixtures.image.base.png200), "still.png");
|
||||
expect(r.animated).toBe(false);
|
||||
expect(r.frames).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { apngFrameCount } from "../../../apps/api/src/lib/apng.js";
|
||||
import { fixtures, readFixture } from "../../fixtures/index.js";
|
||||
|
||||
describe("apngFrameCount", () => {
|
||||
it("returns null for a non-PNG buffer", () => {
|
||||
expect(apngFrameCount(Buffer.from("GIF89a-not-a-png-file"))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 1 for a still PNG (no acTL)", () => {
|
||||
expect(apngFrameCount(readFixture(fixtures.image.base.png200))).toBe(1);
|
||||
});
|
||||
|
||||
it("returns the frame count for a multi-frame APNG", () => {
|
||||
expect(apngFrameCount(readFixture(fixtures.image.animated.apng))).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ describe("toolSection", () => {
|
||||
TOOLS.filter((t) => toolSection(t) === s)
|
||||
.map((t) => t.id)
|
||||
.sort();
|
||||
expect(TOOLS.filter((t) => toolSection(t) === "image")).toHaveLength(105);
|
||||
expect(TOOLS.filter((t) => toolSection(t) === "image")).toHaveLength(106);
|
||||
expect(TOOLS.filter((t) => toolSection(t) === "video")).toHaveLength(57);
|
||||
expect(TOOLS.filter((t) => toolSection(t) === "audio")).toHaveLength(27);
|
||||
expect(bySection("pdf")).toHaveLength(29);
|
||||
|
||||
Reference in New Issue
Block a user