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
+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>
);
}