mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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"}'
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user