diff --git a/README.md b/README.md index c3a369f1..145b053a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## 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 - **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` diff --git a/apps/api/src/lib/format-decoders.ts b/apps/api/src/lib/format-decoders.ts index da0d5230..a45d8730 100644 --- a/apps/api/src/lib/format-decoders.ts +++ b/apps/api/src/lib/format-decoders.ts @@ -8,7 +8,7 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); /** 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 { return CLI_DECODED_FORMATS.has(format); @@ -32,6 +32,10 @@ export async function decodeToSharpCompat(buffer: Buffer, format: string): Promi return decodeExr(buffer); case "hdr": return decodeHdr(buffer); + case "bmp": + return decodeBmp(buffer); + case "jxl": + return decodeJxl(buffer); default: return buffer; } @@ -192,3 +196,39 @@ async function decodeHdr(buffer: Buffer): Promise { await rm(outputPath, { force: true }).catch(() => {}); } } + +async function decodeBmp(buffer: Buffer): Promise { + 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 { + 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(() => {}); + } +} diff --git a/apps/api/src/openapi.yaml b/apps/api/src/openapi.yaml index 7fb2ba09..3318f94b 100644 --- a/apps/api/src/openapi.yaml +++ b/apps/api/src/openapi.yaml @@ -3,7 +3,7 @@ info: title: SnapOtter API version: 1.15.9 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 @@ -344,10 +344,11 @@ paths: type: string description: | JSON string with options: - - `left` (integer, required) — Left offset in pixels - - `top` (integer, required) — Top offset in pixels - - `width` (integer, required) — Width of crop region in pixels - - `height` (integer, required) — Height of crop region in pixels + - `left` (number, required) — Left offset in pixels (min 0) + - `top` (number, required) — Top offset in pixels (min 0) + - `width` (number, required) — Width of crop region in pixels + - `height` (number, required) — Height of crop region in pixels + - `unit` (string, optional) — One of: px, percent responses: "200": description: Processed image @@ -437,7 +438,7 @@ paths: type: string description: | 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 responses: "200": @@ -576,12 +577,17 @@ paths: type: string description: | 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 - - `cornerRadius` (number 0-500, default 0) — Corner rounding radius - `padding` (number 0-200, default 0) — Inner padding in pixels - - `shadowBlur` (number 0-50, default 0) — Drop shadow blur radius - - `shadowColor` (hex string, default "#00000080") — Drop shadow color + - `paddingColor` (hex string, default "#FFFFFF") — Padding fill 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: "200": description: Processed image @@ -1903,7 +1909,7 @@ paths: description: | JSON string with options: - `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 - `opacity` (number 0-100, default 50) — Opacity percentage - `position` (string, default "center") — One of: center, top-left, top-right, bottom-left, bottom-right, tiled @@ -2048,10 +2054,23 @@ paths: type: string description: | JSON string with options: - - `width` (number 1-4096, optional) — Target width in pixels - - `height` (number 1-4096, optional) — Target height in pixels - - `extractFrame` (number, optional) — Extract specific frame index - - `optimize` (boolean, default false) — Optimize GIF file size + - `mode` (string, default "resize") — One of: resize, optimize, speed, reverse, extract, rotate + - `width` (number 1-16384, optional) — Target width in pixels (resize mode) + - `height` (number 1-16384, optional) — Target height in pixels (resize mode) + - `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: "200": description: Processed image @@ -2148,11 +2167,11 @@ paths: type: string description: | JSON string with options: - - `mode` (string) — "subject" (default), "face", or "trim" - - `strategy` (string) — "attention" (default) or "entropy" (subject mode) - - `width` (integer) — Target width in pixels (default 1080) - - `height` (integer) — Target height in pixels (default 1080) - - `padding` (integer 0-50) — Padding percentage around focus area + - `mode` (string, default "subject") — One of: subject, face, trim, attention (alias for subject), content (alias for trim) + - `strategy` (string, default "attention") — One of: attention, entropy (subject mode) + - `width` (integer, optional) — Target width in pixels + - `height` (integer, optional) — Target height in pixels + - `padding` (integer 0-50, default 0) — Padding percentage around focus area - `facePreset` (string) — "closeup", "head-shoulders", "upper-body", "half-body" (face mode) - `sensitivity` (number 0-1) — Face detection sensitivity (face mode) - `threshold` (integer 0-255) — Trim tolerance (trim mode) @@ -2205,9 +2224,9 @@ paths: JSON string with options: - `colorMode` (string, default "bw") - One of: bw, color - `threshold` (number 0-255, default 128) - B&W binarization threshold - - `colorPrecision` (number 1-8, default 6) - Color bits per channel - - `layerDifference` (number 1-64, default 6) - Color gradient step - - `filterSpeckle` (number 1-128, default 4) - Noise filter size + - `colorPrecision` (number 1-16, default 6) - Color bits per channel + - `layerDifference` (number 1-128, default 6) - Color gradient step + - `filterSpeckle` (number 1-256, default 4) - Noise filter size - `pathMode` (string, default "spline") - One of: none, polygon, spline - `cornerThreshold` (number 0-180, default 60) - Corner detection angle - `invert` (boolean, default false) - Invert colors before tracing @@ -2254,9 +2273,9 @@ paths: type: string description: | JSON string with options: - - `width` (number 1-16384, optional) — Output width in pixels - - `height` (number 1-16384, optional) — Output height in pixels - - `dpi` (number 36-1200, default 300) — Render density for SVG rasterization + - `width` (number 1-65536, optional) — Output width in pixels + - `height` (number 1-65536, optional) — Output height in pixels + - `dpi` (number 36-2400, default 300) — Render density for SVG rasterization - `quality` (number 1-100, default 90) — Output quality for lossy formats - `backgroundColor` (hex string, default "#00000000") — Background color - `outputFormat` (string, default "png") — One of: png, jpg, webp, avif, tiff, gif, heif @@ -2353,7 +2372,7 @@ paths: JSON string with options: - `pageSize` (string, default "A4") — One of: A4, Letter, A3, A5 - `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: "200": description: Generated PDF (downloadUrl points to .pdf file) @@ -2398,7 +2417,7 @@ paths: description: | JSON string with options: - `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 - `colorMode` (string, default "color") — One of: color, grayscale, bw - `pages` (string, default "all") — Page selection, e.g. "all", "1-3", "1,3,5" @@ -2557,8 +2576,12 @@ paths: type: string description: | JSON string with options: - - `columns` (number 1-10, default 2) — Number of columns - - `rows` (number 1-10, default 2) — Number of rows + - `columns` (number 1-100, default 3) — Number of columns + - `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: "200": description: ZIP archive with results @@ -2605,7 +2628,7 @@ paths: type: string description: | 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 responses: "200": @@ -2835,6 +2858,20 @@ paths: type: string format: binary 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: "200": description: Processed image @@ -2880,9 +2917,14 @@ paths: type: string description: | JSON string with options: - - `layout` (string, default "2x2") — One of: 2x2, 3x3, 1x3, 2x1, 3x1, 1x2 - - `gap` (number 0-50, default 4) — Gap between images in pixels - - `backgroundColor` (hex string, default "#FFFFFF") — Background fill color + - `templateId` (string, required) — Template ID defining the grid layout + - `cells` (array, optional) — Per-cell configuration overrides + - `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: "200": description: Processed image @@ -2929,14 +2971,14 @@ paths: description: | JSON string with options: - `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 - `alignment` (string, default "center") — One of: start, center, end - - `gap` (number 0-200, default 0) — Gap between images in pixels - - `border` (number 0-50, default 0) — Border width in pixels - - `cornerRadius` (number 0-50, default 0) — Corner radius in pixels - - `backgroundColor` (string, default "#FFFFFF") — Hex color for background and gap fill - - `format` (string, default "png") — One of: png, jpeg, webp + - `gap` (number 0-1000, default 0) — Gap between images in pixels + - `border` (number 0-500, default 0) — Border width in pixels + - `cornerRadius` (number 0-500, default 0) — Corner radius in pixels + - `backgroundColor` (hex string, default "#FFFFFF") — Hex color for background and gap fill + - `format` (string, default "png") — One of: png, jpeg, webp, avif - `quality` (number 1-100, default 90) — Output quality responses: "200": @@ -2982,7 +3024,15 @@ paths: description: | JSON string with options: - `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 + - `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: "200": description: Processed image @@ -3090,6 +3140,11 @@ paths: description: | JSON string with options: - `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: "200": description: Processed image @@ -3133,7 +3188,7 @@ paths: type: string description: | 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 responses: "200": @@ -3178,8 +3233,10 @@ paths: type: string description: | JSON string with options: - - `engine` (string, default "tesseract") — One of: tesseract, paddleocr - - `language` (string, default "en") — One of: en, de, fr, es, zh, ja, ko + - `quality` (string, default "balanced") — One of: fast, balanced, best + - `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: "200": description: Extracted text @@ -3332,6 +3389,11 @@ paths: type: string format: binary 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: "200": description: Decoded barcode data @@ -3378,6 +3440,11 @@ paths: type: string format: binary 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: "200": description: Duplicate groups @@ -3432,7 +3499,7 @@ paths: size: type: integer minimum: 100 - maximum: 2000 + maximum: 10000 default: 400 description: Image size in pixels errorCorrection: @@ -3542,6 +3609,8 @@ paths: type: string description: | JSON string with options: + - `title` (string) — Image title + - `author` (string) — Author name - `artist` (string) — Artist or author name - `copyright` (string) — Copyright notice - `imageDescription` (string) — Image description diff --git a/apps/api/src/routes/docs.ts b/apps/api/src/routes/docs.ts index 040d31e4..601144cf 100644 --- a/apps/api/src/routes/docs.ts +++ b/apps/api/src/routes/docs.ts @@ -39,7 +39,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string { lines.push(`# ${spec.info.title}`); 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("## Docs"); diff --git a/apps/docs/.vitepress/config.mts b/apps/docs/.vitepress/config.mts index dcbfcb38..95d82160 100644 --- a/apps/docs/.vitepress/config.mts +++ b/apps/docs/.vitepress/config.mts @@ -4,7 +4,7 @@ import llmstxt from "vitepress-plugin-llms"; export default defineConfig({ title: "SnapOtter", 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: "/", appearance: { initialValue: "light" }, srcDir: ".", @@ -48,7 +48,7 @@ export default defineConfig({ `, customTemplateVariables: { 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: "Resize, compress, convert, remove backgrounds, upscale, run OCR, and more - without sending images to external services.", }, diff --git a/apps/docs/index.md b/apps/docs/index.md index 8c84d952..bd42a25f 100644 --- a/apps/docs/index.md +++ b/apps/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: "SnapOtter" 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: - theme: brand text: Get started diff --git a/apps/landing/src/app/layout.tsx b/apps/landing/src/app/layout.tsx index 9ec0a5f3..13b68176 100644 --- a/apps/landing/src/app/layout.tsx +++ b/apps/landing/src/app/layout.tsx @@ -8,12 +8,12 @@ const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito" }); export const metadata: Metadata = { title: "SnapOtter | Self-Hosted Image Processing", 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"), openGraph: { title: "SnapOtter | Self-Hosted Image Processing", 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", siteName: "SnapOtter", type: "website", @@ -30,7 +30,7 @@ export const metadata: Metadata = { card: "summary_large_image", title: "SnapOtter | Self-Hosted Image Processing", 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"], }, }; diff --git a/apps/landing/src/components/bento-grid.tsx b/apps/landing/src/components/bento-grid.tsx index a72a3fdc..44a05d9d 100644 --- a/apps/landing/src/components/bento-grid.tsx +++ b/apps/landing/src/components/bento-grid.tsx @@ -33,6 +33,7 @@ import { Pipette, QrCode, RotateCw, + Scaling, Scan, ScanFace, ScanLine, @@ -230,6 +231,12 @@ const tools: { name: string; description: string; category: string; icon: Lucide category: "ai", icon: UserCheck, }, + { + name: "Content-Aware Resize", + description: "Seam carving resize that preserves important content", + category: "ai", + icon: Scaling, + }, // Watermark & Overlay { name: "Text Watermark", @@ -382,7 +389,7 @@ export function BentoGrid() {

