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