fix: sync API docs, register content-aware-resize, normalize tool counts

- Fix 23 OpenAPI schema discrepancies across 16+ tools (wrong ranges,
  missing fields, incorrect schemas for gif-tools/collage/ocr)
- Add content-aware-resize to canonical TOOLS array and landing BentoGrid
- Normalize tool count to 47 across README, docs, landing, i18n, OpenAPI
- Remove dead "automation" ToolCategory variant
- Add BMP and JPEG XL format decoding via ImageMagick
- Add libopenexr-dev to Docker runtime image
- Update e2e test selectors for current pipeline builder UI
This commit is contained in:
SnapOtter
2026-04-24 23:27:08 +08:00
parent 7f62bc32db
commit 8633dba431
18 changed files with 211 additions and 84 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
## Key Features ## Key Features
- **45+ image tools** - Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, find duplicates, generate passport photos, and more - **47 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`
+41 -1
View File
@@ -8,7 +8,7 @@ import { promisify } from "node:util";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
/** Formats that need external CLI tools (not decodable by Sharp). */ /** Formats that need external CLI tools (not decodable by Sharp). */
const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr"]); const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr", "bmp", "jxl"]);
export function needsCliDecode(format: string): boolean { export function needsCliDecode(format: string): boolean {
return CLI_DECODED_FORMATS.has(format); return CLI_DECODED_FORMATS.has(format);
@@ -32,6 +32,10 @@ export async function decodeToSharpCompat(buffer: Buffer, format: string): Promi
return decodeExr(buffer); return decodeExr(buffer);
case "hdr": case "hdr":
return decodeHdr(buffer); return decodeHdr(buffer);
case "bmp":
return decodeBmp(buffer);
case "jxl":
return decodeJxl(buffer);
default: default:
return buffer; return buffer;
} }
@@ -192,3 +196,39 @@ async function decodeHdr(buffer: Buffer): Promise<Buffer> {
await rm(outputPath, { force: true }).catch(() => {}); await rm(outputPath, { force: true }).catch(() => {});
} }
} }
async function decodeBmp(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
const id = randomUUID();
const inputPath = join(tmpdir(), `bmp-in-${id}.bmp`);
const outputPath = join(tmpdir(), `bmp-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
timeout: 120_000,
});
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
async function decodeJxl(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
const id = randomUUID();
const inputPath = join(tmpdir(), `jxl-in-${id}.jxl`);
const outputPath = join(tmpdir(), `jxl-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
timeout: 120_000,
});
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
+113 -44
View File
@@ -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 30+ tools. REST API for SnapOtter, a self-hosted image processing platform with 47 tools.
## Authentication ## Authentication
@@ -344,10 +344,11 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `left` (integer, required) — Left offset in pixels - `left` (number, required) — Left offset in pixels (min 0)
- `top` (integer, required) — Top offset in pixels - `top` (number, required) — Top offset in pixels (min 0)
- `width` (integer, required) — Width of crop region in pixels - `width` (number, required) — Width of crop region in pixels
- `height` (integer, required) — Height of crop region in pixels - `height` (number, required) — Height of crop region in pixels
- `unit` (string, optional) — One of: px, percent
responses: responses:
"200": "200":
description: Processed image description: Processed image
@@ -437,7 +438,7 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `format` (string, required) — One of: jpg, png, webp, avif, tiff, gif, heic - `format` (string, required) — One of: jpg, png, webp, avif, tiff, gif, heic, heif
- `quality` (number 1-100, optional) — Output quality - `quality` (number 1-100, optional) — Output quality
responses: responses:
"200": "200":
@@ -576,12 +577,17 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `borderWidth` (number 0-200, default 10) — Border thickness in pixels - `borderWidth` (number 0-2000, default 10) — Border thickness in pixels
- `borderColor` (hex string, default "#000000") — Border color - `borderColor` (hex string, default "#000000") — Border color
- `cornerRadius` (number 0-500, default 0) — Corner rounding radius
- `padding` (number 0-200, default 0) — Inner padding in pixels - `padding` (number 0-200, default 0) — Inner padding in pixels
- `shadowBlur` (number 0-50, default 0) — Drop shadow blur radius - `paddingColor` (hex string, default "#FFFFFF") — Padding fill color
- `shadowColor` (hex string, default "#00000080") — Drop shadow color - `cornerRadius` (number 0-2000, default 0) — Corner rounding radius
- `shadow` (boolean, default false) — Enable drop shadow
- `shadowBlur` (number 1-200, default 15) — Drop shadow blur radius
- `shadowOffsetX` (number -50 to 50, default 0) — Shadow horizontal offset
- `shadowOffsetY` (number -50 to 50, default 5) — Shadow vertical offset
- `shadowColor` (hex string, default "#000000") — Drop shadow color
- `shadowOpacity` (number 0-100, default 40) — Shadow opacity percentage
responses: responses:
"200": "200":
description: Processed image description: Processed image
@@ -1903,7 +1909,7 @@ paths:
description: | description: |
JSON string with options: JSON string with options:
- `text` (string 1-500, required) — Watermark text - `text` (string 1-500, required) — Watermark text
- `fontSize` (number 8-200, default 48) — Font size in pixels - `fontSize` (number 8-1000, default 48) — Font size in pixels
- `color` (hex string, default "#000000") — Text color - `color` (hex string, default "#000000") — Text color
- `opacity` (number 0-100, default 50) — Opacity percentage - `opacity` (number 0-100, default 50) — Opacity percentage
- `position` (string, default "center") — One of: center, top-left, top-right, bottom-left, bottom-right, tiled - `position` (string, default "center") — One of: center, top-left, top-right, bottom-left, bottom-right, tiled
@@ -2048,10 +2054,23 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `width` (number 1-4096, optional) — Target width in pixels - `mode` (string, default "resize") — One of: resize, optimize, speed, reverse, extract, rotate
- `height` (number 1-4096, optional) — Target height in pixels - `width` (number 1-16384, optional) — Target width in pixels (resize mode)
- `extractFrame` (number, optional) — Extract specific frame index - `height` (number 1-16384, optional) — Target height in pixels (resize mode)
- `optimize` (boolean, default false) — Optimize GIF file size - `percentage` (number 1-500, optional) — Scale by percentage (resize mode)
- `colors` (number 2-256, default 256) — Color palette size (optimize mode)
- `dither` (number 0-1, default 1.0) — Dither amount (optimize mode)
- `effort` (number 1-10, default 7) — Compression effort (optimize mode)
- `speedFactor` (number 0.1-10, default 1.0) — Speed multiplier (speed mode)
- `extractMode` (string, default "single") — One of: single, range, all (extract mode)
- `frameNumber` (number, default 0) — Frame index to extract (extract/single mode)
- `frameStart` (number, default 0) — Start frame index (extract/range mode)
- `frameEnd` (number, optional) — End frame index (extract/range mode)
- `extractFormat` (string, default "png") — One of: png, webp (extract mode)
- `angle` (number, optional) — Rotation angle, one of: 90, 180, 270 (rotate mode)
- `flipH` (boolean, default false) — Flip horizontally (rotate mode)
- `flipV` (boolean, default false) — Flip vertically (rotate mode)
- `loop` (number 0-100, default 0) — Loop count, 0 = infinite
responses: responses:
"200": "200":
description: Processed image description: Processed image
@@ -2148,11 +2167,11 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `mode` (string) — "subject" (default), "face", or "trim" - `mode` (string, default "subject") — One of: subject, face, trim, attention (alias for subject), content (alias for trim)
- `strategy` (string) — "attention" (default) or "entropy" (subject mode) - `strategy` (string, default "attention") — One of: attention, entropy (subject mode)
- `width` (integer) — Target width in pixels (default 1080) - `width` (integer, optional) — Target width in pixels
- `height` (integer) — Target height in pixels (default 1080) - `height` (integer, optional) — Target height in pixels
- `padding` (integer 0-50) — Padding percentage around focus area - `padding` (integer 0-50, default 0) — Padding percentage around focus area
- `facePreset` (string) — "closeup", "head-shoulders", "upper-body", "half-body" (face mode) - `facePreset` (string) — "closeup", "head-shoulders", "upper-body", "half-body" (face mode)
- `sensitivity` (number 0-1) — Face detection sensitivity (face mode) - `sensitivity` (number 0-1) — Face detection sensitivity (face mode)
- `threshold` (integer 0-255) — Trim tolerance (trim mode) - `threshold` (integer 0-255) — Trim tolerance (trim mode)
@@ -2205,9 +2224,9 @@ paths:
JSON string with options: JSON string with options:
- `colorMode` (string, default "bw") - One of: bw, color - `colorMode` (string, default "bw") - One of: bw, color
- `threshold` (number 0-255, default 128) - B&W binarization threshold - `threshold` (number 0-255, default 128) - B&W binarization threshold
- `colorPrecision` (number 1-8, default 6) - Color bits per channel - `colorPrecision` (number 1-16, default 6) - Color bits per channel
- `layerDifference` (number 1-64, default 6) - Color gradient step - `layerDifference` (number 1-128, default 6) - Color gradient step
- `filterSpeckle` (number 1-128, default 4) - Noise filter size - `filterSpeckle` (number 1-256, default 4) - Noise filter size
- `pathMode` (string, default "spline") - One of: none, polygon, spline - `pathMode` (string, default "spline") - One of: none, polygon, spline
- `cornerThreshold` (number 0-180, default 60) - Corner detection angle - `cornerThreshold` (number 0-180, default 60) - Corner detection angle
- `invert` (boolean, default false) - Invert colors before tracing - `invert` (boolean, default false) - Invert colors before tracing
@@ -2254,9 +2273,9 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `width` (number 1-16384, optional) — Output width in pixels - `width` (number 1-65536, optional) — Output width in pixels
- `height` (number 1-16384, optional) — Output height in pixels - `height` (number 1-65536, optional) — Output height in pixels
- `dpi` (number 36-1200, default 300) — Render density for SVG rasterization - `dpi` (number 36-2400, default 300) — Render density for SVG rasterization
- `quality` (number 1-100, default 90) — Output quality for lossy formats - `quality` (number 1-100, default 90) — Output quality for lossy formats
- `backgroundColor` (hex string, default "#00000000") — Background color - `backgroundColor` (hex string, default "#00000000") — Background color
- `outputFormat` (string, default "png") — One of: png, jpg, webp, avif, tiff, gif, heif - `outputFormat` (string, default "png") — One of: png, jpg, webp, avif, tiff, gif, heif
@@ -2353,7 +2372,7 @@ paths:
JSON string with options: JSON string with options:
- `pageSize` (string, default "A4") — One of: A4, Letter, A3, A5 - `pageSize` (string, default "A4") — One of: A4, Letter, A3, A5
- `orientation` (string, default "portrait") — One of: portrait, landscape - `orientation` (string, default "portrait") — One of: portrait, landscape
- `margin` (number 0-100, default 20) — Page margin in points - `margin` (number 0-500, default 20) — Page margin in points
responses: responses:
"200": "200":
description: Generated PDF (downloadUrl points to .pdf file) description: Generated PDF (downloadUrl points to .pdf file)
@@ -2398,7 +2417,7 @@ paths:
description: | description: |
JSON string with options: JSON string with options:
- `format` (string, default "png") — Output format: png, jpg, webp, avif, tiff, gif, heic, heif - `format` (string, default "png") — Output format: png, jpg, webp, avif, tiff, gif, heic, heif
- `dpi` (number 36-1200, default 150) — Resolution in dots per inch - `dpi` (number 36-2400, default 150) — Resolution in dots per inch
- `quality` (number 1-100, default 85) — Output quality - `quality` (number 1-100, default 85) — Output quality
- `colorMode` (string, default "color") — One of: color, grayscale, bw - `colorMode` (string, default "color") — One of: color, grayscale, bw
- `pages` (string, default "all") — Page selection, e.g. "all", "1-3", "1,3,5" - `pages` (string, default "all") — Page selection, e.g. "all", "1-3", "1,3,5"
@@ -2557,8 +2576,12 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `columns` (number 1-10, default 2) — Number of columns - `columns` (number 1-100, default 3) — Number of columns
- `rows` (number 1-10, default 2) — Number of rows - `rows` (number 1-100, default 3) — Number of rows
- `tileWidth` (number, min 10, optional) — Fixed tile width in pixels
- `tileHeight` (number, min 10, optional) — Fixed tile height in pixels
- `outputFormat` (string, default "original") — One of: original, png, jpg, webp, avif
- `quality` (number 1-100, default 90) — Output quality
responses: responses:
"200": "200":
description: ZIP archive with results description: ZIP archive with results
@@ -2605,7 +2628,7 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `pattern` (string 1-200, default "image-{{index}}") — Naming pattern template - `pattern` (string 1-1000, default "image-{{index}}") — Naming pattern template
- `startIndex` (number, default 1) — Starting index number - `startIndex` (number, default 1) — Starting index number
responses: responses:
"200": "200":
@@ -2835,6 +2858,20 @@ paths:
type: string type: string
format: binary format: binary
description: Mask image (white areas will be erased) description: Mask image (white areas will be erased)
format:
type: string
enum: [png, jpg, jpeg, webp, tiff, gif, avif, heic, heif]
default: png
description: Output format
quality:
type: integer
minimum: 1
maximum: 100
default: 95
description: Output quality
clientJobId:
type: string
description: Client-provided job ID for SSE progress tracking
responses: responses:
"200": "200":
description: Processed image description: Processed image
@@ -2880,9 +2917,14 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `layout` (string, default "2x2") — One of: 2x2, 3x3, 1x3, 2x1, 3x1, 1x2 - `templateId` (string, required) — Template ID defining the grid layout
- `gap` (number 0-50, default 4) — Gap between images in pixels - `cells` (array, optional) — Per-cell configuration overrides
- `backgroundColor` (hex string, default "#FFFFFF") — Background fill color - `gap` (number 0-500, default 8) — Gap between images in pixels
- `cornerRadius` (number 0-500, default 0) — Corner rounding radius
- `backgroundColor` (string, default "#FFFFFF") — Background fill color
- `aspectRatio` (string, default "free") — Aspect ratio constraint
- `outputFormat` (string, default "png") — One of: png, jpeg, webp, avif
- `quality` (number 1-100, default 90) — Output quality
responses: responses:
"200": "200":
description: Processed image description: Processed image
@@ -2929,14 +2971,14 @@ paths:
description: | description: |
JSON string with options: JSON string with options:
- `direction` (string, default "horizontal") — One of: horizontal, vertical, grid - `direction` (string, default "horizontal") — One of: horizontal, vertical, grid
- `gridColumns` (integer 2-10, default 2) — Columns when direction is grid - `gridColumns` (integer 2-100, default 2) — Columns when direction is grid
- `resizeMode` (string, default "fit") — One of: fit, original, stretch, crop - `resizeMode` (string, default "fit") — One of: fit, original, stretch, crop
- `alignment` (string, default "center") — One of: start, center, end - `alignment` (string, default "center") — One of: start, center, end
- `gap` (number 0-200, default 0) — Gap between images in pixels - `gap` (number 0-1000, default 0) — Gap between images in pixels
- `border` (number 0-50, default 0) — Border width in pixels - `border` (number 0-500, default 0) — Border width in pixels
- `cornerRadius` (number 0-50, default 0) — Corner radius in pixels - `cornerRadius` (number 0-500, default 0) — Corner radius in pixels
- `backgroundColor` (string, default "#FFFFFF") — Hex color for background and gap fill - `backgroundColor` (hex string, default "#FFFFFF") — Hex color for background and gap fill
- `format` (string, default "png") — One of: png, jpeg, webp - `format` (string, default "png") — One of: png, jpeg, webp, avif
- `quality` (number 1-100, default 90) — Output quality - `quality` (number 1-100, default 90) — Output quality
responses: responses:
"200": "200":
@@ -2982,7 +3024,15 @@ paths:
description: | description: |
JSON string with options: JSON string with options:
- `model` (string, optional) — AI model name - `model` (string, optional) — AI model name
- `backgroundType` (string, optional) — One of: transparent, color, gradient, blur, image
- `backgroundColor` (string, optional) — Hex color to replace removed background with - `backgroundColor` (string, optional) — Hex color to replace removed background with
- `gradientColor1` (string, optional) — First gradient color
- `gradientColor2` (string, optional) — Second gradient color
- `gradientAngle` (number, optional) — Gradient angle in degrees
- `blurEnabled` (boolean, optional) — Enable background blur effect
- `blurIntensity` (number 0-100, optional) — Blur strength
- `shadowEnabled` (boolean, optional) — Enable drop shadow
- `shadowOpacity` (number 0-100, optional) — Shadow opacity
responses: responses:
"200": "200":
description: Processed image description: Processed image
@@ -3090,6 +3140,11 @@ paths:
description: | description: |
JSON string with options: JSON string with options:
- `scale` (number, default 2) — Upscale factor - `scale` (number, default 2) — Upscale factor
- `model` (string, default "auto") — AI model name
- `faceEnhance` (boolean, default false) — Enable face enhancement
- `denoise` (number, default 0) — Denoise strength
- `format` (string, default "png") — Output format
- `quality` (number, default 95) — Output quality (1-100)
responses: responses:
"200": "200":
description: Processed image description: Processed image
@@ -3133,7 +3188,7 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `blurRadius` (number, default 30) — Blur strength radius - `blurRadius` (number 1-100, default 30) — Blur strength radius
- `sensitivity` (number 0-1, default 0.5) — Face detection sensitivity - `sensitivity` (number 0-1, default 0.5) — Face detection sensitivity
responses: responses:
"200": "200":
@@ -3178,8 +3233,10 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `engine` (string, default "tesseract") — One of: tesseract, paddleocr - `quality` (string, default "balanced") — One of: fast, balanced, best
- `language` (string, default "en") — One of: en, de, fr, es, zh, ja, ko - `language` (string, default "auto") — One of: auto, en, de, fr, es, zh, ja, ko
- `enhance` (boolean, default true) — Pre-process image for better recognition
- `engine` (string, optional) — One of: tesseract, paddleocr (backward compat)
responses: responses:
"200": "200":
description: Extracted text description: Extracted text
@@ -3332,6 +3389,11 @@ paths:
type: string type: string
format: binary format: binary
description: Image file containing barcode or QR code description: Image file containing barcode or QR code
settings:
type: string
description: |
JSON string with options:
- `tryHarder` (boolean, default true) — Enable more aggressive barcode detection
responses: responses:
"200": "200":
description: Decoded barcode data description: Decoded barcode data
@@ -3378,6 +3440,11 @@ paths:
type: string type: string
format: binary format: binary
description: Multiple image files to check for duplicates description: Multiple image files to check for duplicates
settings:
type: string
description: |
JSON string with options:
- `threshold` (number 0-20, default 8) — Hamming distance threshold for duplicate detection
responses: responses:
"200": "200":
description: Duplicate groups description: Duplicate groups
@@ -3432,7 +3499,7 @@ paths:
size: size:
type: integer type: integer
minimum: 100 minimum: 100
maximum: 2000 maximum: 10000
default: 400 default: 400
description: Image size in pixels description: Image size in pixels
errorCorrection: errorCorrection:
@@ -3542,6 +3609,8 @@ paths:
type: string type: string
description: | description: |
JSON string with options: JSON string with options:
- `title` (string) — Image title
- `author` (string) — Author name
- `artist` (string) — Artist or author name - `artist` (string) — Artist or author name
- `copyright` (string) — Copyright notice - `copyright` (string) — Copyright notice
- `imageDescription` (string) — Image description - `imageDescription` (string) — Image description
+1 -1
View File
@@ -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 30+ tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.", "> Self-hosted image processing API with 47 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
); );
lines.push(""); lines.push("");
lines.push("## Docs"); lines.push("## Docs");
+2 -2
View File
@@ -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. 45+ tools, local AI, pipelines, REST API.", "Documentation for SnapOtter - A Self Hosted Image Manipulator. 47 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 45+ 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 47 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.",
}, },
+1 -1
View File
@@ -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: 45+ tools. Local AI. No cloud. Your images stay on your machine. tagline: 47 tools. Local AI. No cloud. Your images stay on your machine.
actions: actions:
- theme: brand - theme: brand
text: Get started text: Get started
+3 -3
View File
@@ -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:
"40+ image processing tools with local AI. Runs 100% offline. No data leaves your network. Open source and free forever.", "47 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:
"40+ image processing tools with local AI. Runs 100% offline. No data leaves your network.", "47 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:
"40+ image processing tools with local AI. Runs 100% offline. No data leaves your network.", "47 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
images: ["/og-image.svg"], images: ["/og-image.svg"],
}, },
}; };
+8 -1
View File
@@ -33,6 +33,7 @@ import {
Pipette, Pipette,
QrCode, QrCode,
RotateCw, RotateCw,
Scaling,
Scan, Scan,
ScanFace, ScanFace,
ScanLine, ScanLine,
@@ -230,6 +231,12 @@ const tools: { name: string; description: string; category: string; icon: Lucide
category: "ai", category: "ai",
icon: UserCheck, icon: UserCheck,
}, },
{
name: "Content-Aware Resize",
description: "Seam carving resize that preserves important content",
category: "ai",
icon: Scaling,
},
// Watermark & Overlay // Watermark & Overlay
{ {
name: "Text Watermark", name: "Text Watermark",
@@ -382,7 +389,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">
40+ tools. Zero cloud dependency. 47 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.
+1 -1
View File
@@ -7,7 +7,7 @@ const freePlan = {
price: "Free", price: "Free",
subtitle: "For everyone. Forever.", subtitle: "For everyone. Forever.",
features: [ features: [
"All 40+ image processing tools", "All 47 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",
@@ -11,7 +11,7 @@ const phrases = [
"No limits. No hidden caps.", "No limits. No hidden caps.",
"Works fully offline.", "Works fully offline.",
"Unlimited batch processing.", "Unlimited batch processing.",
"40+ image tools.", "47 image tools.",
"14 AI models. Your hardware.", "14 AI models. Your hardware.",
"Lightning fast. Built on Sharp.", "Lightning fast. Built on Sharp.",
"Air-gapped ready.", "Air-gapped ready.",
@@ -2517,7 +2517,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 30+ tools. Resize, compress, A self-hosted, privacy-first image processing suite with 47 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">
+1
View File
@@ -149,6 +149,7 @@ RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $(
tini \ tini \
imagemagick \ imagemagick \
libraw-dev \ libraw-dev \
libopenexr-dev \
potrace \ potrace \
curl \ curl \
gosu \ gosu \
+8
View File
@@ -233,6 +233,14 @@ export const TOOLS: Tool[] = [
icon: "UserCheck", icon: "UserCheck",
route: "/passport-photo", route: "/passport-photo",
}, },
{
id: "content-aware-resize",
name: "Content-Aware Resize",
description: "Seam carving resize that preserves important content with face protection",
category: "ai",
icon: "Scaling",
route: "/content-aware-resize",
},
// Watermark & Overlay // Watermark & Overlay
{ {
id: "watermark-text", id: "watermark-text",
+1 -1
View File
@@ -193,7 +193,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 30+ tools.", "SnapOtter is a self-hosted, privacy-first image processing suite with 47 tools.",
aboutLinks: "Links", aboutLinks: "Links",
github: "GitHub", github: "GitHub",
documentation: "Documentation", documentation: "Documentation",
+1 -2
View File
@@ -18,8 +18,7 @@ export type ToolCategory =
| "watermark" | "watermark"
| "utilities" | "utilities"
| "layout" | "layout"
| "format" | "format";
| "automation";
export interface CategoryInfo { export interface CategoryInfo {
id: ToolCategory; id: ToolCategory;
+17 -14
View File
@@ -10,16 +10,16 @@ test.describe("Automate Page", () => {
*/ */
async function gotoAutomate(page: import("@playwright/test").Page) { async function gotoAutomate(page: import("@playwright/test").Page) {
const heading = page.getByRole("heading", { const heading = page.getByRole("heading", {
name: /automate/i, name: /pipeline builder|automate/i,
}); });
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
if (attempt === 0) { if (attempt === 0) {
await page.goto("/automate", { waitUntil: "networkidle" }); await page.goto("/automate", { waitUntil: "load" });
} else { } else {
// On retry, wait then reload // On retry, wait then reload
await page.waitForTimeout(500); await page.waitForTimeout(500);
await page.goto("/automate", { waitUntil: "networkidle" }); await page.goto("/automate", { waitUntil: "load" });
} }
try { try {
@@ -31,7 +31,7 @@ test.describe("Automate Page", () => {
} }
// Final attempt - let it throw if it fails // Final attempt - let it throw if it fails
await page.goto("/automate", { waitUntil: "networkidle" }); await page.goto("/automate", { waitUntil: "load" });
await expect(heading).toBeVisible({ timeout: 10_000 }); await expect(heading).toBeVisible({ timeout: 10_000 });
} }
@@ -48,8 +48,6 @@ test.describe("Automate Page", () => {
name: string, name: string,
expectedCount: number, expectedCount: number,
) { ) {
await page.getByRole("button", { name: /add step/i }).click();
await expect(page.getByText("Add a step")).toBeVisible();
await page.getByPlaceholder("Search tools...").fill(name); await page.getByPlaceholder("Search tools...").fill(name);
await page await page
.getByRole("button", { name: new RegExp(name, "i") }) .getByRole("button", { name: new RegExp(name, "i") })
@@ -74,12 +72,16 @@ test.describe("Automate Page", () => {
test("automate page renders pipeline builder", async ({ loggedInPage: page }) => { test("automate page renders pipeline builder", async ({ loggedInPage: page }) => {
await gotoAutomate(page); await gotoAutomate(page);
await expect(page.getByText(/chain tools into a pipeline/i).first()).toBeVisible(); await expect(
page.getByText(/add tools from the palette|chain tools into a pipeline/i).first(),
).toBeVisible();
}); });
test("shows empty state message when no steps", async ({ loggedInPage: page }) => { test("shows empty state message when no steps", async ({ loggedInPage: page }) => {
await gotoAutomate(page); await gotoAutomate(page);
await expect(page.getByText(/add steps to build your pipeline/i)).toBeVisible(); await expect(
page.getByText(/add tools from the palette|add steps to build your pipeline/i),
).toBeVisible();
}); });
test("shows dropzone when no file uploaded", async ({ loggedInPage: page }) => { test("shows dropzone when no file uploaded", async ({ loggedInPage: page }) => {
@@ -89,9 +91,9 @@ test.describe("Automate Page", () => {
await expect(page.getByRole("button", { name: /upload from computer/i })).toBeVisible(); await expect(page.getByRole("button", { name: /upload from computer/i })).toBeVisible();
}); });
test("has Add Step button", async ({ loggedInPage: page }) => { test("has tool palette search", async ({ loggedInPage: page }) => {
await gotoAutomate(page); await gotoAutomate(page);
await expect(page.getByRole("button", { name: /add step/i })).toBeVisible(); await expect(page.getByPlaceholder("Search tools...")).toBeVisible();
}); });
test("has Process button (disabled when no steps or file)", async ({ loggedInPage: page }) => { test("has Process button (disabled when no steps or file)", async ({ loggedInPage: page }) => {
@@ -116,17 +118,18 @@ test.describe("Automate Page", () => {
// --- Add Step --- // --- Add Step ---
test("clicking Add Step opens tool picker", async ({ loggedInPage: page }) => { test("tool palette is visible with search", async ({ loggedInPage: page }) => {
await gotoAutomate(page); await gotoAutomate(page);
await page.getByRole("button", { name: /add step/i }).click(); await expect(page.getByPlaceholder("Search tools...")).toBeVisible();
await expect(page.getByText("Add a step")).toBeVisible();
}); });
test("selecting a tool from picker adds a step", async ({ loggedInPage: page }) => { test("selecting a tool from picker adds a step", async ({ loggedInPage: page }) => {
await gotoAutomate(page); await gotoAutomate(page);
await addToolStep(page, "Resize", 1); await addToolStep(page, "Resize", 1);
// Verify empty state is gone // Verify empty state is gone
await expect(page.getByText(/add steps to build your pipeline/i)).not.toBeVisible(); await expect(
page.getByText(/add tools from the palette|add steps to build your pipeline/i),
).not.toBeVisible();
}); });
test("can add multiple steps", async ({ loggedInPage: page }) => { test("can add multiple steps", async ({ loggedInPage: page }) => {
+9 -9
View File
@@ -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("40+ tools. Zero cloud dependency.")).toBeDefined(); expect(screen.getByText("47 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 46 of 46 tools/); expect(text).toMatch(/Showing 47 of 47 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 (46)")).toBeDefined(); expect(screen.getByText((_, el) => el?.textContent === "All (47)")).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 46 tools/); expect(text).toMatch(/Showing \d+ of 47 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 13 of 46 tools/); expect(text).toMatch(/Showing 14 of 47 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 46 tools/); expect(text).toMatch(/Showing 0 of 47 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 13 of 46 tools/); expect(getCountText()).toMatch(/Showing 14 of 47 tools/);
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (46)")); fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (47)"));
expect(getCountText()).toMatch(/Showing 46 of 46 tools/); expect(getCountText()).toMatch(/Showing 47 of 47 tools/);
}); });
it("renders each tool with name and description", () => { it("renders each tool with name and description", () => {
+1 -1
View File
@@ -159,7 +159,7 @@ describe("Pricing", () => {
it("renders free plan features", () => { it("renders free plan features", () => {
render(<Pricing />); render(<Pricing />);
expect(screen.getByText("All 40+ image processing tools")).toBeDefined(); expect(screen.getByText("All 47 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("14 local AI models included")).toBeDefined();
expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined(); expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined();