diff --git a/.env.example b/.env.example index 6bb068c9..80fe5b02 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api/src/lib/animation-detect.ts b/apps/api/src/lib/animation-detect.ts new file mode 100644 index 00000000..db255daf --- /dev/null +++ b/apps/api/src/lib/animation-detect.ts @@ -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 { + 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 }; +} diff --git a/apps/api/src/lib/apng.ts b/apps/api/src/lib/apng.ts new file mode 100644 index 00000000..392d72d3 --- /dev/null +++ b/apps/api/src/lib/apng.ts @@ -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; +} diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index e295fcc9..343c709f 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -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"), diff --git a/apps/api/src/openapi.yaml b/apps/api/src/openapi.yaml index 3a091bdc..ea636800 100644 --- a/apps/api/src/openapi.yaml +++ b/apps/api/src/openapi.yaml @@ -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 diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index 3bf06948..abb60744 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -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 { { 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 }, diff --git a/apps/api/src/routes/tools/remove-gif-background.ts b/apps/api/src/routes/tools/remove-gif-background.ts new file mode 100644 index 00000000..65f10d36 --- /dev/null +++ b/apps/api/src/routes/tools/remove-gif-background.ts @@ -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; + +function toWrapperOptions(s: z.infer, 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(() => {}); + } + }, + }); +} diff --git a/apps/docs/guide/architecture.md b/apps/docs/guide/architecture.md index 83a64396..041015d1 100644 --- a/apps/docs/guide/architecture.md +++ b/apps/docs/guide/architecture.md @@ -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) diff --git a/apps/docs/guide/getting-started.md b/apps/docs/guide/getting-started.md index c281db9c..01e158c7 100644 --- a/apps/docs/guide/getting-started.md +++ b/apps/docs/guide/getting-started.md @@ -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 | diff --git a/apps/landing/src/data/tool-seo.ts b/apps/landing/src/data/tool-seo.ts index 77642fa0..2035f5a0 100644 --- a/apps/landing/src/data/tool-seo.ts +++ b/apps/landing/src/data/tool-seo.ts @@ -3023,6 +3023,38 @@ export const TOOL_SEO: Record = { }, ], }, + "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: diff --git a/apps/web/src/components/tools/remove-gif-background-settings.tsx b/apps/web/src/components/tools/remove-gif-background-settings.tsx new file mode 100644 index 00000000..020b254c --- /dev/null +++ b/apps/web/src/components/tools/remove-gif-background-settings.tsx @@ -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 = { + fast: "u2net", + balanced: "birefnet-general-lite", + best: "birefnet-general", +}; + +function SectionLabel({ children }: { children: ReactNode }) { + return

{children}

; +} + +function OptionRow({ + options, + value, + onChange, +}: { + options: { value: T; label: string }[]; + value: T; + onChange: (v: T) => void; +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +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("fast"); + const [outputFormat, setOutputFormat] = useState("webp"); + const [backgroundType, setBackgroundType] = useState("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(null); + const bgInputRef = useRef(null); + + const hasFile = files.length > 0; + const needsBgImage = backgroundType === "image" && !bgFile; + + const handleProcess = () => { + const settings: Record = { + 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 ( +
+
+ {s.quality} + + value={quality} + onChange={setQuality} + options={[ + { value: "fast", label: s.qualityFast }, + { value: "balanced", label: s.qualityBalanced }, + { value: "best", label: s.qualityBest }, + ]} + /> +

{s.qualityNote}

+
+ +
+ {s.outputFormat} + + value={outputFormat} + onChange={setOutputFormat} + options={[ + { value: "webp", label: s.formatWebp }, + { value: "apng", label: s.formatApng }, + { value: "gif", label: s.formatGif }, + ]} + /> +

+ {outputFormat === "gif" ? s.gifCaveat : s.formatWebpHint} +

+
+ +
+ {s.background} + + 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" && ( + + )} + + {backgroundType === "gradient" && ( +
+
+ + +
+ +
+ )} + + {backgroundType === "blur" && ( + + )} + + {backgroundType === "image" && ( +
+ setBgFile(e.target.files?.[0] ?? null)} + /> + +
+ )} +
+ +
+ + {shadowEnabled && ( + + )} +
+ +
+ {s.advanced} + + +
+ +

