diff --git a/apps/api/src/lib/bg-effects.ts b/apps/api/src/lib/bg-effects.ts index ffc05f5c..8569ec14 100644 --- a/apps/api/src/lib/bg-effects.ts +++ b/apps/api/src/lib/bg-effects.ts @@ -1,5 +1,26 @@ import sharp from "sharp"; +export type BgOutputFormat = "png" | "webp" | "avif"; + +export const BG_OUTPUT_FORMATS: BgOutputFormat[] = ["png", "webp", "avif"]; + +export const BG_FORMAT_CONTENT_TYPES: Record = { + png: "image/png", + webp: "image/webp", + avif: "image/avif", +}; + +function toOutputFormat(pipeline: sharp.Sharp, format: BgOutputFormat): Promise { + switch (format) { + case "webp": + return pipeline.webp({ lossless: true }).toBuffer(); + case "avif": + return pipeline.avif({ lossless: true }).toBuffer(); + default: + return pipeline.png().toBuffer(); + } +} + /** * Background removal post-processing effects. * All effects use Sharp (libvips) for fast server-side image manipulation. @@ -182,6 +203,7 @@ export async function applyEffects( blurIntensity?: number; shadowEnabled?: boolean; shadowOpacity?: number; + outputFormat?: BgOutputFormat; }, ): Promise { const meta = await sharp(subjectBuffer).metadata(); @@ -238,13 +260,12 @@ export async function applyEffects( } // else: transparent - no background layer + const fmt = settings.outputFormat ?? "png"; + // Step 3: Composite subject onto background if (background) { - return sharp(background) - .composite([{ input: subject, blend: "over" }]) - .png() - .toBuffer(); + return toOutputFormat(sharp(background).composite([{ input: subject, blend: "over" }]), fmt); } - return subject; + return toOutputFormat(sharp(subject), fmt); } diff --git a/apps/api/src/routes/tools/remove-background.ts b/apps/api/src/routes/tools/remove-background.ts index c35a87a7..66b494e9 100644 --- a/apps/api/src/routes/tools/remove-background.ts +++ b/apps/api/src/routes/tools/remove-background.ts @@ -6,7 +6,11 @@ import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; -import { applyEffects } from "../../lib/bg-effects.js"; +import { + applyEffects, + BG_FORMAT_CONTENT_TYPES, + type BgOutputFormat, +} from "../../lib/bg-effects.js"; import { formatZodErrors } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; @@ -28,6 +32,9 @@ const settingsSchema = z.object({ blurIntensity: z.number().min(0).max(100).optional(), shadowEnabled: z.boolean().optional(), shadowOpacity: z.number().min(0).max(100).optional(), + outputFormat: z.enum(["png", "webp", "avif"]).optional(), + edgeRefine: z.number().int().min(0).max(3).optional(), + decontaminate: z.boolean().optional(), }); /** @@ -176,7 +183,11 @@ export function registerRemoveBackground(app: FastifyInstance) { const transparentResult = await removeBackground( fileBuffer, join(workspacePath, "output"), - { model: settings.model }, + { + model: settings.model, + edgeRefine: settings.edgeRefine, + decontaminate: settings.decontaminate, + }, onProgress, ); @@ -265,6 +276,7 @@ export function registerRemoveBackground(app: FastifyInstance) { blurIntensity: z.number().min(0).max(100).optional(), shadowEnabled: z.boolean().optional(), shadowOpacity: z.number().min(0).max(100).optional(), + outputFormat: z.enum(["png", "webp", "avif"]).optional(), }); try { @@ -308,6 +320,7 @@ export function registerRemoveBackground(app: FastifyInstance) { } // Apply effects using cached mask + original + const fmt = (settings.outputFormat ?? "png") as BgOutputFormat; const resultBuffer = await applyEffects(maskBuffer, originalBuffer, { backgroundType: settings.backgroundType, backgroundColor: settings.backgroundColor, @@ -319,10 +332,11 @@ export function registerRemoveBackground(app: FastifyInstance) { blurIntensity: settings.blurIntensity, shadowEnabled: settings.shadowEnabled, shadowOpacity: settings.shadowOpacity, + outputFormat: fmt, }); // Save the final output - const outputFilename = `${baseName}_nobg.png`; + const outputFilename = `${baseName}_nobg.${fmt}`; const outputPath = join(workspacePath, "output", outputFilename); await writeFile(outputPath, resultBuffer); @@ -354,9 +368,10 @@ export function registerRemoveBackground(app: FastifyInstance) { const transparentResult = await removeBackground( orientedBuffer, join(workspacePath, "output"), - { model: s.model }, + { model: s.model, edgeRefine: s.edgeRefine, decontaminate: s.decontaminate }, ); + const fmt = (s.outputFormat ?? "png") as BgOutputFormat; const resultBuffer = await applyEffects(transparentResult, orientedBuffer, { backgroundType: s.backgroundType, backgroundColor: s.backgroundColor, @@ -367,10 +382,15 @@ export function registerRemoveBackground(app: FastifyInstance) { blurIntensity: s.blurIntensity, shadowEnabled: s.shadowEnabled, shadowOpacity: s.shadowOpacity, + outputFormat: fmt, }); - const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`; - return { buffer: resultBuffer, filename: outputFilename, contentType: "image/png" }; + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.${fmt}`; + return { + buffer: resultBuffer, + filename: outputFilename, + contentType: BG_FORMAT_CONTENT_TYPES[fmt], + }; }, }); } diff --git a/apps/web/src/components/tools/remove-bg-settings.tsx b/apps/web/src/components/tools/remove-bg-settings.tsx index d99dff4e..95a55b1b 100644 --- a/apps/web/src/components/tools/remove-bg-settings.tsx +++ b/apps/web/src/components/tools/remove-bg-settings.tsx @@ -22,6 +22,7 @@ type BackgroundType = "transparent" | "color" | "gradient" | "image"; type BgModel = | "birefnet-general" | "birefnet-general-lite" + | "birefnet-hr-matting" | "birefnet-matting" | "birefnet-portrait" | "bria-rmbg" @@ -31,8 +32,8 @@ const MODEL_MAP: Record>> = { people: { fast: "u2net", balanced: "birefnet-portrait", - best: "birefnet-portrait", - ultra: "birefnet-matting", + best: "birefnet-matting", + ultra: "birefnet-hr-matting", }, products: { fast: "u2net", balanced: "bria-rmbg", best: "birefnet-general" }, general: { fast: "u2net", balanced: "birefnet-general-lite", best: "birefnet-general" }, @@ -105,6 +106,13 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont const [shadowEnabled, setShadowEnabled] = useState(false); const [shadowOpacity, setShadowOpacity] = useState(35); + // Post-processing + const [edgeRefine, setEdgeRefine] = useState(0); + const [decontaminate, setDecontaminate] = useState(false); + + // Output + const [outputFormat, setOutputFormat] = useState<"png" | "webp" | "avif">("png"); + // Expandable sections const [effectsOpen, setEffectsOpen] = useState(false); @@ -152,6 +160,10 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont next._bgImageFile = bgImageFile; } + if (edgeRefine > 0) next.edgeRefine = edgeRefine; + if (decontaminate) next.decontaminate = true; + if (outputFormat !== "png") next.outputFormat = outputFormat; + onChangeRef.current(next); }, [ model, @@ -165,6 +177,9 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont blurIntensity, shadowEnabled, shadowOpacity, + edgeRefine, + decontaminate, + outputFormat, ]); return ( @@ -387,6 +402,25 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont )} + {/* Output format */} + {t.toolSettings["remove-background"].outputFormat} +
+ {(["png", "webp", "avif"] as const).map((fmt) => ( + + ))} +
+ {/* Effects */} @@ -467,6 +501,48 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont )} + + {/* Edge smoothing */} +
+
+ + {t.toolSettings["remove-background"].edgeSmoothing} + + + {edgeRefine === 0 + ? t.toolSettings["remove-background"].edgeSmoothingOff + : edgeRefine === 1 + ? t.toolSettings["remove-background"].edgeSmoothingLight + : edgeRefine === 2 + ? t.toolSettings["remove-background"].edgeSmoothingMedium + : t.toolSettings["remove-background"].edgeSmoothingStrong} + +
+ setEdgeRefine(Number(e.target.value))} + className="w-full mt-0.5" + /> +
+ + {/* Color decontamination */} +
+ +
)} @@ -742,6 +818,7 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) { blurIntensity: settings.blurIntensity, shadowEnabled: settings.shadowEnabled, shadowOpacity: settings.shadowOpacity, + outputFormat: settings.outputFormat, }; formData.append("settings", JSON.stringify(effectSettings)); diff --git a/packages/ai/python/remove_bg.py b/packages/ai/python/remove_bg.py index 62240828..02bc0a33 100644 --- a/packages/ai/python/remove_bg.py +++ b/packages/ai/python/remove_bg.py @@ -5,10 +5,77 @@ import os def emit_progress(percent, stage): - """Emit structured progress to stderr for bridge.ts to capture.""" print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True) +def _refine_edges(image_bytes, level): + """Morphological mask refinement to reduce gray halos on edges. + + level: 1=light, 2=medium, 3=strong + """ + import cv2 + import numpy as np + from PIL import Image + import io + + img = Image.open(io.BytesIO(image_bytes)).convert("RGBA") + arr = np.array(img) + alpha = arr[:, :, 3] + + kernel_size = 1 + level + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) + alpha = cv2.morphologyEx(alpha, cv2.MORPH_CLOSE, kernel) + + sigma = 0.3 + level * 0.3 + alpha = cv2.GaussianBlur(alpha, (0, 0), sigma) + + arr[:, :, 3] = alpha + out = Image.fromarray(arr, "RGBA") + buf = io.BytesIO() + out.save(buf, format="PNG") + return buf.getvalue() + + +def _decontaminate_edges(image_bytes): + """Remove background color spill from semi-transparent edge pixels.""" + import numpy as np + from PIL import Image + import io + + img = Image.open(io.BytesIO(image_bytes)).convert("RGBA") + arr = np.array(img, dtype=np.float32) + alpha = arr[:, :, 3] / 255.0 + rgb = arr[:, :, :3] + + bg_mask = alpha < 0.04 + if not np.any(bg_mask): + return image_bytes + + bg_color = np.zeros(3, dtype=np.float32) + for c in range(3): + channel = rgb[:, :, c] + bg_pixels = channel[bg_mask] + if len(bg_pixels) > 0: + bg_color[c] = np.median(bg_pixels) + + edge_mask = (alpha > 0.04) & (alpha < 0.96) + if not np.any(edge_mask): + return image_bytes + + a = alpha[edge_mask, np.newaxis] + fg = rgb[edge_mask] + corrected = (fg - bg_color[np.newaxis, :] * (1.0 - a)) / np.maximum(a, 0.01) + corrected = np.clip(corrected, 0, 255) + rgb[edge_mask] = corrected + + arr[:, :, :3] = rgb + result = np.clip(arr, 0, 255).astype(np.uint8) + out = Image.fromarray(result, "RGBA") + buf = io.BytesIO() + out.save(buf, format="PNG") + return buf.getvalue() + + ALLOWED_MODELS = { "u2net", "isnet-general-use", @@ -167,6 +234,17 @@ def main(): emit_progress(80, "Background removed") + edge_refine = settings.get("edgeRefine", 0) + decontaminate = settings.get("decontaminate", False) + + if edge_refine and edge_refine > 0: + emit_progress(85, "Refining edges") + output_data = _refine_edges(output_data, int(edge_refine)) + + if decontaminate: + emit_progress(90, "Removing color spill") + output_data = _decontaminate_edges(output_data) + # Always return transparent PNG. All background compositing # (solid color, gradient, blur, shadow) is handled by Node.js/Sharp. diff --git a/packages/ai/src/background-removal.ts b/packages/ai/src/background-removal.ts index f459a026..135c2b6f 100644 --- a/packages/ai/src/background-removal.ts +++ b/packages/ai/src/background-removal.ts @@ -8,6 +8,8 @@ import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from ". export interface RemoveBackgroundOptions { model?: string; backgroundColor?: string; + edgeRefine?: number; + decontaminate?: boolean; } const MAX_REMBG_PX = Number(process.env.MAX_REMBG_PX) || 2048; diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index e2d98bbc..4fba10d0 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -587,6 +587,13 @@ export const ar: TranslationKeys = { intensity: "الشدة", addShadow: "إضافة ظل", opacity: "الشفافية", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "جاري المعالجة...", submit: "إزالة الخلفية", submitBatch: "إزالة الخلفية ({count} ملف)", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index e1420f02..6031e2f9 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -595,6 +595,13 @@ export const de: TranslationKeys = { intensity: "Intensitaet", addShadow: "Schatten hinzufuegen", opacity: "Deckkraft", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Wird gerendert...", submit: "Hintergrund entfernen", submitBatch: "Hintergrund entfernen ({count} Dateien)", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index c1d323ee..a8351789 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -545,6 +545,13 @@ export const en = { intensity: "Intensity", addShadow: "Add Shadow", opacity: "Opacity", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Rendering...", submit: "Remove Background", submitBatch: "Remove Background ({count} files)", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index 224383d4..cecfe36e 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -579,6 +579,13 @@ export const es: TranslationKeys = { intensity: "Intensidad", addShadow: "Agregar sombra", opacity: "Opacidad", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Renderizando...", submit: "Eliminar fondo", submitBatch: "Eliminar fondo ({count} archivos)", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 66835400..8de25c01 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -596,6 +596,13 @@ export const fr: TranslationKeys = { intensity: "Intensite", addShadow: "Ajouter une ombre", opacity: "Opacite", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Rendu en cours...", submit: "Supprimer l'arriere-plan", submitBatch: "Supprimer l'arriere-plan ({count} fichiers)", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index a485c27d..6491561a 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -583,6 +583,13 @@ export const hi: TranslationKeys = { intensity: "तीव्रता", addShadow: "शैडो जोड़ें", opacity: "ओपेसिटी", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "रेंडर हो रहा है...", submit: "बैकग्राउंड हटाएं", submitBatch: "बैकग्राउंड हटाएं ({count} फाइलें)", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index 070814aa..bb506a19 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -593,6 +593,13 @@ export const id: TranslationKeys = { intensity: "Intensitas", addShadow: "Tambah Bayangan", opacity: "Opasitas", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Merender...", submit: "Hapus Latar Belakang", submitBatch: "Hapus Latar Belakang ({count} file)", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index c40cfae1..b7852550 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -592,6 +592,13 @@ export const it: TranslationKeys = { intensity: "Intensita", addShadow: "Aggiungi ombra", opacity: "Opacita", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Rendering...", submit: "Rimuovi sfondo", submitBatch: "Rimuovi sfondo ({count} file)", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 24a25cf7..7f1af33b 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -552,6 +552,13 @@ export const ja: TranslationKeys = { intensity: "強度", addShadow: "シャドウ追加", opacity: "不透明度", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "レンダリング中...", submit: "背景を除去", submitBatch: "背景を除去({count}ファイル)", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index ae30b549..f814640f 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -539,6 +539,13 @@ export const ko: TranslationKeys = { intensity: "강도", addShadow: "그림자 추가", opacity: "불투명도", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "렌더링 중...", submit: "배경 제거", submitBatch: "배경 제거 ({count}개 파일)", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 8bf28628..441cbbce 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -593,6 +593,13 @@ export const nl: TranslationKeys = { intensity: "Intensiteit", addShadow: "Schaduw toevoegen", opacity: "Dekking", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Renderen...", submit: "Achtergrond verwijderen", submitBatch: "Achtergrond verwijderen ({count} bestanden)", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index e4c9efe2..5b5ed803 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -596,6 +596,13 @@ export const pl: TranslationKeys = { intensity: "Intensywność", addShadow: "Dodaj cień", opacity: "Krycie", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Renderowanie...", submit: "Usuń tło", submitBatch: "Usuń tło ({count} plików)", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 91066214..426da5df 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -592,6 +592,13 @@ export const ptBR: TranslationKeys = { intensity: "Intensidade", addShadow: "Adicionar sombra", opacity: "Opacidade", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Renderizando...", submit: "Remover fundo", submitBatch: "Remover fundo ({count} arquivos)", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index f713fa4f..235fd1ca 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -594,6 +594,13 @@ export const ru: TranslationKeys = { intensity: "Интенсивность", addShadow: "Добавить тень", opacity: "Непрозрачность", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Рендеринг...", submit: "Удалить фон", submitBatch: "Удалить фон ({count} файлов)", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index f933eff2..f8b62c23 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -591,6 +591,13 @@ export const sv: TranslationKeys = { intensity: "Intensitet", addShadow: "Lagg till skugga", opacity: "Opacitet", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Renderar...", submit: "Ta bort bakgrund", submitBatch: "Ta bort bakgrund ({count} filer)", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index a23a489a..db43c7b2 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -583,6 +583,13 @@ export const th: TranslationKeys = { intensity: "ความเข้ม", addShadow: "เพิ่มเงา", opacity: "ความทึบ", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "กำลังเรนเดอร์...", submit: "ลบพื้นหลัง", submitBatch: "ลบพื้นหลัง ({count} ไฟล์)", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 76d60ecb..a43c3a24 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -595,6 +595,13 @@ export const tr: TranslationKeys = { intensity: "Yoğunluk", addShadow: "Gölge Ekle", opacity: "Opaklık", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "İşleniyor...", submit: "Arka Planı Kaldır", submitBatch: "Arka Planı Kaldır ({count} dosya)", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index 48a3efdd..f63d4d31 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -594,6 +594,13 @@ export const uk: TranslationKeys = { intensity: "Інтенсивність", addShadow: "Додати тінь", opacity: "Непрозорість", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Рендеринг...", submit: "Видалити фон", submitBatch: "Видалити фон ({count} файлів)", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 9c1038a5..c1b4f568 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -594,6 +594,13 @@ export const vi: TranslationKeys = { intensity: "Cường độ", addShadow: "Thêm bóng đổ", opacity: "Độ mờ", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Đang kết xuất...", submit: "Xóa nền", submitBatch: "Xóa nền ({count} tệp)", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index a458cc93..4a9b30e3 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -537,6 +537,13 @@ export const zhCN: TranslationKeys = { intensity: "强度", addShadow: "添加阴影", opacity: "不透明度", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "渲染中...", submit: "移除背景", submitBatch: "移除背景({count} 个文件)", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index cee29353..9aa7be1a 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -536,6 +536,13 @@ export const zhTW: TranslationKeys = { intensity: "強度", addShadow: "加入陰影", opacity: "不透明度", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "算繪中...", submit: "移除背景", submitBatch: "移除背景({count}個檔案)", diff --git a/tests/integration/remove-background.test.ts b/tests/integration/remove-background.test.ts index cf6e71cb..bcebc3c2 100644 --- a/tests/integration/remove-background.test.ts +++ b/tests/integration/remove-background.test.ts @@ -387,6 +387,102 @@ describe("Remove Background", () => { } }); + it("accepts edge refinement and decontamination settings", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ edgeRefine: 2, decontaminate: true }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/remove-background", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect([202, 501]).toContain(res.statusCode); + }, 60_000); + + it("accepts output format settings", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ outputFormat: "webp" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/remove-background", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect([202, 501]).toContain(res.statusCode); + }, 60_000); + + it("rejects edgeRefine out of range", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ edgeRefine: 5 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/remove-background", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect([400, 501]).toContain(res.statusCode); + if (res.statusCode === 400) { + const result = JSON.parse(res.body); + expect(result.error).toMatch(/invalid settings/i); + } + }); + + it("rejects invalid output format", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ outputFormat: "gif" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/remove-background", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect([400, 501]).toContain(res.statusCode); + if (res.statusCode === 400) { + const result = JSON.parse(res.body); + expect(result.error).toMatch(/invalid settings/i); + } + }); + it("rejects unauthenticated requests", async () => { const { body, contentType } = createMultipartPayload([ { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, diff --git a/tests/unit/ai/background-removal.test.ts b/tests/unit/ai/background-removal.test.ts index 7474bf29..7442f221 100644 --- a/tests/unit/ai/background-removal.test.ts +++ b/tests/unit/ai/background-removal.test.ts @@ -97,6 +97,35 @@ describe("removeBackground", () => { }); }); + it("serializes edgeRefine option into the args JSON", async () => { + await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { edgeRefine: 2 }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + expect(JSON.parse(args[2])).toEqual({ edgeRefine: 2 }); + }); + + it("serializes decontaminate option into the args JSON", async () => { + await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { decontaminate: true }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + expect(JSON.parse(args[2])).toEqual({ decontaminate: true }); + }); + + it("serializes all post-processing options together", async () => { + await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { + model: "birefnet-matting", + edgeRefine: 1, + decontaminate: true, + }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + expect(JSON.parse(args[2])).toEqual({ + model: "birefnet-matting", + edgeRefine: 1, + decontaminate: true, + }); + }); + it("converts input to PNG via sharp before writing to disk", async () => { await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);