- 40+ tools. Zero cloud dependency. + 47 tools. Zero cloud dependency.

Search to find exactly what you need. Every tool runs 100% locally. diff --git a/apps/landing/src/components/pricing.tsx b/apps/landing/src/components/pricing.tsx index 1ff7b337..a85af7d6 100644 --- a/apps/landing/src/components/pricing.tsx +++ b/apps/landing/src/components/pricing.tsx @@ -7,7 +7,7 @@ const freePlan = { price: "Free", subtitle: "For everyone. Forever.", features: [ - "All 40+ image processing tools", + "All 47 image processing tools", "Unlimited usage, no hidden caps", "Full REST API with OpenAPI docs", "Pipeline automation", diff --git a/apps/landing/src/components/typing-cursor.tsx b/apps/landing/src/components/typing-cursor.tsx index 78b28d80..27c3ebd9 100644 --- a/apps/landing/src/components/typing-cursor.tsx +++ b/apps/landing/src/components/typing-cursor.tsx @@ -11,7 +11,7 @@ const phrases = [ "No limits. No hidden caps.", "Works fully offline.", "Unlimited batch processing.", - "40+ image tools.", + "47 image tools.", "14 AI models. Your hardware.", "Lightning fast. Built on Sharp.", "Air-gapped ready.", diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index 54b82f00..dc758244 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -2517,7 +2517,7 @@ function AboutSection() {

- 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.

diff --git a/docker/Dockerfile b/docker/Dockerfile index e260f692..b2f2d2bd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -149,6 +149,7 @@ RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $( tini \ imagemagick \ libraw-dev \ + libopenexr-dev \ potrace \ curl \ gosu \ diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 42355bef..46e214be 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -233,6 +233,14 @@ export const TOOLS: Tool[] = [ icon: "UserCheck", 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 { id: "watermark-text", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index d6be25e4..e23f9d66 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -193,7 +193,7 @@ export const en = { regenerateKey: "Regenerate", copyKey: "Copy Key", 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", github: "GitHub", documentation: "Documentation", diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index cf87fff1..be481cef 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -18,8 +18,7 @@ export type ToolCategory = | "watermark" | "utilities" | "layout" - | "format" - | "automation"; + | "format"; export interface CategoryInfo { id: ToolCategory; diff --git a/tests/e2e/automate.spec.ts b/tests/e2e/automate.spec.ts index 6865439e..9c675e8a 100644 --- a/tests/e2e/automate.spec.ts +++ b/tests/e2e/automate.spec.ts @@ -10,16 +10,16 @@ test.describe("Automate Page", () => { */ async function gotoAutomate(page: import("@playwright/test").Page) { const heading = page.getByRole("heading", { - name: /automate/i, + name: /pipeline builder|automate/i, }); for (let attempt = 0; attempt < 3; attempt++) { if (attempt === 0) { - await page.goto("/automate", { waitUntil: "networkidle" }); + await page.goto("/automate", { waitUntil: "load" }); } else { // On retry, wait then reload await page.waitForTimeout(500); - await page.goto("/automate", { waitUntil: "networkidle" }); + await page.goto("/automate", { waitUntil: "load" }); } try { @@ -31,7 +31,7 @@ test.describe("Automate Page", () => { } // 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 }); } @@ -48,8 +48,6 @@ test.describe("Automate Page", () => { name: string, 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 .getByRole("button", { name: new RegExp(name, "i") }) @@ -74,12 +72,16 @@ test.describe("Automate Page", () => { test("automate page renders pipeline builder", async ({ loggedInPage: 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 }) => { 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 }) => { @@ -89,9 +91,9 @@ test.describe("Automate Page", () => { 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 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 }) => { @@ -116,17 +118,18 @@ test.describe("Automate Page", () => { // --- 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 page.getByRole("button", { name: /add step/i }).click(); - await expect(page.getByText("Add a step")).toBeVisible(); + await expect(page.getByPlaceholder("Search tools...")).toBeVisible(); }); test("selecting a tool from picker adds a step", async ({ loggedInPage: page }) => { await gotoAutomate(page); await addToolStep(page, "Resize", 1); // 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 }) => { diff --git a/tests/unit/landing/bento-grid.test.tsx b/tests/unit/landing/bento-grid.test.tsx index a460068d..46ef06e3 100644 --- a/tests/unit/landing/bento-grid.test.tsx +++ b/tests/unit/landing/bento-grid.test.tsx @@ -25,7 +25,7 @@ function getCountText(): string { describe("BentoGrid", () => { it("renders the section heading", () => { render(); - expect(screen.getByText("40+ tools. Zero cloud dependency.")).toBeDefined(); + expect(screen.getByText("47 tools. Zero cloud dependency.")).toBeDefined(); }); it("renders the search input", () => { @@ -36,12 +36,12 @@ describe("BentoGrid", () => { it("shows all tools by default", () => { render(); 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", () => { render(); - 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(/AI Tools/)).toBeDefined(); expect(screen.getByText(/Optimization/)).toBeDefined(); @@ -53,7 +53,7 @@ describe("BentoGrid", () => { fireEvent.change(input, { target: { value: "resize" } }); expect(screen.getByText("Resize")).toBeDefined(); 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(); }); @@ -62,7 +62,7 @@ describe("BentoGrid", () => { const aiButton = screen.getByText(/AI Tools/); fireEvent.click(aiButton); 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.queryByText("Resize")).toBeNull(); }); @@ -73,7 +73,7 @@ describe("BentoGrid", () => { fireEvent.change(input, { target: { value: "xyznonexistent" } }); expect(screen.getByText("No tools found. Try a different search.")).toBeDefined(); 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", () => { @@ -89,9 +89,9 @@ describe("BentoGrid", () => { it("clicking All resets category filter", () => { render(); fireEvent.click(screen.getByText(/AI Tools/)); - expect(getCountText()).toMatch(/Showing 13 of 46 tools/); - fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (46)")); - expect(getCountText()).toMatch(/Showing 46 of 46 tools/); + expect(getCountText()).toMatch(/Showing 14 of 47 tools/); + fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (47)")); + expect(getCountText()).toMatch(/Showing 47 of 47 tools/); }); it("renders each tool with name and description", () => { diff --git a/tests/unit/landing/components.test.tsx b/tests/unit/landing/components.test.tsx index cabde36c..40f95e08 100644 --- a/tests/unit/landing/components.test.tsx +++ b/tests/unit/landing/components.test.tsx @@ -159,7 +159,7 @@ describe("Pricing", () => { it("renders free plan features", () => { render(); - 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("14 local AI models included")).toBeDefined(); expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined();