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:
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## Key Features
|
## 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
|
- **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
|
- **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`
|
- **REST API** - Every tool available via API with API key auth. Interactive docs at `/api/docs`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ info:
|
|||||||
title: SnapOtter API
|
title: SnapOtter API
|
||||||
version: 1.15.9
|
version: 1.15.9
|
||||||
description: |
|
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
|
## Authentication
|
||||||
|
|
||||||
@@ -3225,6 +3225,81 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/UnauthorizedError"
|
$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:
|
/api/v1/tools/upscale:
|
||||||
post:
|
post:
|
||||||
tags: [Tools]
|
tags: [Tools]
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
|
|||||||
lines.push(`# ${spec.info.title}`);
|
lines.push(`# ${spec.info.title}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
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("");
|
||||||
lines.push("## Docs");
|
lines.push("## Docs");
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { registerStitch } from "./stitch.js";
|
|||||||
import { registerStripMetadata } from "./strip-metadata.js";
|
import { registerStripMetadata } from "./strip-metadata.js";
|
||||||
import { registerSvgToRaster } from "./svg-to-raster.js";
|
import { registerSvgToRaster } from "./svg-to-raster.js";
|
||||||
import { registerTextOverlay } from "./text-overlay.js";
|
import { registerTextOverlay } from "./text-overlay.js";
|
||||||
|
import { registerTransparencyFixer } from "./transparency-fixer.js";
|
||||||
import { registerUpscale } from "./upscale.js";
|
import { registerUpscale } from "./upscale.js";
|
||||||
import { registerVectorize } from "./vectorize.js";
|
import { registerVectorize } from "./vectorize.js";
|
||||||
import { registerWatermarkImage } from "./watermark-image.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: "passport-photo", register: registerPassportPhoto },
|
||||||
{ id: "red-eye-removal", register: registerRedEyeRemoval },
|
{ id: "red-eye-removal", register: registerRedEyeRemoval },
|
||||||
{ id: "restore-photo", register: registerRestorePhoto },
|
{ id: "restore-photo", register: registerRestorePhoto },
|
||||||
|
{ id: "transparency-fixer", register: registerTransparencyFixer },
|
||||||
];
|
];
|
||||||
|
|
||||||
let skipped = 0;
|
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({
|
export default defineConfig({
|
||||||
title: "SnapOtter",
|
title: "SnapOtter",
|
||||||
description:
|
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: "/",
|
base: "/",
|
||||||
appearance: { initialValue: "light" },
|
appearance: { initialValue: "light" },
|
||||||
srcDir: ".",
|
srcDir: ".",
|
||||||
@@ -48,7 +48,7 @@ export default defineConfig({
|
|||||||
`,
|
`,
|
||||||
customTemplateVariables: {
|
customTemplateVariables: {
|
||||||
description:
|
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:
|
details:
|
||||||
"Resize, compress, convert, remove backgrounds, upscale, run OCR, and more - without sending images to external services.",
|
"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.
|
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
|
## Architecture
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ Node.js Tool Route
|
|||||||
├─ noise_removal.py (tiered denoising)
|
├─ noise_removal.py (tiered denoising)
|
||||||
├─ red_eye_removal.py (landmark + color analysis)
|
├─ red_eye_removal.py (landmark + color analysis)
|
||||||
├─ restore.py (scratch repair + enhancement + denoising)
|
├─ restore.py (scratch repair + enhancement + denoising)
|
||||||
|
├─ transparency_fix.py (BiRefNet HR-matting + defringe)
|
||||||
└─ seam_carving (Go caire binary - not Python)
|
└─ 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 |
|
| `square` | boolean | false | Force square output |
|
||||||
|
|
||||||
Max input edge before auto-downscaling: **1200 px**.
|
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` |
|
| `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` |
|
| `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` |
|
| `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
|
### 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.
|
**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.
|
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`)
|
### 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
|
- 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
|
- 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)
|
- Tool execution (routes each tool request to the image engine or AI bridge)
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ pnpm dev
|
|||||||
|
|
||||||
## What You Can Do
|
## What You Can Do
|
||||||
|
|
||||||
### Image Processing (47 Tools)
|
### Image Processing (48 Tools)
|
||||||
|
|
||||||
| Category | Tools |
|
| Category | Tools |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| **Essentials** | Resize, Crop, Rotate & Flip, Convert, Compress |
|
| **Essentials** | Resize, Crop, Rotate & Flip, Convert, Compress |
|
||||||
| **Optimization** | Optimize for Web, Strip Metadata, Edit Metadata, Bulk Rename, Image to PDF, Favicon Generator |
|
| **Optimization** | Optimize for Web, Strip Metadata, Edit Metadata, Bulk Rename, Image to PDF, Favicon Generator |
|
||||||
| **Adjustments** | Adjust Colors, Sharpening, Replace Color |
|
| **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 |
|
| **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 |
|
| **Utilities** | Image Info, Compare, Find Duplicates, Color Palette, QR Code Generator, Barcode Reader, Image to Base64 |
|
||||||
| **Layout** | Collage, Stitch, Split, Border & Frame |
|
| **Layout** | Collage, Stitch, Split, Border & Frame |
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@ layout: home
|
|||||||
hero:
|
hero:
|
||||||
name: "SnapOtter"
|
name: "SnapOtter"
|
||||||
text: "A Self Hosted Image Manipulator"
|
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:
|
actions:
|
||||||
- theme: brand
|
- theme: brand
|
||||||
text: Get started
|
text: Get started
|
||||||
@@ -14,10 +14,10 @@ hero:
|
|||||||
link: /api/rest
|
link: /api/rest
|
||||||
|
|
||||||
features:
|
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.
|
details: Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, build collages, generate passport photos, find duplicates, and more.
|
||||||
- title: Local AI
|
- 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
|
- title: Pipelines
|
||||||
details: Chain tools into reusable workflows with unlimited steps. Batch process unlimited images at once with a single request.
|
details: Chain tools into reusable workflows with unlimited steps. Batch process unlimited images at once with a single request.
|
||||||
- title: REST API
|
- title: REST API
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito" });
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "SnapOtter | Self-Hosted Image Processing",
|
title: "SnapOtter | Self-Hosted Image Processing",
|
||||||
description:
|
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"),
|
metadataBase: new URL("https://snapotter.com"),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "SnapOtter | Self-Hosted Image Processing",
|
title: "SnapOtter | Self-Hosted Image Processing",
|
||||||
description:
|
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",
|
url: "https://snapotter.com",
|
||||||
siteName: "SnapOtter",
|
siteName: "SnapOtter",
|
||||||
type: "website",
|
type: "website",
|
||||||
@@ -30,7 +30,7 @@ export const metadata: Metadata = {
|
|||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "SnapOtter | Self-Hosted Image Processing",
|
title: "SnapOtter | Self-Hosted Image Processing",
|
||||||
description:
|
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"],
|
images: ["/og-image.svg"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Columns2,
|
Columns2,
|
||||||
Copy,
|
Copy,
|
||||||
Crop,
|
Crop,
|
||||||
|
Droplets,
|
||||||
Eraser,
|
Eraser,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -237,6 +238,12 @@ const tools: { name: string; description: string; category: string; icon: Lucide
|
|||||||
category: "ai",
|
category: "ai",
|
||||||
icon: Scaling,
|
icon: Scaling,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "PNG Transparency Fixer",
|
||||||
|
description: "Fix fake transparent PNGs with AI matting",
|
||||||
|
category: "ai",
|
||||||
|
icon: Droplets,
|
||||||
|
},
|
||||||
// Watermark & Overlay
|
// Watermark & Overlay
|
||||||
{
|
{
|
||||||
name: "Text Watermark",
|
name: "Text Watermark",
|
||||||
@@ -389,7 +396,7 @@ export function BentoGrid() {
|
|||||||
<div className="mx-auto max-w-6xl">
|
<div className="mx-auto max-w-6xl">
|
||||||
<FadeIn>
|
<FadeIn>
|
||||||
<h2 className="font-[family-name:var(--font-nunito)] text-center text-3xl font-bold tracking-tight md:text-4xl">
|
<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>
|
</h2>
|
||||||
<p className="mx-auto mt-4 max-w-xl text-center text-lg text-muted">
|
<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.
|
Search to find exactly what you need. Every tool runs 100% locally.
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ const freePlan = {
|
|||||||
price: "Free",
|
price: "Free",
|
||||||
subtitle: "For everyone. Forever.",
|
subtitle: "For everyone. Forever.",
|
||||||
features: [
|
features: [
|
||||||
"All 47 image processing tools",
|
"All 48 image processing tools",
|
||||||
"Unlimited usage, no hidden caps",
|
"Unlimited usage, no hidden caps",
|
||||||
"Full REST API with OpenAPI docs",
|
"Full REST API with OpenAPI docs",
|
||||||
"Pipeline automation",
|
"Pipeline automation",
|
||||||
"Batch processing (unlimited files)",
|
"Batch processing (unlimited files)",
|
||||||
"14 local AI models included",
|
"15 local AI models included",
|
||||||
"Self-host on any infrastructure",
|
"Self-host on any infrastructure",
|
||||||
"Docker, Kubernetes, bare metal",
|
"Docker, Kubernetes, bare metal",
|
||||||
"ARM and x86 support",
|
"ARM and x86 support",
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const phrases = [
|
|||||||
"No limits. No hidden caps.",
|
"No limits. No hidden caps.",
|
||||||
"Works fully offline.",
|
"Works fully offline.",
|
||||||
"Unlimited batch processing.",
|
"Unlimited batch processing.",
|
||||||
"47 image tools.",
|
"48 image tools.",
|
||||||
"14 AI models. Your hardware.",
|
"15 AI models. Your hardware.",
|
||||||
"Lightning fast. Built on Sharp.",
|
"Lightning fast. Built on Sharp.",
|
||||||
"Air-gapped ready.",
|
"Air-gapped ready.",
|
||||||
"One Docker container.",
|
"One Docker container.",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FeatureBundleState } from "@snapotter/shared";
|
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 { useEffect, useState } from "react";
|
||||||
import { useFeaturesStore } from "@/stores/features-store";
|
import { useFeaturesStore } from "@/stores/features-store";
|
||||||
|
|
||||||
@@ -46,14 +46,17 @@ function formatTimeRemaining(ms: number): string {
|
|||||||
interface FeatureInstallPromptProps {
|
interface FeatureInstallPromptProps {
|
||||||
bundle: FeatureBundleState;
|
bundle: FeatureBundleState;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
toolName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureInstallPrompt({ bundle, isAdmin }: FeatureInstallPromptProps) {
|
export function FeatureInstallPrompt({ bundle, isAdmin, toolName }: FeatureInstallPromptProps) {
|
||||||
const { installBundle, clearError, installing, errors, startTimes } = useFeaturesStore();
|
const { installBundle, clearError, installing, errors, startTimes, queued } = useFeaturesStore();
|
||||||
const progress = installing[bundle.id] ?? null;
|
const progress = installing[bundle.id] ?? null;
|
||||||
const error = errors[bundle.id] ?? null;
|
const error = errors[bundle.id] ?? null;
|
||||||
const isInstalling = !!progress;
|
const isInstalling = !!progress;
|
||||||
|
const isQueued = queued.includes(bundle.id);
|
||||||
const startTime = startTimes[bundle.id] ?? null;
|
const startTime = startTimes[bundle.id] ?? null;
|
||||||
|
const displayName = toolName || bundle.name;
|
||||||
|
|
||||||
const [messageIndex, setMessageIndex] = useState(() =>
|
const [messageIndex, setMessageIndex] = useState(() =>
|
||||||
Math.floor(Math.random() * PROGRESS_MESSAGES.length),
|
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">
|
<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" />
|
<Download className="h-16 w-16 text-muted-foreground" />
|
||||||
<div className="space-y-2">
|
<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-muted-foreground max-w-md">{bundle.description}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This feature requires an additional download (~{bundle.estimatedSize})
|
This feature requires an additional download (~{bundle.estimatedSize})
|
||||||
@@ -139,13 +142,20 @@ export function FeatureInstallPrompt({ bundle, isAdmin }: FeatureInstallPromptPr
|
|||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleInstall}
|
onClick={handleInstall}
|
||||||
className="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 font-medium"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2535,7 +2535,7 @@ function AboutSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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.
|
convert, watermark, and automate your image workflows without sending data to the cloud.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<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,
|
default: m.RestorePhotoSettings,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
const TransparencyFixerSettings = lazy(() =>
|
||||||
|
import("@/components/tools/transparency-fixer-settings").then((m) => ({
|
||||||
|
default: m.TransparencyFixerSettings,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
// ── Color tool wrapper ─────────────────────────────────────────────
|
// ── Color tool wrapper ─────────────────────────────────────────────
|
||||||
// Color tools share a single component but differ by toolId.
|
// 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 }],
|
["red-eye-removal", { displayMode: "before-after", Settings: RedEyeRemovalSettings }],
|
||||||
["restore-photo", { displayMode: "before-after", Settings: RestorePhotoSettings }],
|
["restore-photo", { displayMode: "before-after", Settings: RestorePhotoSettings }],
|
||||||
|
["transparency-fixer", { displayMode: "before-after", Settings: TransparencyFixerSettings }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function getToolRegistryEntry(toolId: string): ToolRegistryEntry | undefined {
|
export function getToolRegistryEntry(toolId: string): ToolRegistryEntry | undefined {
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ export function ToolPage() {
|
|||||||
if (isAiTool && !toolInstalled && featureBundle) {
|
if (isAiTool && !toolInstalled && featureBundle) {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<FeatureInstallPrompt bundle={featureBundle} isAdmin={isAdmin} />
|
<FeatureInstallPrompt bundle={featureBundle} isAdmin={isAdmin} toolName={tool?.name} />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ REMBG_MODELS = [
|
|||||||
"birefnet-portrait",
|
"birefnet-portrait",
|
||||||
"birefnet-general",
|
"birefnet-general",
|
||||||
"birefnet-matting",
|
"birefnet-matting",
|
||||||
|
"birefnet-hr-matting",
|
||||||
]
|
]
|
||||||
|
|
||||||
# PaddleOCR PP-OCRv5 HuggingFace model repos to pre-download.
|
# PaddleOCR PP-OCRv5 HuggingFace model repos to pre-download.
|
||||||
@@ -235,12 +236,59 @@ def _register_birefnet_matting():
|
|||||||
sessions_class.append(BiRefNetMattingSession)
|
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():
|
def download_rembg_models():
|
||||||
"""Download all rembg ONNX models."""
|
"""Download all rembg ONNX models."""
|
||||||
print("=== Downloading rembg models ===")
|
print("=== Downloading rembg models ===")
|
||||||
from rembg import new_session
|
from rembg import new_session
|
||||||
|
|
||||||
_register_birefnet_matting()
|
_register_birefnet_matting()
|
||||||
|
_register_birefnet_hr_matting()
|
||||||
|
|
||||||
for model in REMBG_MODELS:
|
for model in REMBG_MODELS:
|
||||||
print(f" Downloading {model}...")
|
print(f" Downloading {model}...")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"background-removal": {
|
"background-removal": {
|
||||||
"name": "Background Removal",
|
"name": "Background Removal",
|
||||||
"description": "Remove image backgrounds with AI",
|
"description": "Remove image backgrounds with AI",
|
||||||
"estimatedSize": "3-4 GB",
|
"estimatedSize": "4-5 GB",
|
||||||
"packages": {
|
"packages": {
|
||||||
"common": ["rembg==2.0.62"],
|
"common": ["rembg==2.0.62"],
|
||||||
"amd64": ["onnxruntime-gpu==1.20.1", "mediapipe==0.10.21"],
|
"amd64": ["onnxruntime-gpu==1.20.1", "mediapipe==0.10.21"],
|
||||||
@@ -42,9 +42,14 @@
|
|||||||
"id": "rembg-birefnet-matting",
|
"id": "rembg-birefnet-matting",
|
||||||
"downloadFn": "rembg_session",
|
"downloadFn": "rembg_session",
|
||||||
"args": ["birefnet-matting"]
|
"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": {
|
"face-detection": {
|
||||||
"name": "Face Detection",
|
"name": "Face Detection",
|
||||||
|
|||||||
@@ -316,6 +316,63 @@ def _register_birefnet_matting() -> None:
|
|||||||
sessions_class.append(BiRefNetMattingSession)
|
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:
|
def download_rembg_session(model: dict, models_dir: str) -> None:
|
||||||
"""Download a rembg model by initializing a session."""
|
"""Download a rembg model by initializing a session."""
|
||||||
args = model.get("args", [])
|
args = model.get("args", [])
|
||||||
@@ -331,6 +388,7 @@ def download_rembg_session(model: dict, models_dir: str) -> None:
|
|||||||
|
|
||||||
from rembg import new_session
|
from rembg import new_session
|
||||||
_register_birefnet_matting()
|
_register_birefnet_matting()
|
||||||
|
_register_birefnet_hr_matting()
|
||||||
new_session(model_name)
|
new_session(model_name)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ ALLOWED_MODELS = {
|
|||||||
"birefnet-portrait",
|
"birefnet-portrait",
|
||||||
"birefnet-general",
|
"birefnet-general",
|
||||||
"birefnet-matting",
|
"birefnet-matting",
|
||||||
|
"birefnet-hr-matting",
|
||||||
}
|
}
|
||||||
|
|
||||||
_matting_registered = False
|
_matting_registered = False
|
||||||
@@ -51,6 +52,57 @@ def _register_matting_session(sessions_class):
|
|||||||
|
|
||||||
sessions_class.append(BiRefNetMattingSession)
|
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():
|
def main():
|
||||||
input_path = sys.argv[1]
|
input_path = sys.argv[1]
|
||||||
@@ -73,6 +125,7 @@ def main():
|
|||||||
|
|
||||||
# Register BiRefNet-matting (Ultra quality) if not already present
|
# Register BiRefNet-matting (Ultra quality) if not already present
|
||||||
_register_matting_session(sessions_class)
|
_register_matting_session(sessions_class)
|
||||||
|
_register_hr_matting_session(sessions_class)
|
||||||
|
|
||||||
emit_progress(10, "Loading model")
|
emit_progress(10, "Loading model")
|
||||||
|
|
||||||
|
|||||||
@@ -241,6 +241,14 @@ export const TOOLS: Tool[] = [
|
|||||||
icon: "Scaling",
|
icon: "Scaling",
|
||||||
route: "/content-aware-resize",
|
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
|
// Watermark & Overlay
|
||||||
{
|
{
|
||||||
id: "watermark-text",
|
id: "watermark-text",
|
||||||
@@ -1207,4 +1215,5 @@ export const PYTHON_SIDECAR_TOOLS = [
|
|||||||
"red-eye-removal",
|
"red-eye-removal",
|
||||||
"restore-photo",
|
"restore-photo",
|
||||||
"passport-photo",
|
"passport-photo",
|
||||||
|
"transparency-fixer",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export const FEATURE_BUNDLES: Record<string, FeatureBundleInfo> = {
|
|||||||
id: "background-removal",
|
id: "background-removal",
|
||||||
name: "Background Removal",
|
name: "Background Removal",
|
||||||
description: "Remove image backgrounds with AI",
|
description: "Remove image backgrounds with AI",
|
||||||
estimatedSize: "3-4 GB",
|
estimatedSize: "4-5 GB",
|
||||||
enablesTools: ["remove-background", "passport-photo"],
|
enablesTools: ["remove-background", "passport-photo", "transparency-fixer"],
|
||||||
},
|
},
|
||||||
"face-detection": {
|
"face-detection": {
|
||||||
id: "face-detection",
|
id: "face-detection",
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ export const en = {
|
|||||||
description:
|
description:
|
||||||
"Create government-compliant passport, visa, and ID photos with auto face detection",
|
"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-text": { name: "Text Watermark", description: "Add text watermark overlay" },
|
||||||
"watermark-image": { name: "Image Watermark", description: "Overlay a logo as watermark" },
|
"watermark-image": { name: "Image Watermark", description: "Overlay a logo as watermark" },
|
||||||
"text-overlay": { name: "Text Overlay", description: "Add styled text to images" },
|
"text-overlay": { name: "Text Overlay", description: "Add styled text to images" },
|
||||||
@@ -203,7 +207,7 @@ export const en = {
|
|||||||
regenerateKey: "Regenerate",
|
regenerateKey: "Regenerate",
|
||||||
copyKey: "Copy Key",
|
copyKey: "Copy Key",
|
||||||
aboutDescription:
|
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",
|
aboutLinks: "Links",
|
||||||
github: "GitHub",
|
github: "GitHub",
|
||||||
documentation: "Documentation",
|
documentation: "Documentation",
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -13,7 +13,7 @@ test.describe("Docs Homepage", () => {
|
|||||||
await expect(page.getByText("SnapOtter").first()).toBeVisible();
|
await expect(page.getByText("SnapOtter").first()).toBeVisible();
|
||||||
await expect(page.getByText("A Self Hosted Image Manipulator")).toBeVisible();
|
await expect(page.getByText("A Self Hosted Image Manipulator")).toBeVisible();
|
||||||
await expect(
|
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();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ test.describe("Docs Homepage", () => {
|
|||||||
|
|
||||||
test("features section renders all 6 feature cards", async ({ page }) => {
|
test("features section renders all 6 feature cards", async ({ page }) => {
|
||||||
const features = [
|
const features = [
|
||||||
"47 Image Tools",
|
"48 Image Tools",
|
||||||
"Local AI",
|
"Local AI",
|
||||||
"Pipelines",
|
"Pipelines",
|
||||||
"REST API",
|
"REST API",
|
||||||
|
|||||||
@@ -37,16 +37,20 @@ test.describe("Landing Homepage", () => {
|
|||||||
|
|
||||||
test("how-it-works section renders Docker command", async ({ page }) => {
|
test("how-it-works section renders Docker command", async ({ page }) => {
|
||||||
await expect(page.getByText("Get started in seconds")).toBeVisible();
|
await expect(page.getByText("Get started in seconds")).toBeVisible();
|
||||||
await expect(
|
await expect(page.getByText("docker run -d --name SnapOtter", { exact: false })).toBeVisible();
|
||||||
page.getByText("docker run -d --name SnapOtter", { exact: false }),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("why-choose section renders all 9 benefit cards", async ({ page }) => {
|
test("why-choose section renders all 9 benefit cards", async ({ page }) => {
|
||||||
await expect(page.getByText("Built different. On purpose.")).toBeVisible();
|
await expect(page.getByText("Built different. On purpose.")).toBeVisible();
|
||||||
const cards = [
|
const cards = [
|
||||||
"No Signup", "No Uploads", "Forever Free", "No Limits",
|
"No Signup",
|
||||||
"Batch Processing", "Lightning Fast", "Open Source", "REST API",
|
"No Uploads",
|
||||||
|
"Forever Free",
|
||||||
|
"No Limits",
|
||||||
|
"Batch Processing",
|
||||||
|
"Lightning Fast",
|
||||||
|
"Open Source",
|
||||||
|
"REST API",
|
||||||
"Pipeline Automation",
|
"Pipeline Automation",
|
||||||
];
|
];
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
@@ -55,9 +59,9 @@ test.describe("Landing Homepage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("bento grid renders with search and tool count", async ({ page }) => {
|
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.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 }) => {
|
test("enterprise section renders feature cards", async ({ page }) => {
|
||||||
|
|||||||
@@ -9,28 +9,28 @@ test.describe("BentoGrid Interactions", () => {
|
|||||||
const input = page.getByPlaceholder("Search tools...");
|
const input = page.getByPlaceholder("Search tools...");
|
||||||
await input.fill("resize");
|
await input.fill("resize");
|
||||||
await expect(page.getByText("Resize", { exact: true }).first()).toBeVisible();
|
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 }) => {
|
test("category pill filters tools", async ({ page }) => {
|
||||||
await page.getByText(/AI Tools/).click();
|
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();
|
await expect(page.getByText("Remove Background")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking All resets category filter", async ({ page }) => {
|
test("clicking All resets category filter", async ({ page }) => {
|
||||||
await page.getByText(/AI Tools/).click();
|
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 page.getByText(/All \(48\)/).click();
|
||||||
await expect(page.getByText(/Showing 47 of 47 tools/)).toBeVisible();
|
await expect(page.getByText(/Showing 48 of 48 tools/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("search with no results shows empty state", async ({ page }) => {
|
test("search with no results shows empty state", async ({ page }) => {
|
||||||
const input = page.getByPlaceholder("Search tools...");
|
const input = page.getByPlaceholder("Search tools...");
|
||||||
await input.fill("xyznonexistent");
|
await input.fill("xyznonexistent");
|
||||||
await expect(page.getByText("No tools found. Try a different search.")).toBeVisible();
|
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 }) => {
|
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 }) => {
|
test("clicking a question expands the answer", async ({ page }) => {
|
||||||
await page.getByText("Are my files safe and private?").click();
|
await page.getByText("Are my files safe and private?").click();
|
||||||
await expect(
|
await expect(page.getByText(/All processing happens on your own server/)).toBeVisible();
|
||||||
page.getByText(/All processing happens on your own server/),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking again collapses the answer", async ({ page }) => {
|
test("clicking again collapses the answer", async ({ page }) => {
|
||||||
const question = page.getByText("Are my files safe and private?");
|
const question = page.getByText("Are my files safe and private?");
|
||||||
await question.click();
|
await question.click();
|
||||||
await expect(
|
await expect(page.getByText(/All processing happens on your own server/)).toBeVisible();
|
||||||
page.getByText(/All processing happens on your own server/),
|
|
||||||
).toBeVisible();
|
|
||||||
await question.click();
|
await question.click();
|
||||||
await expect(
|
await expect(page.getByText(/All processing happens on your own server/)).not.toBeVisible();
|
||||||
page.getByText(/All processing happens on your own server/),
|
|
||||||
).not.toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multiple FAQs can be open simultaneously", async ({ page }) => {
|
test("multiple FAQs can be open simultaneously", async ({ page }) => {
|
||||||
await page.getByText("Are my files safe and private?").click();
|
await page.getByText("Are my files safe and private?").click();
|
||||||
await page.getByText("Is SnapOtter really free?").click();
|
await page.getByText("Is SnapOtter really free?").click();
|
||||||
await expect(
|
await expect(page.getByText(/All processing happens on your own server/)).toBeVisible();
|
||||||
page.getByText(/All processing happens on your own server/),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByText(/open source under AGPL-3.0/)).toBeVisible();
|
await expect(page.getByText(/open source under AGPL-3.0/)).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
// Each tool row has an enable/disable toggle (w-11 h-6 rounded-full)
|
||||||
const toolToggles = page.locator("button.w-11.h-6");
|
const toolToggles = page.locator("button.w-11.h-6");
|
||||||
const count = await toolToggles.count();
|
const count = await toolToggles.count();
|
||||||
// Should have many tools (SnapOtter has 47)
|
// Should have many tools (SnapOtter has 48)
|
||||||
expect(count).toBeGreaterThan(10);
|
expect(count).toBeGreaterThan(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -831,4 +831,131 @@ test.describe("GUI AI Tools", () => {
|
|||||||
).toBeVisible({ timeout: 10_000 });
|
).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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const TOOLS_WITH_DROPZONE = [
|
|||||||
{ id: "vectorize", name: "Image to SVG" },
|
{ id: "vectorize", name: "Image to SVG" },
|
||||||
{ id: "gif-tools", name: "GIF" },
|
{ id: "gif-tools", name: "GIF" },
|
||||||
{ id: "noise-removal", name: "Noise Removal" },
|
{ id: "noise-removal", name: "Noise Removal" },
|
||||||
|
{ id: "transparency-fixer", name: "PNG Transparency Fixer" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TOOLS_WITHOUT_DROPZONE = [{ id: "qr-generate", name: "QR Code" }];
|
const TOOLS_WITHOUT_DROPZONE = [{ id: "qr-generate", name: "QR Code" }];
|
||||||
@@ -53,6 +54,7 @@ const AI_TOOL_IDS = new Set([
|
|||||||
"blur-faces",
|
"blur-faces",
|
||||||
"smart-crop",
|
"smart-crop",
|
||||||
"noise-removal",
|
"noise-removal",
|
||||||
|
"transparency-fixer",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
test.describe("All tool pages render", () => {
|
test.describe("All tool pages render", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -25,7 +25,7 @@ function getCountText(): string {
|
|||||||
describe("BentoGrid", () => {
|
describe("BentoGrid", () => {
|
||||||
it("renders the section heading", () => {
|
it("renders the section heading", () => {
|
||||||
render(<BentoGrid />);
|
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", () => {
|
it("renders the search input", () => {
|
||||||
@@ -36,12 +36,12 @@ describe("BentoGrid", () => {
|
|||||||
it("shows all tools by default", () => {
|
it("shows all tools by default", () => {
|
||||||
render(<BentoGrid />);
|
render(<BentoGrid />);
|
||||||
const text = getCountText();
|
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", () => {
|
it("renders category filter pills including All", () => {
|
||||||
render(<BentoGrid />);
|
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(/Essentials/)).toBeDefined();
|
||||||
expect(screen.getByText(/AI Tools/)).toBeDefined();
|
expect(screen.getByText(/AI Tools/)).toBeDefined();
|
||||||
expect(screen.getByText(/Optimization/)).toBeDefined();
|
expect(screen.getByText(/Optimization/)).toBeDefined();
|
||||||
@@ -53,7 +53,7 @@ describe("BentoGrid", () => {
|
|||||||
fireEvent.change(input, { target: { value: "resize" } });
|
fireEvent.change(input, { target: { value: "resize" } });
|
||||||
expect(screen.getByText("Resize")).toBeDefined();
|
expect(screen.getByText("Resize")).toBeDefined();
|
||||||
const text = getCountText();
|
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();
|
expect(screen.queryByText("OCR / Text Extraction")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ describe("BentoGrid", () => {
|
|||||||
const aiButton = screen.getByText(/AI Tools/);
|
const aiButton = screen.getByText(/AI Tools/);
|
||||||
fireEvent.click(aiButton);
|
fireEvent.click(aiButton);
|
||||||
const text = getCountText();
|
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.getByText("Remove Background")).toBeDefined();
|
||||||
expect(screen.queryByText("Resize")).toBeNull();
|
expect(screen.queryByText("Resize")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -73,7 +73,7 @@ describe("BentoGrid", () => {
|
|||||||
fireEvent.change(input, { target: { value: "xyznonexistent" } });
|
fireEvent.change(input, { target: { value: "xyznonexistent" } });
|
||||||
expect(screen.getByText("No tools found. Try a different search.")).toBeDefined();
|
expect(screen.getByText("No tools found. Try a different search.")).toBeDefined();
|
||||||
const text = getCountText();
|
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", () => {
|
it("combines search and category filter", () => {
|
||||||
@@ -89,9 +89,9 @@ describe("BentoGrid", () => {
|
|||||||
it("clicking All resets category filter", () => {
|
it("clicking All resets category filter", () => {
|
||||||
render(<BentoGrid />);
|
render(<BentoGrid />);
|
||||||
fireEvent.click(screen.getByText(/AI Tools/));
|
fireEvent.click(screen.getByText(/AI Tools/));
|
||||||
expect(getCountText()).toMatch(/Showing 14 of 47 tools/);
|
expect(getCountText()).toMatch(/Showing 15 of 48 tools/);
|
||||||
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (47)"));
|
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (48)"));
|
||||||
expect(getCountText()).toMatch(/Showing 47 of 47 tools/);
|
expect(getCountText()).toMatch(/Showing 48 of 48 tools/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders each tool with name and description", () => {
|
it("renders each tool with name and description", () => {
|
||||||
|
|||||||
@@ -159,9 +159,9 @@ describe("Pricing", () => {
|
|||||||
|
|
||||||
it("renders free plan features", () => {
|
it("renders free plan features", () => {
|
||||||
render(<Pricing />);
|
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("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();
|
expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user