Merge feat/transparency-fixer: add PNG Transparency Fixer tool

New AI-powered tool that fixes fake transparent PNGs in one click.
Uses BiRefNet HR-matting (2048x2048) with Sharp defringe post-processing.
This commit is contained in:
SnapOtter
2026-05-06 21:57:46 +08:00
38 changed files with 1399 additions and 117 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
## Key Features
- **47 image tools** - Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, find duplicates, generate passport photos, and more
- **48 image tools** - Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, find duplicates, generate passport photos, and more
- **Local AI** - Remove backgrounds, upscale images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR). All on your hardware - no internet required
- **Pipelines** - Chain tools into reusable workflows with unlimited steps. Batch process unlimited images at once
- **REST API** - Every tool available via API with API key auth. Interactive docs at `/api/docs`
+76 -1
View File
@@ -3,7 +3,7 @@ info:
title: SnapOtter API
version: 1.15.9
description: |
REST API for SnapOtter, a self-hosted image processing platform with 47 tools.
REST API for SnapOtter, a self-hosted image processing platform with 48 tools.
## Authentication
@@ -3225,6 +3225,81 @@ paths:
schema:
$ref: "#/components/schemas/UnauthorizedError"
/api/v1/tools/transparency-fixer:
post:
tags: [Tools]
summary: Fix fake transparency
description: |
Fix "fake transparent" PNGs that have fringing, halos, or semi-transparent
artifacts from a previous background removal. Uses BiRefNet HR-matting
(2048x2048) to produce a clean alpha channel with configurable defringe
processing. Falls back to birefnet-general, then u2net on OOM. Requires
the background-removal feature bundle to be installed.
security:
- bearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: PNG image with fake or damaged transparency
settings:
type: string
description: |
JSON string with options:
- `defringe` (number 0-100, default 30) — Edge defringe strength to remove color contamination
- `outputFormat` (string, default "png") — One of: png, webp
clientJobId:
type: string
description: Client-provided job ID for SSE progress tracking
responses:
"200":
description: Image with corrected transparency
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/ToolResponse"
- type: object
properties:
width:
type: integer
height:
type: integer
model:
type: string
description: AI model that was used (may differ from default due to OOM fallback)
"400":
description: Invalid input
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Authentication required
content:
application/json:
schema:
$ref: "#/components/schemas/UnauthorizedError"
"422":
description: Processing failed
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"501":
description: Feature not installed
content:
application/json:
schema:
$ref: "#/components/schemas/FeatureNotInstalledError"
/api/v1/tools/upscale:
post:
tags: [Tools]
+1 -1
View File
@@ -39,7 +39,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
lines.push(`# ${spec.info.title}`);
lines.push("");
lines.push(
"> Self-hosted image processing API with 47 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
"> Self-hosted image processing API with 48 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
);
lines.push("");
lines.push("## Docs");
+2
View File
@@ -45,6 +45,7 @@ import { registerStitch } from "./stitch.js";
import { registerStripMetadata } from "./strip-metadata.js";
import { registerSvgToRaster } from "./svg-to-raster.js";
import { registerTextOverlay } from "./text-overlay.js";
import { registerTransparencyFixer } from "./transparency-fixer.js";
import { registerUpscale } from "./upscale.js";
import { registerVectorize } from "./vectorize.js";
import { registerWatermarkImage } from "./watermark-image.js";
@@ -146,6 +147,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "passport-photo", register: registerPassportPhoto },
{ id: "red-eye-removal", register: registerRedEyeRemoval },
{ id: "restore-photo", register: registerRestorePhoto },
{ id: "transparency-fixer", register: registerTransparencyFixer },
];
let skipped = 0;
@@ -0,0 +1,316 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { removeBackground } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } 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 { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { registerToolProcessFn } from "../tool-factory.js";
const TOOL_ID = "transparency-fixer";
const DEFAULT_MODEL = "birefnet-hr-matting";
const FALLBACK_MODEL = "birefnet-general";
const settingsSchema = z.object({
defringe: z.number().min(0).max(100).optional().default(30),
outputFormat: z.enum(["png", "webp"]).optional().default("png"),
});
/**
* Sharp-based defringe post-processing.
*
* Removes semi-transparent fringe pixels that rembg sometimes leaves around
* hair, fur, and fine edges. Works by blurring the alpha channel and zeroing
* out pixels whose alpha falls below a computed threshold.
*/
async function applyDefringe(buffer: Buffer, intensity: number): Promise<Buffer> {
if (intensity <= 0) return buffer;
const img = sharp(buffer);
const { width, height, channels } = await img.metadata();
if (!width || !height || channels !== 4) return buffer;
const { data, info } = await img.raw().toBuffer({ resolveWithObject: true });
const pixelCount = info.width * info.height;
// Extract alpha channel
const alpha = Buffer.alloc(pixelCount);
for (let i = 0; i < pixelCount; i++) {
alpha[i] = data[i * 4 + 3];
}
// Blur the alpha channel
const blurRadius = Math.max(0.3, Math.round(intensity / 20));
const blurredAlphaRaw = await sharp(alpha, {
raw: { width: info.width, height: info.height, channels: 1 },
})
.blur(blurRadius)
.raw()
.toBuffer();
// Threshold: zero out fringe pixels
const threshold = Math.round(128 + (intensity / 100) * 80);
const result = Buffer.from(data);
for (let i = 0; i < pixelCount; i++) {
if (alpha[i] > 0 && blurredAlphaRaw[i] < threshold) {
result[i * 4] = 0;
result[i * 4 + 1] = 0;
result[i * 4 + 2] = 0;
result[i * 4 + 3] = 0;
}
}
return sharp(result, {
raw: { width: info.width, height: info.height, channels: 4 },
})
.png()
.toBuffer();
}
/**
* Run transparency fix: rembg matting -> defringe -> output format.
*/
async function processTransparencyFix(
inputBuffer: Buffer,
settings: z.infer<typeof settingsSchema>,
outputDir: string,
onProgress?: (percent: number, stage: string) => void,
): Promise<Buffer> {
let resultBuffer: Buffer;
try {
resultBuffer = await removeBackground(
inputBuffer,
outputDir,
{ model: DEFAULT_MODEL },
onProgress,
);
} catch (err) {
const isOom = err instanceof Error && err.message.includes("out of memory");
if (!isOom) throw err;
// removeBackground has its own internal u2net fallback on OOM.
// This route-level fallback provides an intermediate quality step
// (birefnet-general) before that kicks in on a second OOM.
onProgress?.(5, `Retrying with fallback model (${FALLBACK_MODEL})`);
resultBuffer = await removeBackground(
inputBuffer,
outputDir,
{ model: FALLBACK_MODEL },
onProgress,
);
}
// Apply defringe post-processing
resultBuffer = await applyDefringe(resultBuffer, settings.defringe);
// Convert to output format if requested
if (settings.outputFormat === "webp") {
resultBuffer = await sharp(resultBuffer).webp({ lossless: true }).toBuffer();
}
return resultBuffer;
}
export function registerTransparencyFixer(app: FastifyInstance) {
app.post(
"/api/v1/tools/transparency-fixer",
async (request: FastifyRequest, reply: FastifyReply) => {
if (!isToolInstalled(TOOL_ID)) {
const bundle = getBundleForTool(TOOL_ID);
return reply.status(501).send({
error: "Feature not installed",
code: "FEATURE_NOT_INSTALLED",
feature: TOOL_BUNDLE_MAP[TOOL_ID],
featureName: bundle?.name ?? TOOL_ID,
estimatedSize: bundle?.estimatedSize ?? "unknown",
});
}
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) chunks.push(chunk);
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
} 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;
}
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
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}` });
}
let settings: z.infer<typeof settingsSchema>;
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" });
}
try {
// Decode HEIC/HEIF before processing
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
const ext = filename.match(/\.[^.]+$/)?.[0];
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
const ext = filename.match(/\.[^.]+$/)?.[0];
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
}
// Auto-orient to fix EXIF rotation
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: TOOL_ID }, "Input decoding failed");
return reply.status(422).send({
error: "Transparency fix failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: TOOL_ID }, "Workspace creation failed");
return reply.status(422).send({
error: "Transparency fix failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const log = request.log;
log.info(
{ toolId: TOOL_ID, imageSize: originalSize, model: DEFAULT_MODEL },
"Starting transparency fix",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent: Math.min(percent, 95),
});
};
const outputExt = settings.outputFormat === "webp" ? "webp" : "png";
// Fire-and-forget: processing happens after the response is sent
(async () => {
const resultBuffer = await processTransparencyFix(
fileBuffer,
settings,
join(workspacePath, "output"),
onProgress,
);
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_fixed.${outputExt}`;
await writeFile(join(workspacePath, "output", outputFilename), resultBuffer);
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
originalSize,
processedSize: resultBuffer.length,
filename,
},
});
log.info({ toolId: TOOL_ID, jobId, downloadUrl }, "Transparency fix complete");
})().catch((err) => {
log.error({ err, toolId: TOOL_ID }, "Transparency fix failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Transparency fix failed",
});
});
},
);
// Pipeline/batch registry
registerToolProcessFn({
toolId: TOOL_ID,
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const s = settings as z.infer<typeof settingsSchema>;
const orientedBuffer = await autoOrient(inputBuffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const resultBuffer = await processTransparencyFix(
orientedBuffer,
s,
join(workspacePath, "output"),
);
const outputExt = s.outputFormat === "webp" ? "webp" : "png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_fixed.${outputExt}`;
const contentType = outputExt === "webp" ? "image/webp" : "image/png";
return { buffer: resultBuffer, filename: outputFilename, contentType };
},
});
}
+2 -2
View File
@@ -4,7 +4,7 @@ import llmstxt from "vitepress-plugin-llms";
export default defineConfig({
title: "SnapOtter",
description:
"Documentation for SnapOtter - A Self Hosted Image Manipulator. 47 tools, local AI, pipelines, REST API.",
"Documentation for SnapOtter - A Self Hosted Image Manipulator. 48 tools, local AI, pipelines, REST API.",
base: "/",
appearance: { initialValue: "light" },
srcDir: ".",
@@ -48,7 +48,7 @@ export default defineConfig({
`,
customTemplateVariables: {
description:
"SnapOtter is a self-hosted, open-source image processing platform with 47 tools including AI/ML. Runs in a single Docker container with GPU auto-detection.",
"SnapOtter is a self-hosted, open-source image processing platform with 48 tools including AI/ML. Runs in a single Docker container with GPU auto-detection.",
details:
"Resize, compress, convert, remove backgrounds, upscale, run OCR, and more - without sending images to external services.",
},
+26 -1
View File
@@ -2,7 +2,7 @@
The `@snapotter/ai` package bridges Node.js to a **persistent Python sidecar** for all ML operations. The dispatcher process stays alive between requests for fast warm-start performance. GPU is auto-detected at startup and used when available.
14 AI tool routes. All models run locally - no internet required after initial model download.
15 AI tool routes. All models run locally - no internet required after initial model download.
## Architecture
@@ -26,6 +26,7 @@ Node.js Tool Route
├─ noise_removal.py (tiered denoising)
├─ red_eye_removal.py (landmark + color analysis)
├─ restore.py (scratch repair + enhancement + denoising)
├─ transparency_fix.py (BiRefNet HR-matting + defringe)
└─ seam_carving (Go caire binary - not Python)
```
@@ -255,3 +256,27 @@ Intelligently resizes images by removing or adding low-energy seams, preserving
| `square` | boolean | false | Force square output |
Max input edge before auto-downscaling: **1200 px**.
## PNG Transparency Fixer
**Function:** `fixTransparency`
**Tool route:** `transparency-fixer`
**Model:** BiRefNet HR-matting (2048x2048 resolution)
Fixes "fake transparent" PNGs where the background was removed but left behind fringing, halos, or semi-transparent artifacts. Uses BiRefNet's high-resolution matting model to produce a clean alpha channel, then applies configurable defringe processing to remove color contamination along edges.
**OOM fallback chain:** If BiRefNet HR-matting exceeds available memory, the tool automatically falls back to `birefnet-general`, then to `u2net`.
**Feature bundle:** Background Removal (shared with Remove Background and Passport Photo).
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `defringe` | number (0-100) | 30 | Edge defringe strength to remove color contamination |
| `outputFormat` | `"png"` \| `"webp"` | `"png"` | Output image format |
```bash
curl -X POST http://localhost:1349/api/v1/tools/transparency-fixer \
-H "Authorization: Bearer <token>" \
-F "file=@fake-transparent.png" \
-F 'settings={"defringe":30,"outputFormat":"png"}'
```
+1
View File
@@ -152,6 +152,7 @@ All AI tools run on your hardware (CPU or NVIDIA GPU). No internet required.
| `restore-photo` | Photo Restoration | Multi-step pipeline | `mode` (auto/light/heavy), `scratchRemoval`, `faceEnhancement`, `fidelity`, `denoise`, `denoiseStrength`, `colorize` |
| `passport-photo` | Passport Photo | MediaPipe landmarks | `country` (37 countries), `printLayout` (4x6/A4/none), `backgroundColor` |
| `content-aware-resize` | Content-Aware Resize | Seam carving (caire) | `width`, `height`, `protectFaces`, `blurRadius`, `sobelThreshold`, `square` |
| `transparency-fixer` | PNG Transparency Fixer | BiRefNet HR-matting | `defringe` (0-100), `outputFormat` (png/webp) |
### Watermark & Overlay
+2 -2
View File
@@ -31,7 +31,7 @@ A bridge layer that calls Python scripts for ML operations. On first use, the br
**Models are not pre-loaded.** Each tool script loads its model weights from disk at request time and discards them when the request finishes. See [Resource footprint](#resource-footprint) for the full memory profile.
Supported operations: background removal (rembg/BiRefNet), upscaling (RealESRGAN), face blur (MediaPipe), face enhancement (GFPGAN/CodeFormer), object erasing (LaMa ONNX), OCR (PaddleOCR/Tesseract), colorization (DDColor), noise removal, red eye removal, photo restoration, passport photo generation, and content-aware resize (Go caire binary).
Supported operations: background removal (rembg/BiRefNet), upscaling (RealESRGAN), face blur (MediaPipe), face enhancement (GFPGAN/CodeFormer), object erasing (LaMa ONNX), OCR (PaddleOCR/Tesseract), colorization (DDColor), noise removal, red eye removal, photo restoration, passport photo generation, transparency fixing (BiRefNet HR-matting), and content-aware resize (Go caire binary).
Python scripts live in `packages/ai/python/`. The Docker image pre-downloads all model weights during the build so the container works fully offline.
@@ -43,7 +43,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
### API (`apps/api`)
A Fastify v5 server exposing 47 tool routes (33 standard image operations + 14 AI-powered) that handles:
A Fastify v5 server exposing 48 tool routes (33 standard image operations + 15 AI-powered) 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)
+2 -2
View File
@@ -64,14 +64,14 @@ pnpm dev
## What You Can Do
### Image Processing (47 Tools)
### Image Processing (48 Tools)
| Category | Tools |
|----------|-------|
| **Essentials** | Resize, Crop, Rotate & Flip, Convert, Compress |
| **Optimization** | Optimize for Web, Strip Metadata, Edit Metadata, Bulk Rename, Image to PDF, Favicon Generator |
| **Adjustments** | Adjust Colors, Sharpening, Replace Color |
| **AI Tools** | Remove Background, Upscale, Erase Object, OCR, Blur Faces, Smart Crop, Image Enhancement, Enhance Faces, Colorize, Noise Removal, Red Eye Removal, Restore Photo, Passport Photo, Content-Aware Resize |
| **AI Tools** | Remove Background, Upscale, Erase Object, OCR, Blur Faces, Smart Crop, Image Enhancement, Enhance Faces, Colorize, Noise Removal, Red Eye Removal, Restore Photo, Passport Photo, Content-Aware Resize, PNG Transparency Fixer |
| **Watermark & Overlay** | Text Watermark, Image Watermark, Text Overlay, Image Composition |
| **Utilities** | Image Info, Compare, Find Duplicates, Color Palette, QR Code Generator, Barcode Reader, Image to Base64 |
| **Layout** | Collage, Stitch, Split, Border & Frame |
+3 -3
View File
@@ -4,7 +4,7 @@ layout: home
hero:
name: "SnapOtter"
text: "A Self Hosted Image Manipulator"
tagline: 47 tools. Local AI. No cloud. Your images never leave your home.
tagline: 48 tools. Local AI. No cloud. Your images never leave your home.
actions:
- theme: brand
text: Get started
@@ -14,10 +14,10 @@ hero:
link: /api/rest
features:
- title: 47 Image Tools
- title: 48 Image Tools
details: Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, build collages, generate passport photos, find duplicates, and more.
- title: Local AI
details: 14 AI-powered tools - remove backgrounds, upscale, enhance images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR). All on your hardware, no internet required.
details: 15 AI-powered tools - remove backgrounds, upscale, enhance images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR), fix fake transparency. All on your hardware, no internet required.
- title: Pipelines
details: Chain tools into reusable workflows with unlimited steps. Batch process unlimited images at once with a single request.
- title: REST API
+3 -3
View File
@@ -8,12 +8,12 @@ const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito" });
export const metadata: Metadata = {
title: "SnapOtter | Self-Hosted Image Processing",
description:
"47 image processing tools with local AI. Runs 100% offline. No data leaves your network. Open source and free forever.",
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network. Open source and free forever.",
metadataBase: new URL("https://snapotter.com"),
openGraph: {
title: "SnapOtter | Self-Hosted Image Processing",
description:
"47 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
url: "https://snapotter.com",
siteName: "SnapOtter",
type: "website",
@@ -30,7 +30,7 @@ export const metadata: Metadata = {
card: "summary_large_image",
title: "SnapOtter | Self-Hosted Image Processing",
description:
"47 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
images: ["/og-image.svg"],
},
};
+8 -1
View File
@@ -9,6 +9,7 @@ import {
Columns2,
Copy,
Crop,
Droplets,
Eraser,
Eye,
EyeOff,
@@ -237,6 +238,12 @@ const tools: { name: string; description: string; category: string; icon: Lucide
category: "ai",
icon: Scaling,
},
{
name: "PNG Transparency Fixer",
description: "Fix fake transparent PNGs with AI matting",
category: "ai",
icon: Droplets,
},
// Watermark & Overlay
{
name: "Text Watermark",
@@ -389,7 +396,7 @@ export function BentoGrid() {
<div className="mx-auto max-w-6xl">
<FadeIn>
<h2 className="font-[family-name:var(--font-nunito)] text-center text-3xl font-bold tracking-tight md:text-4xl">
47 tools. Zero cloud dependency.
48 tools. Zero cloud dependency.
</h2>
<p className="mx-auto mt-4 max-w-xl text-center text-lg text-muted">
Search to find exactly what you need. Every tool runs 100% locally.
+2 -2
View File
@@ -7,12 +7,12 @@ const freePlan = {
price: "Free",
subtitle: "For everyone. Forever.",
features: [
"All 47 image processing tools",
"All 48 image processing tools",
"Unlimited usage, no hidden caps",
"Full REST API with OpenAPI docs",
"Pipeline automation",
"Batch processing (unlimited files)",
"14 local AI models included",
"15 local AI models included",
"Self-host on any infrastructure",
"Docker, Kubernetes, bare metal",
"ARM and x86 support",
@@ -11,8 +11,8 @@ const phrases = [
"No limits. No hidden caps.",
"Works fully offline.",
"Unlimited batch processing.",
"47 image tools.",
"14 AI models. Your hardware.",
"48 image tools.",
"15 AI models. Your hardware.",
"Lightning fast. Built on Sharp.",
"Air-gapped ready.",
"One Docker container.",
@@ -1,5 +1,5 @@
import type { FeatureBundleState } from "@snapotter/shared";
import { AlertCircle, Download, Loader2, RotateCcw } from "lucide-react";
import { AlertCircle, Clock, Download, Loader2, RotateCcw } from "lucide-react";
import { useEffect, useState } from "react";
import { useFeaturesStore } from "@/stores/features-store";
@@ -46,14 +46,17 @@ function formatTimeRemaining(ms: number): string {
interface FeatureInstallPromptProps {
bundle: FeatureBundleState;
isAdmin: boolean;
toolName?: string;
}
export function FeatureInstallPrompt({ bundle, isAdmin }: FeatureInstallPromptProps) {
const { installBundle, clearError, installing, errors, startTimes } = useFeaturesStore();
export function FeatureInstallPrompt({ bundle, isAdmin, toolName }: FeatureInstallPromptProps) {
const { installBundle, clearError, installing, errors, startTimes, queued } = useFeaturesStore();
const progress = installing[bundle.id] ?? null;
const error = errors[bundle.id] ?? null;
const isInstalling = !!progress;
const isQueued = queued.includes(bundle.id);
const startTime = startTimes[bundle.id] ?? null;
const displayName = toolName || bundle.name;
const [messageIndex, setMessageIndex] = useState(() =>
Math.floor(Math.random() * PROGRESS_MESSAGES.length),
@@ -99,7 +102,7 @@ export function FeatureInstallPrompt({ bundle, isAdmin }: FeatureInstallPromptPr
<div className="flex flex-col items-center justify-center h-full gap-6 text-center px-4">
<Download className="h-16 w-16 text-muted-foreground" />
<div className="space-y-2">
<h2 className="text-xl font-semibold text-foreground">{bundle.name}</h2>
<h2 className="text-xl font-semibold text-foreground">{displayName}</h2>
<p className="text-muted-foreground max-w-md">{bundle.description}</p>
<p className="text-sm text-muted-foreground">
This feature requires an additional download (~{bundle.estimatedSize})
@@ -139,13 +142,20 @@ export function FeatureInstallPrompt({ bundle, isAdmin }: FeatureInstallPromptPr
</div>
)}
{!isInstalling && !error && (
{isQueued && (
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-5 w-5" />
<span className="text-sm font-medium">Queued for installation...</span>
</div>
)}
{!isInstalling && !error && !isQueued && (
<button
type="button"
onClick={handleInstall}
className="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 font-medium"
>
Enable {bundle.name}
Enable {displayName}
</button>
)}
</div>
@@ -2535,7 +2535,7 @@ function AboutSection() {
</div>
</div>
<p className="text-sm text-muted-foreground">
A self-hosted, privacy-first image processing suite with 47 tools. Resize, compress,
A self-hosted, privacy-first image processing suite with 48 tools. Resize, compress,
convert, watermark, and automate your image workflows without sending data to the cloud.
</p>
<div className="flex items-center gap-4 text-sm">
@@ -0,0 +1,140 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
type OutputFormat = "png" | "webp";
// ── Shared controls (used by both standalone page and pipeline steps) ──
export interface TransparencyFixerControlsProps {
settings: Record<string, unknown>;
onChange: (settings: Record<string, unknown>) => void;
}
export function TransparencyFixerControls({
settings: _settings,
onChange,
}: TransparencyFixerControlsProps) {
const [defringe, setDefringe] = useState(30);
const [outputFormat, setOutputFormat] = useState<OutputFormat>("png");
const [advancedOpen, setAdvancedOpen] = useState(false);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
});
// Sync settings on every control change
useEffect(() => {
onChangeRef.current({ defringe, outputFormat });
}, [defringe, outputFormat]);
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Upload a PNG with a fake transparent background and we'll fix it in one click.
</p>
{/* Advanced toggle */}
<button
type="button"
onClick={() => setAdvancedOpen(!advancedOpen)}
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground w-full pt-1"
>
{advancedOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Advanced
</button>
{advancedOpen && (
<div className="space-y-3 pl-1">
{/* Defringe slider */}
<div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Defringe</span>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
{defringe}
</span>
</div>
<input
type="range"
min={0}
max={100}
value={defringe}
onChange={(e) => setDefringe(Number(e.target.value))}
className="w-full mt-0.5"
/>
</div>
{/* Output Format dropdown */}
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 mb-1">
Output Format
</p>
<select
value={outputFormat}
onChange={(e) => setOutputFormat(e.target.value as OutputFormat)}
className="px-2 py-1.5 rounded-lg border border-border bg-background text-xs text-foreground"
>
<option value="png">PNG</option>
<option value="webp">WebP</option>
</select>
</div>
</div>
)}
</div>
);
}
// ── Standalone tool page wrapper ──
export function TransparencyFixerSettings() {
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("transparency-fixer");
const [settings, setSettings] = useState<Record<string, unknown>>({});
const handleProcess = () => {
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
return (
<div className="space-y-4">
<TransparencyFixerControls settings={settings} onChange={setSettings} />
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process button / progress */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={
hasMultiple ? `Fixing transparency (${files.length} files)` : "Fixing transparency"
}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="transparency-fixer-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? `Fix Transparency (${files.length} files)` : "Fix Transparency"}
</button>
)}
</div>
);
}
+6
View File
@@ -298,6 +298,11 @@ const RestorePhotoSettings = lazy(() =>
default: m.RestorePhotoSettings,
})),
);
const TransparencyFixerSettings = lazy(() =>
import("@/components/tools/transparency-fixer-settings").then((m) => ({
default: m.TransparencyFixerSettings,
})),
);
// ── Color tool wrapper ─────────────────────────────────────────────
// Color tools share a single component but differ by toolId.
@@ -451,6 +456,7 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
],
["red-eye-removal", { displayMode: "before-after", Settings: RedEyeRemovalSettings }],
["restore-photo", { displayMode: "before-after", Settings: RestorePhotoSettings }],
["transparency-fixer", { displayMode: "before-after", Settings: TransparencyFixerSettings }],
]);
export function getToolRegistryEntry(toolId: string): ToolRegistryEntry | undefined {
+1 -1
View File
@@ -292,7 +292,7 @@ export function ToolPage() {
if (isAiTool && !toolInstalled && featureBundle) {
return (
<AppLayout>
<FeatureInstallPrompt bundle={featureBundle} isAdmin={isAdmin} />
<FeatureInstallPrompt bundle={featureBundle} isAdmin={isAdmin} toolName={tool?.name} />
</AppLayout>
);
}
+48
View File
@@ -186,6 +186,7 @@ REMBG_MODELS = [
"birefnet-portrait",
"birefnet-general",
"birefnet-matting",
"birefnet-hr-matting",
]
# PaddleOCR PP-OCRv5 HuggingFace model repos to pre-download.
@@ -235,12 +236,59 @@ def _register_birefnet_matting():
sessions_class.append(BiRefNetMattingSession)
def _register_birefnet_hr_matting():
"""Register BiRefNet HR-matting ONNX session for 2048x2048 high-res matting."""
import os
import numpy as np
import pooch
from PIL import Image
from rembg.sessions import sessions_class
from rembg.sessions.birefnet_general import BiRefNetSessionGeneral
class BiRefNetHRMattingSession(BiRefNetSessionGeneral):
@classmethod
def download_models(cls, *args, **kwargs):
fname = f"{cls.name(*args, **kwargs)}.onnx"
pooch.retrieve(
"https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet_HR-matting-epoch_135.onnx",
None,
fname=fname,
path=cls.u2net_home(*args, **kwargs),
progressbar=True,
)
return os.path.join(cls.u2net_home(*args, **kwargs), fname)
@classmethod
def name(cls, *args, **kwargs):
return "birefnet-hr-matting"
def predict(self, img, *args, **kwargs):
ort_outs = self.inner_session.run(
None,
self.normalize(
img, (0.485, 0.456, 0.406), (0.229, 0.224, 0.225), (2048, 2048)
),
)
pred = ort_outs[0][:, 0, :, :]
ma = np.max(pred)
mi = np.min(pred)
denom = ma - mi
pred = (pred - mi) / denom if denom > 0 else pred * 0
pred = np.squeeze(pred)
mask = Image.fromarray((pred * 255).astype("uint8"), mode="L")
mask = mask.resize(img.size, Image.LANCZOS)
return [mask]
sessions_class.append(BiRefNetHRMattingSession)
def download_rembg_models():
"""Download all rembg ONNX models."""
print("=== Downloading rembg models ===")
from rembg import new_session
_register_birefnet_matting()
_register_birefnet_hr_matting()
for model in REMBG_MODELS:
print(f" Downloading {model}...")
+7 -2
View File
@@ -7,7 +7,7 @@
"background-removal": {
"name": "Background Removal",
"description": "Remove image backgrounds with AI",
"estimatedSize": "3-4 GB",
"estimatedSize": "4-5 GB",
"packages": {
"common": ["rembg==2.0.62"],
"amd64": ["onnxruntime-gpu==1.20.1", "mediapipe==0.10.21"],
@@ -42,9 +42,14 @@
"id": "rembg-birefnet-matting",
"downloadFn": "rembg_session",
"args": ["birefnet-matting"]
},
{
"id": "rembg-birefnet-hr-matting",
"downloadFn": "rembg_session",
"args": ["birefnet-hr-matting"]
}
],
"enablesTools": ["remove-background", "passport-photo"]
"enablesTools": ["remove-background", "passport-photo", "transparency-fixer"]
},
"face-detection": {
"name": "Face Detection",
+58
View File
@@ -316,6 +316,63 @@ def _register_birefnet_matting() -> None:
sessions_class.append(BiRefNetMattingSession)
_hr_matting_registered = False
def _register_birefnet_hr_matting() -> None:
"""Register the custom BiRefNet HR-matting ONNX session for 2048x2048 high-res matting.
Like _register_birefnet_matting(), this model is not built into rembg and
must be registered before calling new_session("birefnet-hr-matting").
"""
global _hr_matting_registered
if _hr_matting_registered:
return
_hr_matting_registered = True
import numpy as np
import pooch
from PIL import Image
from rembg.sessions import sessions_class
from rembg.sessions.birefnet_general import BiRefNetSessionGeneral
class BiRefNetHRMattingSession(BiRefNetSessionGeneral):
@classmethod
def download_models(cls, *args, **kwargs):
fname = f"{cls.name(*args, **kwargs)}.onnx"
pooch.retrieve(
"https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet_HR-matting-epoch_135.onnx",
None,
fname=fname,
path=cls.u2net_home(*args, **kwargs),
progressbar=True,
)
return os.path.join(cls.u2net_home(*args, **kwargs), fname)
@classmethod
def name(cls, *args, **kwargs):
return "birefnet-hr-matting"
def predict(self, img, *args, **kwargs):
ort_outs = self.inner_session.run(
None,
self.normalize(
img, (0.485, 0.456, 0.406), (0.229, 0.224, 0.225), (2048, 2048)
),
)
pred = ort_outs[0][:, 0, :, :]
ma = np.max(pred)
mi = np.min(pred)
denom = ma - mi
pred = (pred - mi) / denom if denom > 0 else pred * 0
pred = np.squeeze(pred)
mask = Image.fromarray((pred * 255).astype("uint8"), mode="L")
mask = mask.resize(img.size, Image.LANCZOS)
return [mask]
sessions_class.append(BiRefNetHRMattingSession)
def download_rembg_session(model: dict, models_dir: str) -> None:
"""Download a rembg model by initializing a session."""
args = model.get("args", [])
@@ -331,6 +388,7 @@ def download_rembg_session(model: dict, models_dir: str) -> None:
from rembg import new_session
_register_birefnet_matting()
_register_birefnet_hr_matting()
new_session(model_name)
+53
View File
@@ -17,6 +17,7 @@ ALLOWED_MODELS = {
"birefnet-portrait",
"birefnet-general",
"birefnet-matting",
"birefnet-hr-matting",
}
_matting_registered = False
@@ -51,6 +52,57 @@ def _register_matting_session(sessions_class):
sessions_class.append(BiRefNetMattingSession)
_hr_matting_registered = False
def _register_hr_matting_session(sessions_class):
"""Register the BiRefNet HR-matting ONNX session for 2048x2048 high-res matting."""
global _hr_matting_registered
if _hr_matting_registered:
return
_hr_matting_registered = True
import os
import numpy as np
import pooch
from PIL import Image
from rembg.sessions.birefnet_general import BiRefNetSessionGeneral
class BiRefNetHRMattingSession(BiRefNetSessionGeneral):
@classmethod
def download_models(cls, *args, **kwargs):
fname = f"{cls.name(*args, **kwargs)}.onnx"
pooch.retrieve(
"https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet_HR-matting-epoch_135.onnx",
None,
fname=fname,
path=cls.u2net_home(*args, **kwargs),
progressbar=True,
)
return os.path.join(cls.u2net_home(*args, **kwargs), fname)
@classmethod
def name(cls, *args, **kwargs):
return "birefnet-hr-matting"
def predict(self, img, *args, **kwargs):
ort_outs = self.inner_session.run(
None,
self.normalize(
img, (0.485, 0.456, 0.406), (0.229, 0.224, 0.225), (2048, 2048)
),
)
pred = ort_outs[0][:, 0, :, :]
ma = np.max(pred)
mi = np.min(pred)
denom = ma - mi
pred = (pred - mi) / denom if denom > 0 else pred * 0
pred = np.squeeze(pred)
mask = Image.fromarray((pred * 255).astype("uint8"), mode="L")
mask = mask.resize(img.size, Image.LANCZOS)
return [mask]
sessions_class.append(BiRefNetHRMattingSession)
def main():
input_path = sys.argv[1]
@@ -73,6 +125,7 @@ def main():
# Register BiRefNet-matting (Ultra quality) if not already present
_register_matting_session(sessions_class)
_register_hr_matting_session(sessions_class)
emit_progress(10, "Loading model")
+9
View File
@@ -241,6 +241,14 @@ export const TOOLS: Tool[] = [
icon: "Scaling",
route: "/content-aware-resize",
},
{
id: "transparency-fixer",
name: "PNG Transparency Fixer",
description: "Fix fake transparent PNGs in one click",
category: "ai",
icon: "Wand2",
route: "/transparency-fixer",
},
// Watermark & Overlay
{
id: "watermark-text",
@@ -1207,4 +1215,5 @@ export const PYTHON_SIDECAR_TOOLS = [
"red-eye-removal",
"restore-photo",
"passport-photo",
"transparency-fixer",
] as const;
+2 -2
View File
@@ -25,8 +25,8 @@ export const FEATURE_BUNDLES: Record<string, FeatureBundleInfo> = {
id: "background-removal",
name: "Background Removal",
description: "Remove image backgrounds with AI",
estimatedSize: "3-4 GB",
enablesTools: ["remove-background", "passport-photo"],
estimatedSize: "4-5 GB",
enablesTools: ["remove-background", "passport-photo", "transparency-fixer"],
},
"face-detection": {
id: "face-detection",
+5 -1
View File
@@ -124,6 +124,10 @@ export const en = {
description:
"Create government-compliant passport, visa, and ID photos with auto face detection",
},
"transparency-fixer": {
name: "PNG Transparency Fixer",
description: "Fix fake transparent PNGs in one click",
},
"watermark-text": { name: "Text Watermark", description: "Add text watermark overlay" },
"watermark-image": { name: "Image Watermark", description: "Overlay a logo as watermark" },
"text-overlay": { name: "Text Overlay", description: "Add styled text to images" },
@@ -203,7 +207,7 @@ export const en = {
regenerateKey: "Regenerate",
copyKey: "Copy Key",
aboutDescription:
"SnapOtter is a self-hosted, privacy-first image processing suite with 47 tools.",
"SnapOtter is a self-hosted, privacy-first image processing suite with 48 tools.",
aboutLinks: "Links",
github: "GitHub",
documentation: "Documentation",
-44
View File
@@ -1,44 +0,0 @@
import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
const authFile = path.join(__dirname, ".playwright", ".auth", "analytics-user.json");
// Point raw-fetch tests (api.spec, security.spec, people.spec, rbac.spec) at
// the Docker container instead of the dev-server default (port 13490).
// Start the container with: SKIP_MUST_CHANGE_PASSWORD=true docker compose -f docker/docker-compose.yml up -d
const containerUrl = process.env.BASE_URL || "http://localhost:1349";
process.env.API_URL ??= containerUrl;
export default defineConfig({
testDir: "./tests/e2e-docker",
timeout: 600_000,
expect: {
timeout: 60_000,
},
fullyParallel: false,
retries: 0,
workers: 1,
reporter: [["html", { open: "never" }], ["list"]],
use: {
baseURL: containerUrl,
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
projects: [
{
name: "setup",
testMatch: /auth\.setup\.ts/,
},
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: authFile,
},
dependencies: ["setup"],
},
],
// No webServer — tests run against the Docker container at localhost:1349
});
export { authFile };
+2 -2
View File
@@ -13,7 +13,7 @@ test.describe("Docs Homepage", () => {
await expect(page.getByText("SnapOtter").first()).toBeVisible();
await expect(page.getByText("A Self Hosted Image Manipulator")).toBeVisible();
await expect(
page.getByText("47 tools. Local AI. No cloud. Your images never leave your home."),
page.getByText("48 tools. Local AI. No cloud. Your images never leave your home."),
).toBeVisible();
});
@@ -29,7 +29,7 @@ test.describe("Docs Homepage", () => {
test("features section renders all 6 feature cards", async ({ page }) => {
const features = [
"47 Image Tools",
"48 Image Tools",
"Local AI",
"Pipelines",
"REST API",
+11 -7
View File
@@ -37,16 +37,20 @@ test.describe("Landing Homepage", () => {
test("how-it-works section renders Docker command", async ({ page }) => {
await expect(page.getByText("Get started in seconds")).toBeVisible();
await expect(
page.getByText("docker run -d --name SnapOtter", { exact: false }),
).toBeVisible();
await expect(page.getByText("docker run -d --name SnapOtter", { exact: false })).toBeVisible();
});
test("why-choose section renders all 9 benefit cards", async ({ page }) => {
await expect(page.getByText("Built different. On purpose.")).toBeVisible();
const cards = [
"No Signup", "No Uploads", "Forever Free", "No Limits",
"Batch Processing", "Lightning Fast", "Open Source", "REST API",
"No Signup",
"No Uploads",
"Forever Free",
"No Limits",
"Batch Processing",
"Lightning Fast",
"Open Source",
"REST API",
"Pipeline Automation",
];
for (const card of cards) {
@@ -55,9 +59,9 @@ test.describe("Landing Homepage", () => {
});
test("bento grid renders with search and tool count", async ({ page }) => {
await expect(page.getByText("47 tools. Zero cloud dependency.")).toBeVisible();
await expect(page.getByText("48 tools. Zero cloud dependency.")).toBeVisible();
await expect(page.getByPlaceholder("Search tools...")).toBeVisible();
await expect(page.getByText(/Showing 47 of 47 tools/)).toBeVisible();
await expect(page.getByText(/Showing 48 of 48 tools/)).toBeVisible();
});
test("enterprise section renders feature cards", async ({ page }) => {
+10 -18
View File
@@ -9,28 +9,28 @@ test.describe("BentoGrid Interactions", () => {
const input = page.getByPlaceholder("Search tools...");
await input.fill("resize");
await expect(page.getByText("Resize", { exact: true }).first()).toBeVisible();
await expect(page.getByText(/Showing \d+ of 47 tools/)).toBeVisible();
await expect(page.getByText(/Showing \d+ of 48 tools/)).toBeVisible();
});
test("category pill filters tools", async ({ page }) => {
await page.getByText(/AI Tools/).click();
await expect(page.getByText(/Showing 14 of 47 tools/)).toBeVisible();
await expect(page.getByText(/Showing 15 of 48 tools/)).toBeVisible();
await expect(page.getByText("Remove Background")).toBeVisible();
});
test("clicking All resets category filter", async ({ page }) => {
await page.getByText(/AI Tools/).click();
await expect(page.getByText(/Showing 14 of 47 tools/)).toBeVisible();
await expect(page.getByText(/Showing 15 of 48 tools/)).toBeVisible();
await page.getByText(/All \(47\)/).click();
await expect(page.getByText(/Showing 47 of 47 tools/)).toBeVisible();
await page.getByText(/All \(48\)/).click();
await expect(page.getByText(/Showing 48 of 48 tools/)).toBeVisible();
});
test("search with no results shows empty state", async ({ page }) => {
const input = page.getByPlaceholder("Search tools...");
await input.fill("xyznonexistent");
await expect(page.getByText("No tools found. Try a different search.")).toBeVisible();
await expect(page.getByText(/Showing 0 of 47 tools/)).toBeVisible();
await expect(page.getByText(/Showing 0 of 48 tools/)).toBeVisible();
});
test("combined search and category filter works", async ({ page }) => {
@@ -68,29 +68,21 @@ test.describe("FAQ Page Accordion", () => {
test("clicking a question expands the answer", async ({ page }) => {
await page.getByText("Are my files safe and private?").click();
await expect(
page.getByText(/All processing happens on your own server/),
).toBeVisible();
await expect(page.getByText(/All processing happens on your own server/)).toBeVisible();
});
test("clicking again collapses the answer", async ({ page }) => {
const question = page.getByText("Are my files safe and private?");
await question.click();
await expect(
page.getByText(/All processing happens on your own server/),
).toBeVisible();
await expect(page.getByText(/All processing happens on your own server/)).toBeVisible();
await question.click();
await expect(
page.getByText(/All processing happens on your own server/),
).not.toBeVisible();
await expect(page.getByText(/All processing happens on your own server/)).not.toBeVisible();
});
test("multiple FAQs can be open simultaneously", async ({ page }) => {
await page.getByText("Are my files safe and private?").click();
await page.getByText("Is SnapOtter really free?").click();
await expect(
page.getByText(/All processing happens on your own server/),
).toBeVisible();
await expect(page.getByText(/All processing happens on your own server/)).toBeVisible();
await expect(page.getByText(/open source under AGPL-3.0/)).toBeVisible();
});
});
+1 -1
View File
@@ -289,7 +289,7 @@ test.describe("GUI Settings - Tools Tab (deep)", () => {
// Each tool row has an enable/disable toggle (w-11 h-6 rounded-full)
const toolToggles = page.locator("button.w-11.h-6");
const count = await toolToggles.count();
// Should have many tools (SnapOtter has 47)
// Should have many tools (SnapOtter has 48)
expect(count).toBeGreaterThan(10);
});
+127
View File
@@ -831,4 +831,131 @@ test.describe("GUI AI Tools", () => {
).toBeVisible({ timeout: 10_000 });
});
});
// ========================================================================
// TRANSPARENCY FIXER
// ========================================================================
test.describe("PNG Transparency Fixer", () => {
test("renders tool page with dropzone", async ({ loggedInPage: page }) => {
await page.goto("/transparency-fixer");
await expect(page.getByText("PNG Transparency Fixer").first()).toBeVisible();
await expect(page.getByText("Upload from computer")).toBeVisible();
});
test("shows description text and submit button after upload", async ({
loggedInPage: page,
}) => {
await page.goto("/transparency-fixer");
await uploadTestImage(page);
await expect(page.getByText("Upload a PNG with a fake transparent background")).toBeVisible();
await expect(page.getByTestId("transparency-fixer-submit")).toBeVisible();
await expect(page.getByTestId("transparency-fixer-submit")).toHaveText(/Fix Transparency/);
});
test("advanced section is collapsed by default", async ({ loggedInPage: page }) => {
await page.goto("/transparency-fixer");
await uploadTestImage(page);
// Advanced toggle should be visible
await expect(page.getByText("Advanced")).toBeVisible();
// Defringe slider and Output Format should NOT be visible
await expect(page.getByText("Defringe")).not.toBeVisible();
await expect(page.getByText("Output Format")).not.toBeVisible();
});
test("advanced section toggles open with defringe and output format", async ({
loggedInPage: page,
}) => {
await page.goto("/transparency-fixer");
await uploadTestImage(page);
// Open advanced section
await page.getByText("Advanced").click();
// Defringe slider should be visible with default value 30
await expect(page.getByText("Defringe")).toBeVisible();
await expect(page.getByText("30")).toBeVisible();
// Output format dropdown should be visible
await expect(page.getByText("Output Format")).toBeVisible();
const formatSelect = page.locator("select");
await expect(formatSelect.first()).toBeVisible();
await expect(formatSelect.first()).toHaveValue("png");
});
test("defringe slider is interactive", async ({ loggedInPage: page }) => {
await page.goto("/transparency-fixer");
await uploadTestImage(page);
// Open advanced section
await page.getByText("Advanced").click();
// The slider should exist and be interactive
const slider = page.locator("input[type='range']").first();
await expect(slider).toBeVisible();
await expect(slider).toHaveAttribute("min", "0");
await expect(slider).toHaveAttribute("max", "100");
});
test("output format dropdown allows switching to WebP", async ({ loggedInPage: page }) => {
await page.goto("/transparency-fixer");
await uploadTestImage(page);
// Open advanced section
await page.getByText("Advanced").click();
const formatSelect = page.locator("select").first();
await formatSelect.selectOption("webp");
await expect(formatSelect).toHaveValue("webp");
// Switch back to PNG
await formatSelect.selectOption("png");
await expect(formatSelect).toHaveValue("png");
});
test("submit button disabled without file, enabled with file", async ({
loggedInPage: page,
}) => {
await page.goto("/transparency-fixer");
const submitBtn = page.getByTestId("transparency-fixer-submit");
await expect(submitBtn).toBeDisabled();
await uploadTestImage(page);
await expect(submitBtn).toBeEnabled();
await expect(submitBtn).toHaveText(/Fix Transparency/);
});
test("shows multi-file text for batch upload", async ({ loggedInPage: page }) => {
await page.goto("/transparency-fixer");
// Upload multiple files
const fileChooserPromise = page.waitForEvent("filechooser");
await page.locator("[class*='border-dashed']").first().click();
const fileChooser = await fileChooserPromise;
const path = await import("node:path");
const fixtures = path.join(process.cwd(), "tests", "fixtures");
await fileChooser.setFiles([
path.join(fixtures, "test-200x150.png"),
path.join(fixtures, "test-100x100.jpg"),
]);
await page.waitForTimeout(500);
await expect(page.getByTestId("transparency-fixer-submit")).toHaveText(
/Fix Transparency \(2 files\)/,
);
});
test.skip("shows feature-not-installed warning when AI sidecar is down", async ({
loggedInPage: page,
}) => {
await page.goto("/transparency-fixer");
await uploadTestImage(page);
await expect(
page.locator("text=/not installed|Feature.*not|is not available/i").first(),
).toBeVisible({ timeout: 10_000 });
});
});
});
+2
View File
@@ -41,6 +41,7 @@ const TOOLS_WITH_DROPZONE = [
{ id: "vectorize", name: "Image to SVG" },
{ id: "gif-tools", name: "GIF" },
{ id: "noise-removal", name: "Noise Removal" },
{ id: "transparency-fixer", name: "PNG Transparency Fixer" },
];
const TOOLS_WITHOUT_DROPZONE = [{ id: "qr-generate", name: "QR Code" }];
@@ -53,6 +54,7 @@ const AI_TOOL_IDS = new Set([
"blur-faces",
"smart-crop",
"noise-removal",
"transparency-fixer",
]);
test.describe("All tool pages render", () => {
+180
View File
@@ -0,0 +1,180 @@
import path from "node:path";
import { expect, isAiSidecarRunning, test } from "./helpers";
// ---------------------------------------------------------------------------
// PNG Transparency Fixer tool - e2e tests.
// Covers UI rendering, advanced settings, processing flow, and result display.
// ---------------------------------------------------------------------------
function fixturePath(name: string): string {
return path.join(process.cwd(), "tests", "fixtures", name);
}
async function uploadFile(page: import("@playwright/test").Page, filePath: string) {
const fileChooserPromise = page.waitForEvent("filechooser");
const dropzone = page.locator("[class*='border-dashed']").first();
await dropzone.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(filePath);
await page.waitForTimeout(500);
}
async function fixTransparencyAndWait(page: import("@playwright/test").Page) {
await page.getByTestId("transparency-fixer-submit").click();
// Wait for the before/after slider or result image to appear (AI processing can be slow)
await expect(page.locator("section[aria-label='Image area'] img").first()).toBeVisible({
timeout: 300_000,
});
// Ensure the submit button re-appears (processing finished, ProgressCard gone)
await expect(page.getByTestId("transparency-fixer-submit")).toBeVisible({ timeout: 10_000 });
}
test.describe("PNG Transparency Fixer tool", () => {
async function skipIfFeatureNotInstalled(page: import("@playwright/test").Page) {
await page.goto("/transparency-fixer");
try {
await page
.getByTestId("transparency-fixer-submit")
.waitFor({ state: "visible", timeout: 15_000 });
} catch {
test.skip(true, "background-removal feature bundle not installed");
}
if (!(await isAiSidecarRunning(page))) {
test.skip(true, "AI sidecar not running");
}
}
test("page loads with correct title and description", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
// Tool title shown in the settings panel header
await expect(page.getByText("PNG Transparency Fixer")).toBeVisible();
// Description text from the settings component
await expect(page.getByText("Upload a PNG with a fake transparent background")).toBeVisible();
// Submit button disabled with no file
await expect(page.getByTestId("transparency-fixer-submit")).toBeDisabled();
});
test("submit button disabled without file", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
await expect(page.getByTestId("transparency-fixer-submit")).toBeDisabled();
});
test("submit button enables after file upload", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
await uploadFile(page, fixturePath("test-fake-transparency.png"));
await expect(page.getByTestId("transparency-fixer-submit")).toBeEnabled();
});
test("advanced section is collapsed by default", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
// Advanced toggle button should be visible
await expect(page.getByText("Advanced")).toBeVisible();
// Defringe slider and output format should NOT be visible yet
await expect(page.getByText("Defringe")).not.toBeVisible();
await expect(page.getByText("Output Format")).not.toBeVisible();
});
test("advanced section toggles open and shows defringe slider and output format", async ({
loggedInPage: page,
}) => {
await skipIfFeatureNotInstalled(page);
// Open advanced section
await page.getByText("Advanced").click();
// Defringe slider visible
await expect(page.getByText("Defringe")).toBeVisible();
const defringeSlider = page.locator("input[type='range'][min='0'][max='100']");
await expect(defringeSlider).toBeVisible();
// Output format dropdown visible
await expect(page.getByText("Output Format")).toBeVisible();
const formatSelect = page.locator("select");
await expect(formatSelect).toBeVisible();
// Verify default values
await expect(page.getByText("30")).toBeVisible(); // default defringe value
await expect(formatSelect).toHaveValue("png");
});
test("defringe slider is interactive", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
// Open advanced section
await page.getByText("Advanced").click();
const defringeSlider = page.locator("input[type='range'][min='0'][max='100']");
await expect(defringeSlider).toBeVisible();
// Adjust slider value
await defringeSlider.fill("75");
await expect(page.getByText("75")).toBeVisible();
await defringeSlider.fill("0");
await expect(page.getByText("0")).toBeVisible();
});
test("output format dropdown switches to WebP", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
// Open advanced section
await page.getByText("Advanced").click();
const formatSelect = page.locator("select");
await expect(formatSelect).toHaveValue("png");
// Switch to WebP
await formatSelect.selectOption("webp");
await expect(formatSelect).toHaveValue("webp");
// Switch back to PNG
await formatSelect.selectOption("png");
await expect(formatSelect).toHaveValue("png");
});
test("progress indicator appears during processing", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
await uploadFile(page, fixturePath("test-fake-transparency.png"));
// Click submit
await page.getByTestId("transparency-fixer-submit").click();
// ProgressCard should appear (contains an animated spinner)
await expect(page.locator("[class*='animate-spin']")).toBeVisible({ timeout: 5_000 });
// Wait for processing to complete
await expect(page.locator("section[aria-label='Image area'] img").first()).toBeVisible({
timeout: 300_000,
});
});
test("PNG - fixes transparency and shows before/after result", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
await uploadFile(page, fixturePath("test-fake-transparency.png"));
await fixTransparencyAndWait(page);
// Before/after slider or result image should be visible
await expect(page.locator("section[aria-label='Image area'] img").first()).toBeVisible();
// No error shown
await expect(page.getByText("Transparency fix failed")).not.toBeVisible();
await expect(page.getByText("Network error")).not.toBeVisible();
});
test("result download is available after processing", async ({ loggedInPage: page }) => {
await skipIfFeatureNotInstalled(page);
await uploadFile(page, fixturePath("test-fake-transparency.png"));
await fixTransparencyAndWait(page);
// ReviewPanel should show with Download button in the sidebar
const downloadButton = page.getByRole("button", { name: /download/i });
await expect(downloadButton).toBeVisible({ timeout: 10_000 });
});
});
@@ -0,0 +1,262 @@
/**
* Integration tests for the transparency-fixer tool (/api/v1/tools/transparency-fixer).
*
* This tool requires the Python sidecar (rembg with BiRefNet HR-matting model).
* Tests accept 200, 202 (sidecar running), and 501 (not installed) for
* processing paths while fully testing validation paths that don't depend on
* the sidecar.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const FAKE_TRANSPARENCY = readFileSync(join(FIXTURES, "test-fake-transparency.png"));
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp"));
const SVG = readFileSync(join(FIXTURES, "test-100x100.svg"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
const TOOL_URL = "/api/v1/tools/transparency-fixer";
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/** Helper: build multipart payload and POST to the transparency-fixer endpoint. */
async function postTransparencyFixer(
fileBuffer: Buffer,
filename: string,
settings?: Record<string, unknown>,
) {
const fields: Array<{
name: string;
filename?: string;
contentType?: string;
content: Buffer | string;
}> = [{ name: "file", filename, contentType: "application/octet-stream", content: fileBuffer }];
if (settings !== undefined) {
fields.push({ name: "settings", content: JSON.stringify(settings) });
}
const { body, contentType } = createMultipartPayload(fields);
return app.inject({
method: "POST",
url: TOOL_URL,
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
}
/** Helper: POST with raw settings string (for invalid JSON tests). */
async function postWithRawSettings(fileBuffer: Buffer, filename: string, rawSettings: string) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content: fileBuffer },
{ name: "settings", content: rawSettings },
]);
return app.inject({
method: "POST",
url: TOOL_URL,
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
}
// ═══════════════════════════════════════════════════════════════════════════
// Happy path
// ═══════════════════════════════════════════════════════════════════════════
describe("PNG Transparency Fixer - Happy path", () => {
it("processes fake-transparency PNG with default settings", async () => {
const res = await postTransparencyFixer(FAKE_TRANSPARENCY, "test-fake-transparency.png", {});
expect([200, 202, 501]).toContain(res.statusCode);
if (res.statusCode === 202) {
const result = JSON.parse(res.body);
expect(result.jobId).toBeDefined();
expect(result.async).toBe(true);
}
if (res.statusCode === 501) {
const result = JSON.parse(res.body);
expect(result.code).toBe("FEATURE_NOT_INSTALLED");
}
}, 120_000);
it("processes standard PNG with default settings", async () => {
const res = await postTransparencyFixer(PNG, "test-200x150.png", {});
expect([200, 202, 501]).toContain(res.statusCode);
}, 120_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// Advanced settings
// ═══════════════════════════════════════════════════════════════════════════
describe("PNG Transparency Fixer - Advanced settings", () => {
it("accepts defringe at 0", async () => {
const res = await postTransparencyFixer(PNG, "test.png", { defringe: 0 });
expect([200, 202, 501]).toContain(res.statusCode);
}, 120_000);
it("accepts defringe at 50", async () => {
const res = await postTransparencyFixer(PNG, "test.png", { defringe: 50 });
expect([200, 202, 501]).toContain(res.statusCode);
}, 120_000);
it("accepts defringe at 100", async () => {
const res = await postTransparencyFixer(PNG, "test.png", { defringe: 100 });
expect([200, 202, 501]).toContain(res.statusCode);
}, 120_000);
it("accepts output format webp", async () => {
const res = await postTransparencyFixer(PNG, "test.png", { outputFormat: "webp" });
expect([200, 202, 501]).toContain(res.statusCode);
}, 120_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// Input format coverage
// ═══════════════════════════════════════════════════════════════════════════
describe("PNG Transparency Fixer - Input format coverage", () => {
it("accepts JPEG input", async () => {
const res = await postTransparencyFixer(JPG, "photo.jpg", {});
expect([200, 202, 501]).toContain(res.statusCode);
}, 120_000);
it("accepts PNG input", async () => {
const res = await postTransparencyFixer(PNG, "image.png", {});
expect([200, 202, 501]).toContain(res.statusCode);
}, 120_000);
it("accepts WebP input", async () => {
const res = await postTransparencyFixer(WEBP, "image.webp", {});
expect([200, 202, 501]).toContain(res.statusCode);
}, 120_000);
it("accepts SVG input", async () => {
const res = await postTransparencyFixer(SVG, "image.svg", {});
expect([200, 202, 501]).toContain(res.statusCode);
}, 120_000);
it("accepts HEIC input", async () => {
const res = await postTransparencyFixer(HEIC, "photo.heic", {});
expect([200, 202, 501]).toContain(res.statusCode);
}, 120_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// Error handling
// ═══════════════════════════════════════════════════════════════════════════
describe("PNG Transparency Fixer - Error handling", () => {
it("rejects requests without a file", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: TOOL_URL,
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(/no image/i);
}
});
it("rejects invalid settings JSON", async () => {
const res = await postWithRawSettings(PNG, "test.png", "not valid json{{{");
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/json/i);
}
});
it("rejects defringe out of range (negative)", async () => {
const res = await postTransparencyFixer(PNG, "test.png", { defringe: -5 });
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 defringe out of range (>100)", async () => {
const res = await postTransparencyFixer(PNG, "test.png", { defringe: 200 });
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 res = await postTransparencyFixer(PNG, "test.png", { outputFormat: "bmp" });
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Edge cases
// ═══════════════════════════════════════════════════════════════════════════
describe("PNG Transparency Fixer - Edge cases", () => {
it("handles 1x1 pixel image", async () => {
const res = await postTransparencyFixer(TINY, "tiny.png", {});
expect([200, 202, 422, 501]).toContain(res.statusCode);
}, 120_000);
it("handles already-transparent PNG", async () => {
// Create a 50x50 RGBA image with 50% alpha
const semiTransparent = await sharp({
create: {
width: 50,
height: 50,
channels: 4,
background: { r: 128, g: 128, b: 128, alpha: 0.5 },
},
})
.png()
.toBuffer();
const res = await postTransparencyFixer(semiTransparent, "semi-transparent.png", {});
expect([200, 202, 501]).toContain(res.statusCode);
}, 120_000);
});
+9 -9
View File
@@ -25,7 +25,7 @@ function getCountText(): string {
describe("BentoGrid", () => {
it("renders the section heading", () => {
render(<BentoGrid />);
expect(screen.getByText("47 tools. Zero cloud dependency.")).toBeDefined();
expect(screen.getByText("48 tools. Zero cloud dependency.")).toBeDefined();
});
it("renders the search input", () => {
@@ -36,12 +36,12 @@ describe("BentoGrid", () => {
it("shows all tools by default", () => {
render(<BentoGrid />);
const text = getCountText();
expect(text).toMatch(/Showing 47 of 47 tools/);
expect(text).toMatch(/Showing 48 of 48 tools/);
});
it("renders category filter pills including All", () => {
render(<BentoGrid />);
expect(screen.getByText((_, el) => el?.textContent === "All (47)")).toBeDefined();
expect(screen.getByText((_, el) => el?.textContent === "All (48)")).toBeDefined();
expect(screen.getByText(/Essentials/)).toBeDefined();
expect(screen.getByText(/AI Tools/)).toBeDefined();
expect(screen.getByText(/Optimization/)).toBeDefined();
@@ -53,7 +53,7 @@ describe("BentoGrid", () => {
fireEvent.change(input, { target: { value: "resize" } });
expect(screen.getByText("Resize")).toBeDefined();
const text = getCountText();
expect(text).toMatch(/Showing \d+ of 47 tools/);
expect(text).toMatch(/Showing \d+ of 48 tools/);
expect(screen.queryByText("OCR / Text Extraction")).toBeNull();
});
@@ -62,7 +62,7 @@ describe("BentoGrid", () => {
const aiButton = screen.getByText(/AI Tools/);
fireEvent.click(aiButton);
const text = getCountText();
expect(text).toMatch(/Showing 14 of 47 tools/);
expect(text).toMatch(/Showing 15 of 48 tools/);
expect(screen.getByText("Remove Background")).toBeDefined();
expect(screen.queryByText("Resize")).toBeNull();
});
@@ -73,7 +73,7 @@ describe("BentoGrid", () => {
fireEvent.change(input, { target: { value: "xyznonexistent" } });
expect(screen.getByText("No tools found. Try a different search.")).toBeDefined();
const text = getCountText();
expect(text).toMatch(/Showing 0 of 47 tools/);
expect(text).toMatch(/Showing 0 of 48 tools/);
});
it("combines search and category filter", () => {
@@ -89,9 +89,9 @@ describe("BentoGrid", () => {
it("clicking All resets category filter", () => {
render(<BentoGrid />);
fireEvent.click(screen.getByText(/AI Tools/));
expect(getCountText()).toMatch(/Showing 14 of 47 tools/);
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (47)"));
expect(getCountText()).toMatch(/Showing 47 of 47 tools/);
expect(getCountText()).toMatch(/Showing 15 of 48 tools/);
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (48)"));
expect(getCountText()).toMatch(/Showing 48 of 48 tools/);
});
it("renders each tool with name and description", () => {
+2 -2
View File
@@ -159,9 +159,9 @@ describe("Pricing", () => {
it("renders free plan features", () => {
render(<Pricing />);
expect(screen.getByText("All 47 image processing tools")).toBeDefined();
expect(screen.getByText("All 48 image processing tools")).toBeDefined();
expect(screen.getByText("Unlimited usage, no hidden caps")).toBeDefined();
expect(screen.getByText("14 local AI models included")).toBeDefined();
expect(screen.getByText("15 local AI models included")).toBeDefined();
expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined();
});