{s.frameNote}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} + + {downloadUrl && !processing && files.length <= 1 && ( + + + {s.download} + + )} +
+ ); +} diff --git a/apps/web/src/lib/tool-display-modes.ts b/apps/web/src/lib/tool-display-modes.ts index 433ce109..5ba97d13 100644 --- a/apps/web/src/lib/tool-display-modes.ts +++ b/apps/web/src/lib/tool-display-modes.ts @@ -89,6 +89,7 @@ export const TOOL_DISPLAY_MODES: Record = { // AI tools "remove-background": "before-after", + "remove-gif-background": "before-after", upscale: "before-after", ocr: "before-after", "blur-faces": "before-after", diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index d6aceca2..0b0dcf7a 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -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 }], diff --git a/docker/feature-manifest.json b/docker/feature-manifest.json index 04e18464..0925ede5 100644 --- a/docker/feature-manifest.json +++ b/docker/feature-manifest.json @@ -87,6 +87,7 @@ "smokeImports": ["rembg", "onnxruntime"], "enablesTools": [ "remove-background", + "remove-gif-background", "passport-photo", "transparency-fixer", "background-replace", diff --git a/packages/ai/python/dispatcher.py b/packages/ai/python/dispatcher.py index 2c6f2682..7639fa8b 100644 --- a/packages/ai/python/dispatcher.py +++ b/packages/ai/python/dispatcher.py @@ -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", diff --git a/packages/ai/python/gif_remove_bg.py b/packages/ai/python/gif_remove_bg.py new file mode 100644 index 00000000..0abdb62b --- /dev/null +++ b/packages/ai/python/gif_remove_bg.py @@ -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() diff --git a/packages/ai/src/background-removal-animated.ts b/packages/ai/src/background-removal-animated.ts new file mode 100644 index 00000000..25925a65 --- /dev/null +++ b/packages/ai/src/background-removal-animated.ts @@ -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 = { + webp: "image/webp", + gif: "image/gif", + apng: "image/apng", +}; + +const FORMAT_EXT: Record = { + 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 { + 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(() => {}); + } +} diff --git a/packages/ai/src/feature-gate.ts b/packages/ai/src/feature-gate.ts index 446cae6f..6ee187c3 100644 --- a/packages/ai/src/feature-gate.ts +++ b/packages/ai/src/feature-gate.ts @@ -12,6 +12,7 @@ import { join } from "node:path"; */ export const SCRIPT_BUNDLE_MAP: Record = { remove_bg: "background-removal", + gif_remove_bg: "background-removal", detect_faces: "face-detection", face_landmarks: "face-detection", red_eye_removal: "face-detection", diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 794ad802..5ff4fc54 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -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, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 2599cd32..7b070109 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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", diff --git a/packages/shared/src/features.ts b/packages/shared/src/features.ts index 4d9c4a10..bc2cf17f 100644 --- a/packages/shared/src/features.ts +++ b/packages/shared/src/features.ts @@ -35,6 +35,7 @@ export const FEATURE_BUNDLES: Record = { estimatedSize: "4-5 GB", enablesTools: [ "remove-background", + "remove-gif-background", "passport-photo", "transparency-fixer", "background-replace", diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index 61b132d4..74ed2a92 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -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: "الجودة", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index 16f1007b..76b23729 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -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", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 79b794aa..7fee16c2 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -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", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index 735299ea..417638fb 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -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", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 5969f660..9d01293c 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -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é", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index 72bfeb30..d666998e 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -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: "क्वालिटी", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index b0365f55..dfbd0c8d 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -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", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index 826ee71d..ec8ef3e6 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -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à", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 496115a9..046f4a3e 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -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: "品質", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index 46e3ba30..35241ede 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -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: "품질", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 436ed167..99e4ee73 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -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", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 987a2ca1..ae416719 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -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ść", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 82d6e321..c517bafa 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -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", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index 5305c597..7aadad7a 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -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: "Качество", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index 3cd7d8a3..15287aa6 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -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", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index f68f5135..a91e03af 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -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: "คุณภาพ", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 45b9b586..1f01ee7c 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -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", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index 7692c5da..e95e9604 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -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: "Якість", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index e663921c..a697185b 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index fccac370..9e6319b1 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -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: "质量", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index 02e3de43..425f496f 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -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: "品質", diff --git a/tests/fixtures/gen-synthetic-content.mjs b/tests/fixtures/gen-synthetic-content.mjs index 62ef2b84..3adde7d6 100644 --- a/tests/fixtures/gen-synthetic-content.mjs +++ b/tests/fixtures/gen-synthetic-content.mjs @@ -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). diff --git a/tests/fixtures/image/valid/animated.apng b/tests/fixtures/image/valid/animated.apng new file mode 100644 index 00000000..5286f3d3 Binary files /dev/null and b/tests/fixtures/image/valid/animated.apng differ diff --git a/tests/fixtures/index.ts b/tests/fixtures/index.ts index 00b6f6f4..010cc77d 100644 --- a/tests/fixtures/index.ts +++ b/tests/fixtures/index.ts @@ -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: { diff --git a/tests/integration/platform/docs.test.ts b/tests/integration/platform/docs.test.ts index bca1318d..629e0bca 100644 --- a/tests/integration/platform/docs.test.ts +++ b/tests/integration/platform/docs.test.ts @@ -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 -"); }); diff --git a/tests/integration/tools/image/remove-gif-background.test.ts b/tests/integration/tools/image/remove-gif-background.test.ts new file mode 100644 index 00000000..5f953b85 --- /dev/null +++ b/tests/integration/tools/image/remove-gif-background.test.ts @@ -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>; + +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 = {}; + 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 = {}, +) { + 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. +}); diff --git a/tests/unit/api/animation-detect.test.ts b/tests/unit/api/animation-detect.test.ts new file mode 100644 index 00000000..444171dd --- /dev/null +++ b/tests/unit/api/animation-detect.test.ts @@ -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); + }); +}); diff --git a/tests/unit/api/apng.test.ts b/tests/unit/api/apng.test.ts new file mode 100644 index 00000000..0e842559 --- /dev/null +++ b/tests/unit/api/apng.test.ts @@ -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); + }); +}); diff --git a/tests/unit/shared/section.test.ts b/tests/unit/shared/section.test.ts index 4c9f2336..a5fa2876 100644 --- a/tests/unit/shared/section.test.ts +++ b/tests/unit/shared/section.test.ts @@ -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);