From a1e11dff74680209382f38dec28c3b9766355d54 Mon Sep 17 00:00:00 2001 From: stirling-image Date: Mon, 13 Apr 2026 16:23:07 +0800 Subject: [PATCH] feat(gif-tools): SOTA upgrade with 6 processing modes (#52) * feat(find-duplicates): upgrade to 128-bit dHash with metadata and thumbnails * feat(find-duplicates): add custom-results display mode and duplicate store * feat(find-duplicates): add results overview grid and detail comparison view * feat(find-duplicates): overhaul settings with sensitivity presets and download actions * feat(find-duplicates): update i18n description * chore: replace jsqr with zxing-wasm for barcode reading * feat(barcode-read): rewrite backend with zxing-wasm for all barcode types * feat(barcode-read): rewrite frontend with multi-file, results table, progress, export - Multi-file sequential processing with per-file progress - Structured results table with type badges and copy per-result - Copy All and Export CSV functionality - Thorough scan toggle (maps to tryHarder in zxing-wasm) - Before/after view shows annotated image with bounding boxes - Updated tool description in constants and i18n * feat(stitch): update tool name and description for redesign * feat(stitch): add grid layout, alignment, border, radius, quality, and new resize modes * feat(stitch): redesign settings UI with grid, alignment, border, radius, quality * test(stitch): add stitch to e2e tool navigation suite * feat(vectorize): redesign with dual-engine backend and preset-driven UI - Backend: potrace for B&W, VTracer (@neplex/vectorizer) for full-color vectorization - Frontend: 5 presets (logo, illustration, photo, sketch, custom) - Settings: color precision, gradient step, detail, smoothing, corner threshold, invert - Updated OpenAPI spec and i18n description * feat(border): redesign with presets, shadow, padding color, swatches - Add 8 one-click presets (Clean White, Gallery Black, Shadow, Rounded, Polaroid, Vintage, Minimal, Cinematic) - Implement proper shadow rendering with blur, offset X/Y, color, opacity - Add padding color control (was hardcoded white) - Add color swatches for quick color selection - Wrap in form for Enter key submission - Add smart validation (requires at least one effect active) - Align frontend/backend slider ranges - Organize UI with sections and collapsible shadow toggle * feat(split): overhaul image splitting with live grid overlay and tile preview - Add interactive-split display mode with SplitCanvas component - Live SVG grid overlay on uploaded image showing split boundaries - Two split modes: Grid (NxM) and Tile Size (px dimensions) - 9 grid presets (2x1, 1x2, 2x2, 3x1, 1x3, 3x3, 2x3, 3x2, 4x4) - Output format selection (original/PNG/JPG/WebP) with quality slider - Post-split tile preview thumbnails with individual download - Download All as ZIP button - HEIC/HEIF preview with loading spinner - Backend: tile-size mode, output format conversion, quality control - Zustand store for split state management * feat(split): rewrite backend and frontend settings Backend: tile-size mode, output format conversion, quality control. Frontend: split modes, presets, format selector, tile preview grid. * feat(border): add live CSS preview and remove before/after slider - Add imageWrapperStyle prop to ImageViewer for live border preview - Add onImageStyle callback through tool-page to settings components - Change border displayMode to no-comparison (no slider) - BorderControls sends live CSS styles (border, padding, radius, shadow) - Preview updates instantly as user adjusts sliders or clicks presets * fix: repair i18n file corrupted by formatter during merge conflict resolution * feat(border): enable live CSS preview in right pane as settings change * fix(border): keep CSS preview visible after processing for WYSIWYG consistency * chore(gif-tools): scaffold for SOTA upgrade - Add animated GIF test fixture (3 frames, 100x100) - Update tool description to reflect new capabilities - Add fflate dependency to API for ZIP creation * feat(gif-tools): rewrite backend with 6 processing modes Modes: resize (with percentage), optimize (colors/dither/effort), speed (delay manipulation), reverse (frame reorder), extract (single/range/all with ZIP), rotate (90/180/270 + flip). Adds /api/v1/tools/gif-tools/info metadata endpoint. * test(gif-tools): add integration tests for all 6 modes Tests metadata endpoint, resize (pixel + percentage), optimize, speed, reverse, extract (single/range/all), and rotate (angle + flip). Fix animated.gif fixture to be a real 3-frame animation (was a single 100x300 frame). Fix reverse and rotate modes to process frames individually and reassemble via GIF binary concatenation, since Sharp 0.33.x loses page-height metadata when reconstructing from raw pixel data. * feat(gif-tools): rewrite frontend with tabbed 6-mode UI - useGifInfo hook for metadata (frame count, dimensions, duration) - Info bar showing GIF properties - 3x2 mode grid: Resize, Optimize, Speed, Reverse, Extract, Rotate - Animation modes disabled for static images - Loop control (infinite/once/custom) - Batch processing support * test(gif-tools): add to representative tools in e2e suite --------- Co-authored-by: Siddharth Kumar Sah --- apps/api/package.json | 6 +- apps/api/src/openapi.yaml | 13 +- apps/api/src/routes/tools/barcode-read.ts | 186 ++++- apps/api/src/routes/tools/border.ts | 189 +++-- apps/api/src/routes/tools/find-duplicates.ts | 181 ++++- apps/api/src/routes/tools/gif-tools.ts | 310 +++++++- apps/api/src/routes/tools/split.ts | 91 ++- apps/api/src/routes/tools/stitch.ts | 296 ++++++-- apps/api/src/routes/tools/vectorize.ts | 61 +- apps/docs/.vitepress/config.mts | 3 +- apps/docs/.vitepress/theme/GitHubStars.vue | 2 +- apps/docs/.vitepress/theme/Layout.vue | 2 +- apps/web/package.json | 1 + .../src/components/common/image-viewer.tsx | 29 + .../tools/barcode-read-settings.tsx | 407 ++++++++-- .../src/components/tools/border-settings.tsx | 589 +++++++++++++-- .../src/components/tools/collage-preview.tsx | 437 +++++++++++ .../tools/find-duplicates-results.tsx | 278 +++++++ .../tools/find-duplicates-settings.tsx | 283 +++++-- .../components/tools/gif-tools-settings.tsx | 692 ++++++++++++++++-- .../web/src/components/tools/split-canvas.tsx | 269 +++++++ .../src/components/tools/split-settings.tsx | 448 ++++++++++-- .../src/components/tools/stitch-settings.tsx | 258 +++++-- .../components/tools/vectorize-settings.tsx | 344 +++++++-- apps/web/src/hooks/use-gif-info.ts | 68 ++ apps/web/src/lib/collage-templates.ts | 361 +++++++++ apps/web/src/lib/tool-registry.tsx | 29 +- apps/web/src/pages/tool-page.tsx | 51 +- apps/web/src/stores/collage-store.ts | 263 +++++++ apps/web/src/stores/duplicate-store.ts | 63 ++ apps/web/src/stores/split-store.ts | 135 ++++ collage-1-1-aspect.png | Bin 0 -> 190006 bytes collage-canvas-section.png | Bin 0 -> 155384 bytes collage-initial.png | Bin 0 -> 40525 bytes collage-result.png | Bin 0 -> 193274 bytes collage-styled.png | Bin 0 -> 154721 bytes collage-three-columns.png | Bin 0 -> 151815 bytes collage-with-images.png | Bin 0 -> 170018 bytes packages/shared/src/constants.ts | 13 +- packages/shared/src/i18n/en.ts | 23 +- pdf-redesign-initial.png | Bin 0 -> 62157 bytes pdf-to-image-converted.png | Bin 0 -> 59915 bytes pdf-to-image-grayscale-custom-dpi.png | Bin 0 -> 59470 bytes pdf-to-image-initial.png | Bin 0 -> 48576 bytes pdf-to-image-jpeg-grayscale-result.png | Bin 0 -> 61356 bytes pdf-to-image-jpeg-quality.png | Bin 0 -> 61057 bytes pdf-to-image-page-deselected.png | Bin 0 -> 57529 bytes pdf-to-image-with-thumbnails.png | Bin 0 -> 56080 bytes pnpm-lock.yaml | 205 +++++- qr-dots-style.png | Bin 0 -> 49792 bytes qr-email-tab.png | Bin 0 -> 66573 bytes qr-final-complete.png | Bin 0 -> 47641 bytes qr-final-initial.png | Bin 0 -> 51215 bytes qr-gradient.png | Bin 0 -> 93317 bytes qr-initial-page.png | Bin 0 -> 51055 bytes qr-sms-tab.png | Bin 0 -> 69017 bytes qr-transparent-classy.png | Bin 0 -> 67877 bytes qr-url-filled.png | Bin 0 -> 48430 bytes qr-vcard-tab.png | Bin 0 -> 91005 bytes qr-wifi-filled.png | Bin 0 -> 108614 bytes qr-wifi-tab.png | Bin 0 -> 98589 bytes tests/e2e/tools-all.spec.ts | 2 + tests/fixtures/animated.gif | Bin 0 -> 615 bytes tests/integration/api.test.ts | 125 ++++ tests/integration/gif-tools.test.ts | 354 +++++++++ 65 files changed, 6293 insertions(+), 774 deletions(-) create mode 100644 apps/web/src/components/tools/collage-preview.tsx create mode 100644 apps/web/src/components/tools/find-duplicates-results.tsx create mode 100644 apps/web/src/components/tools/split-canvas.tsx create mode 100644 apps/web/src/hooks/use-gif-info.ts create mode 100644 apps/web/src/lib/collage-templates.ts create mode 100644 apps/web/src/stores/collage-store.ts create mode 100644 apps/web/src/stores/duplicate-store.ts create mode 100644 apps/web/src/stores/split-store.ts create mode 100644 collage-1-1-aspect.png create mode 100644 collage-canvas-section.png create mode 100644 collage-initial.png create mode 100644 collage-result.png create mode 100644 collage-styled.png create mode 100644 collage-three-columns.png create mode 100644 collage-with-images.png create mode 100644 pdf-redesign-initial.png create mode 100644 pdf-to-image-converted.png create mode 100644 pdf-to-image-grayscale-custom-dpi.png create mode 100644 pdf-to-image-initial.png create mode 100644 pdf-to-image-jpeg-grayscale-result.png create mode 100644 pdf-to-image-jpeg-quality.png create mode 100644 pdf-to-image-page-deselected.png create mode 100644 pdf-to-image-with-thumbnails.png create mode 100644 qr-dots-style.png create mode 100644 qr-email-tab.png create mode 100644 qr-final-complete.png create mode 100644 qr-final-initial.png create mode 100644 qr-gradient.png create mode 100644 qr-initial-page.png create mode 100644 qr-sms-tab.png create mode 100644 qr-transparent-classy.png create mode 100644 qr-url-filled.png create mode 100644 qr-vcard-tab.png create mode 100644 qr-wifi-filled.png create mode 100644 qr-wifi-tab.png create mode 100644 tests/fixtures/animated.gif create mode 100644 tests/integration/gif-tools.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index db33daf3..401785ff 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,6 +16,7 @@ "@fastify/multipart": "^9.0.0", "@fastify/rate-limit": "^10.2.0", "@fastify/static": "^8.1.0", + "@neplex/vectorizer": "^0.0.5", "@scalar/fastify-api-reference": "^1.49.5", "@stirling-image/ai": "workspace:*", "@stirling-image/image-engine": "workspace:*", @@ -26,8 +27,8 @@ "drizzle-orm": "^0.38.0", "exif-reader": "^2.0.3", "fastify": "^5.2.0", + "fflate": "^0.8.2", "js-yaml": "^4.1.1", - "jsqr": "^1.4.0", "mupdf": "^1.27.0", "p-queue": "^9.1.0", "pdfkit": "^0.18.0", @@ -36,7 +37,8 @@ "qrcode": "^1.5.4", "sharp": "^0.33.0", "tsx": "^4.19.0", - "zod": "^3.24.0" + "zod": "^3.24.0", + "zxing-wasm": "^3.0.2" }, "devDependencies": { "@types/archiver": "^7.0.0", diff --git a/apps/api/src/openapi.yaml b/apps/api/src/openapi.yaml index 0fd36cd6..ee7c23bd 100644 --- a/apps/api/src/openapi.yaml +++ b/apps/api/src/openapi.yaml @@ -831,7 +831,7 @@ paths: post: tags: [Tools] summary: Image to SVG - description: Convert a raster image to SVG vector format. + description: Convert a raster image to SVG vector format using potrace (B&W) or VTracer (color). security: - bearerAuth: [] requestBody: @@ -850,9 +850,14 @@ paths: type: string description: | JSON string with options: - - `colorMode` (string, default "bw") — One of: bw, color - - `threshold` (number 0-255, default 128) — Binarization threshold - - `detail` (string, default "medium") — One of: low, medium, high + - `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 + - `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 responses: "200": description: Processed image (downloadUrl points to .svg file) diff --git a/apps/api/src/routes/tools/barcode-read.ts b/apps/api/src/routes/tools/barcode-read.ts index 6232de2a..71fab569 100644 --- a/apps/api/src/routes/tools/barcode-read.ts +++ b/apps/api/src/routes/tools/barcode-read.ts @@ -1,18 +1,83 @@ -import { basename } from "node:path"; +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import jsQR from "jsqr"; import sharp from "sharp"; +import { readBarcodes } from "zxing-wasm/reader"; +import { autoOrient } from "../../lib/auto-orient.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; +import { createWorkspace } from "../../lib/workspace.js"; /** - * Read QR codes and barcodes from uploaded images. + * Color palette for bounding-box overlays. + * Semi-transparent fills paired with solid strokes. + */ +const BOX_COLORS = [ + { fill: "rgba(59,130,246,0.18)", stroke: "rgba(59,130,246,0.9)" }, // blue + { fill: "rgba(34,197,94,0.18)", stroke: "rgba(34,197,94,0.9)" }, // green + { fill: "rgba(245,158,11,0.18)", stroke: "rgba(245,158,11,0.9)" }, // amber + { fill: "rgba(239,68,68,0.18)", stroke: "rgba(239,68,68,0.9)" }, // red + { fill: "rgba(168,85,247,0.18)", stroke: "rgba(168,85,247,0.9)" }, // purple + { fill: "rgba(236,72,153,0.18)", stroke: "rgba(236,72,153,0.9)" }, // pink +]; + +/** + * Build an SVG overlay with numbered polygon bounding boxes for each barcode. + */ +function buildOverlaySvg( + width: number, + height: number, + barcodes: { + position: { + topLeft: { x: number; y: number }; + topRight: { x: number; y: number }; + bottomLeft: { x: number; y: number }; + bottomRight: { x: number; y: number }; + }; + }[], +): string { + const shortSide = Math.min(width, height); + const strokeWidth = Math.max(2, Math.round(shortSide / 200)); + const fontSize = Math.max(14, Math.round(shortSide / 40)); + const labelPad = Math.round(fontSize * 0.4); + + let elements = ""; + + for (let i = 0; i < barcodes.length; i++) { + const { position: pos } = barcodes[i]; + const color = BOX_COLORS[i % BOX_COLORS.length]; + + // Polygon points: TL -> TR -> BR -> BL + const points = [ + `${pos.topLeft.x},${pos.topLeft.y}`, + `${pos.topRight.x},${pos.topRight.y}`, + `${pos.bottomRight.x},${pos.bottomRight.y}`, + `${pos.bottomLeft.x},${pos.bottomLeft.y}`, + ].join(" "); + + elements += ``; + + // Numbered label above top-left corner + const labelX = pos.topLeft.x; + const labelY = Math.max(pos.topLeft.y - labelPad, fontSize + labelPad); + + elements += `${i + 1}`; + } + + return `${elements}`; +} + +/** + * Read barcodes (all 1D + 2D types) from uploaded images using zxing-wasm. */ export function registerBarcodeRead(app: FastifyInstance) { app.post("/api/v1/tools/barcode-read", async (request: FastifyRequest, reply: FastifyReply) => { let fileBuffer: Buffer | null = null; let filename = "image"; + let settingsRaw: string | null = null; + // --- Parse multipart --- try { const parts = request.parts(); for await (const part of parts) { @@ -23,6 +88,8 @@ export function registerBarcodeRead(app: FastifyInstance) { } fileBuffer = Buffer.concat(chunks); filename = basename(part.filename ?? "image"); + } else if (part.fieldname === "settings") { + settingsRaw = part.value as string; } } } catch (err) { @@ -36,51 +103,110 @@ export function registerBarcodeRead(app: FastifyInstance) { return reply.status(400).send({ error: "No image file provided" }); } - // Validate the uploaded image + // --- Validate --- const validation = await validateImageBuffer(fileBuffer); if (!validation.valid) { - return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); + return reply.status(400).send({ + error: `Invalid image: ${validation.reason}`, + }); } try { - // Decode HEIC/HEIF if needed - fileBuffer = await ensureSharpCompat(fileBuffer); + const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + const tryHarder = settings.tryHarder !== false; // default true - // Convert to RGBA raw pixel data for jsQR + // Decode HEIC/HEIF if needed, then auto-orient + fileBuffer = await ensureSharpCompat(fileBuffer); + fileBuffer = await autoOrient(fileBuffer); + + // Convert to raw RGBA pixel data const image = sharp(fileBuffer); const metadata = await image.metadata(); const width = metadata.width ?? 0; const height = metadata.height ?? 0; - const rawData = await image.ensureAlpha().raw().toBuffer(); - - const code = jsQR( - new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length), - width, - height, - ); - - if (!code) { - return reply.send({ - filename, - found: false, - text: null, - message: "No QR code found in the image", + if (width === 0 || height === 0) { + return reply.status(422).send({ + error: "Could not determine image dimensions", }); } + const rawData = await image.ensureAlpha().raw().toBuffer(); + + // --- Detect barcodes via zxing-wasm --- + const imageData = { + data: new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length), + width, + height, + }; + + const results = await readBarcodes(imageData, { + tryHarder, + maxNumberOfSymbols: 255, + }); + + const validResults = results.filter((r) => r.isValid); + + // Map to the response shape + const barcodes = validResults.map((r) => ({ + type: r.format, + text: r.text, + position: { + topLeft: { x: r.position.topLeft.x, y: r.position.topLeft.y }, + topRight: { x: r.position.topRight.x, y: r.position.topRight.y }, + bottomLeft: { + x: r.position.bottomLeft.x, + y: r.position.bottomLeft.y, + }, + bottomRight: { + x: r.position.bottomRight.x, + y: r.position.bottomRight.y, + }, + }, + })); + + // No barcodes found - return early + if (barcodes.length === 0) { + return reply.send({ + filename, + barcodes: [], + annotatedUrl: null, + previewUrl: null, + }); + } + + // --- Generate annotated image --- + const jobId = randomUUID(); + const workspacePath = await createWorkspace(jobId); + + // Save original input + const inputPath = join(workspacePath, "input", filename); + await writeFile(inputPath, fileBuffer); + + // Build SVG overlay with bounding boxes + const overlaySvg = buildOverlaySvg(width, height, barcodes); + + const stem = filename.replace(/\.[^.]+$/, ""); + const outputFilename = `annotated-${stem}.png`; + const outputPath = join(workspacePath, "output", outputFilename); + + const annotatedBuffer = await sharp(fileBuffer) + .composite([{ input: Buffer.from(overlaySvg), top: 0, left: 0 }]) + .png() + .toBuffer(); + + await writeFile(outputPath, annotatedBuffer); + + const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; + return reply.send({ filename, - found: true, - text: code.data, - location: { - topLeft: code.location.topLeftCorner, - topRight: code.location.topRightCorner, - bottomLeft: code.location.bottomLeftCorner, - bottomRight: code.location.bottomRightCorner, - }, + barcodes, + annotatedUrl: downloadUrl, + previewUrl: downloadUrl, }); } catch (err) { + request.log.error({ err, toolId: "barcode-read" }, "Barcode read failed"); return reply.status(422).send({ error: "Barcode reading failed", details: err instanceof Error ? err.message : "Unknown error", diff --git a/apps/api/src/routes/tools/border.ts b/apps/api/src/routes/tools/border.ts index 4639c41b..f1b327d9 100644 --- a/apps/api/src/routes/tools/border.ts +++ b/apps/api/src/routes/tools/border.ts @@ -3,101 +3,150 @@ import sharp from "sharp"; import { z } from "zod"; import { createToolRoute } from "../tool-factory.js"; +const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/); + const settingsSchema = z.object({ borderWidth: z.number().min(0).max(200).default(10), - borderColor: z - .string() - .regex(/^#[0-9a-fA-F]{6}$/) - .default("#000000"), - cornerRadius: z.number().min(0).max(500).default(0), + borderColor: hexColor.default("#000000"), padding: z.number().min(0).max(200).default(0), - shadowBlur: z.number().min(0).max(50).default(0), - shadowColor: z - .string() - .regex(/^#[0-9a-fA-F]{6,8}$/) - .default("#00000080"), + paddingColor: hexColor.default("#FFFFFF"), + cornerRadius: z.number().min(0).max(500).default(0), + shadow: z.boolean().default(false), + shadowBlur: z.number().min(1).max(50).default(15), + shadowOffsetX: z.number().min(-50).max(50).default(0), + shadowOffsetY: z.number().min(-50).max(50).default(5), + shadowColor: hexColor.default("#000000"), + shadowOpacity: z.number().min(0).max(100).default(40), }); +function parseHex(hex: string) { + return { + r: parseInt(hex.slice(1, 3), 16), + g: parseInt(hex.slice(3, 5), 16), + b: parseInt(hex.slice(5, 7), 16), + }; +} + export function registerBorder(app: FastifyInstance) { createToolRoute(app, { toolId: "border", settingsSchema, process: async (inputBuffer, settings, filename) => { - const image = sharp(inputBuffer); - const meta = await image.metadata(); - const w = meta.width ?? 100; - const h = meta.height ?? 100; + let buf = inputBuffer; - // Parse border color - const br = parseInt(settings.borderColor.slice(1, 3), 16); - const bg = parseInt(settings.borderColor.slice(3, 5), 16); - const bb = parseInt(settings.borderColor.slice(5, 7), 16); + // 1. Add padding + if (settings.padding > 0) { + const c = parseHex(settings.paddingColor); + buf = await sharp(buf) + .extend({ + top: settings.padding, + bottom: settings.padding, + left: settings.padding, + right: settings.padding, + background: { r: c.r, g: c.g, b: c.b, alpha: 1 }, + }) + .toBuffer(); + } - const totalBorder = settings.borderWidth + settings.padding; - const shadowPad = settings.shadowBlur > 0 ? settings.shadowBlur * 2 : 0; + // 2. Add border + if (settings.borderWidth > 0) { + const c = parseHex(settings.borderColor); + buf = await sharp(buf) + .extend({ + top: settings.borderWidth, + bottom: settings.borderWidth, + left: settings.borderWidth, + right: settings.borderWidth, + background: { r: c.r, g: c.g, b: c.b, alpha: 1 }, + }) + .toBuffer(); + } - // Extend image with border - let result = sharp(inputBuffer).extend({ - top: totalBorder + shadowPad, - bottom: totalBorder + shadowPad, - left: totalBorder + shadowPad, - right: totalBorder + shadowPad, - background: { r: br, g: bg, b: bb, alpha: 1 }, - }); + // 3. Apply corner radius + if (settings.cornerRadius > 0) { + buf = await sharp(buf).ensureAlpha().png().toBuffer(); + const meta = await sharp(buf).metadata(); + const w = meta.width ?? 100; + const h = meta.height ?? 100; + const r = Math.min(settings.cornerRadius, w / 2, h / 2); - // If inner padding, overlay a background-colored rectangle for padding area - if (settings.padding > 0 && settings.borderWidth > 0) { - const _outerW = w + totalBorder * 2 + shadowPad * 2; - const _outerH = h + totalBorder * 2 + shadowPad * 2; + const mask = Buffer.from( + ``, + ); + buf = await sharp(buf) + .composite([{ input: await sharp(mask).resize(w, h).toBuffer(), blend: "dest-in" }]) + .png() + .toBuffer(); + } - // Create a white padding region behind the image - const paddingRect = await sharp({ + // 4. Apply shadow + if (settings.shadow) { + buf = await sharp(buf).ensureAlpha().png().toBuffer(); + const meta = await sharp(buf).metadata(); + const bW = meta.width ?? 100; + const bH = meta.height ?? 100; + + const sc = parseHex(settings.shadowColor); + const alpha = settings.shadowOpacity / 100; + const blur = settings.shadowBlur; + const spread = Math.ceil(blur * 2); + const ox = settings.shadowOffsetX; + const oy = settings.shadowOffsetY; + + // Create shadow silhouette matching image shape (respects rounded corners) + const shadowSilhouette = await sharp({ create: { - width: w + settings.padding * 2, - height: h + settings.padding * 2, + width: bW, + height: bH, channels: 4, - background: { r: 255, g: 255, b: 255, alpha: 1 }, + background: { r: sc.r, g: sc.g, b: sc.b, alpha }, }, }) + .composite([{ input: buf, blend: "dest-in" }]) + .extend({ + top: spread, + bottom: spread, + left: spread, + right: spread, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .blur(Math.max(blur, 0.3)) .png() .toBuffer(); - const currentBuf = await result.toBuffer(); - result = sharp(currentBuf).composite([ - { - input: paddingRect, - top: settings.borderWidth + shadowPad, - left: settings.borderWidth + shadowPad, + // Calculate canvas padding for shadow spread + offset + const padL = Math.max(0, spread - ox); + const padR = Math.max(0, spread + ox); + const padT = Math.max(0, spread - oy); + const padB = Math.max(0, spread + oy); + + const canvasW = bW + padL + padR; + const canvasH = bH + padT + padB; + + const imgX = padL; + const imgY = padT; + const shadX = Math.max(0, imgX + ox - spread); + const shadY = Math.max(0, imgY + oy - spread); + + buf = await sharp({ + create: { + width: canvasW, + height: canvasH, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, }, - { - input: inputBuffer, - top: totalBorder + shadowPad, - left: totalBorder + shadowPad, - }, - ]); + }) + .composite([ + { input: shadowSilhouette, left: shadX, top: shadY }, + { input: buf, left: imgX, top: imgY }, + ]) + .png() + .toBuffer(); } - // Apply rounded corners via SVG mask - if (settings.cornerRadius > 0) { - const buf = await result.ensureAlpha().toBuffer(); - const bufMeta = await sharp(buf).metadata(); - const maskW = bufMeta.width ?? w; - const maskH = bufMeta.height ?? h; - const r = Math.min(settings.cornerRadius, maskW / 2, maskH / 2); - - const roundedMask = Buffer.from( - ` - - `, - ); - - const maskBuffer = await sharp(roundedMask).resize(maskW, maskH).toBuffer(); - - result = sharp(buf).composite([{ input: maskBuffer, blend: "dest-in" }]); - } - - const buffer = await result.png().toBuffer(); - return { buffer, filename, contentType: "image/png" }; + const buffer = await sharp(buf).png().toBuffer(); + const outName = filename.replace(/\.[^.]+$/, ".png"); + return { buffer, filename: outName, contentType: "image/png" }; }, }); } diff --git a/apps/api/src/routes/tools/find-duplicates.ts b/apps/api/src/routes/tools/find-duplicates.ts index dab29e53..74ca9192 100644 --- a/apps/api/src/routes/tools/find-duplicates.ts +++ b/apps/api/src/routes/tools/find-duplicates.ts @@ -4,27 +4,35 @@ import sharp from "sharp"; import { autoOrient } from "../../lib/auto-orient.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; -/** - * Compute a dHash (difference hash) for perceptual duplicate detection. - * Resize to 9x8 grayscale, compare adjacent pixels to create 64-bit hash. - */ -async function computeDHash(buffer: Buffer): Promise { - const pixels = await sharp(buffer).resize(9, 8, { fit: "fill" }).grayscale().raw().toBuffer(); +const DEFAULT_THRESHOLD = 8; +const THUMBNAIL_WIDTH = 200; +/** + * Compute a 128-bit dHash (row + column) for perceptual duplicate detection. + * Row hash: resize to 9x8 grayscale, compare adjacent horizontal pixels (64 bits). + * Column hash: resize to 8x9 grayscale, compare adjacent vertical pixels (64 bits). + */ +async function computeDHash128(buffer: Buffer): Promise { + // Row hash: 9 wide x 8 tall + const rowPixels = await sharp(buffer).resize(9, 8, { fit: "fill" }).grayscale().raw().toBuffer(); let hash = ""; for (let y = 0; y < 8; y++) { for (let x = 0; x < 8; x++) { - const left = pixels[y * 9 + x]; - const right = pixels[y * 9 + x + 1]; - hash += left > right ? "1" : "0"; + hash += rowPixels[y * 9 + x] > rowPixels[y * 9 + x + 1] ? "1" : "0"; } } - return hash; + + // Column hash: 8 wide x 9 tall + const colPixels = await sharp(buffer).resize(8, 9, { fit: "fill" }).grayscale().raw().toBuffer(); + for (let y = 0; y < 8; y++) { + for (let x = 0; x < 8; x++) { + hash += colPixels[y * 8 + x] > colPixels[(y + 1) * 8 + x] ? "1" : "0"; + } + } + + return hash; // 128 characters } -/** - * Compute hamming distance between two 64-bit hash strings. - */ function hammingDistance(a: string, b: string): number { let distance = 0; for (let i = 0; i < a.length; i++) { @@ -33,9 +41,55 @@ function hammingDistance(a: string, b: string): number { return distance; } +interface FileData { + buffer: Buffer; + filename: string; + originalSize: number; +} + +interface FileInfo { + filename: string; + hash: string; + width: number; + height: number; + fileSize: number; + format: string; + thumbnail: string | null; +} + +async function extractFileInfo(file: FileData): Promise { + const meta = await sharp(file.buffer).metadata(); + const width = meta.width ?? 0; + const height = meta.height ?? 0; + const format = meta.format ?? "unknown"; + + // Generate 200px wide JPEG thumbnail as base64 + let thumbnail: string | null = null; + try { + const thumbBuffer = await sharp(file.buffer) + .resize(THUMBNAIL_WIDTH, undefined, { withoutEnlargement: true }) + .jpeg({ quality: 70 }) + .toBuffer(); + thumbnail = `data:image/jpeg;base64,${thumbBuffer.toString("base64")}`; + } catch { + // Non-fatal: some formats may fail thumbnail generation + } + + return { + filename: file.filename, + hash: "", + width, + height, + fileSize: file.originalSize, + format, + thumbnail, + }; +} + export function registerFindDuplicates(app: FastifyInstance) { app.post("/api/v1/tools/find-duplicates", async (request, reply) => { - const files: Array<{ buffer: Buffer; filename: string }> = []; + const files: FileData[] = []; + let threshold = DEFAULT_THRESHOLD; try { const parts = request.parts(); @@ -50,8 +104,14 @@ export function registerFindDuplicates(app: FastifyInstance) { files.push({ buffer: buf, filename: basename(part.filename ?? `image-${files.length}`), + originalSize: buf.length, }); } + } else if (part.type === "field" && part.fieldname === "threshold") { + const val = Number(part.value); + if (!Number.isNaN(val) && val >= 0 && val <= 20) { + threshold = val; + } } } } catch (err) { @@ -73,40 +133,96 @@ export function registerFindDuplicates(app: FastifyInstance) { file.buffer = await autoOrient(await ensureSharpCompat(file.buffer)); } - // Compute hashes for all images - const hashes: Array<{ filename: string; hash: string }> = []; + // Extract metadata, thumbnails, and compute hashes + const fileInfos: FileInfo[] = []; for (const file of files) { - const hash = await computeDHash(file.buffer); - hashes.push({ filename: file.filename, hash }); + const info = await extractFileInfo(file); + info.hash = await computeDHash128(file.buffer); + fileInfos.push(info); } - // Compare all pairs, group duplicates - const threshold = 10; // Hamming distance threshold for "similar" - const groups: Array<{ - files: Array<{ filename: string; similarity: number }>; - }> = []; + // Group duplicates by hamming distance const assigned = new Set(); + const groups: Array<{ + groupId: number; + files: Array<{ + filename: string; + similarity: number; + width: number; + height: number; + fileSize: number; + format: string; + isBest: boolean; + thumbnail: string | null; + }>; + }> = []; - for (let i = 0; i < hashes.length; i++) { + let groupCounter = 0; + + for (let i = 0; i < fileInfos.length; i++) { if (assigned.has(i)) continue; - const group: Array<{ filename: string; similarity: number }> = [ - { filename: hashes[i].filename, similarity: 100 }, + const members: Array<{ index: number; similarity: number }> = [ + { index: i, similarity: 100 }, ]; - for (let j = i + 1; j < hashes.length; j++) { + for (let j = i + 1; j < fileInfos.length; j++) { if (assigned.has(j)) continue; - const dist = hammingDistance(hashes[i].hash, hashes[j].hash); + const dist = hammingDistance(fileInfos[i].hash, fileInfos[j].hash); if (dist <= threshold) { - const similarity = Math.round((1 - dist / 64) * 10000) / 100; - group.push({ filename: hashes[j].filename, similarity }); + const similarity = Math.round((1 - dist / 128) * 10000) / 100; + members.push({ index: j, similarity }); assigned.add(j); } } - if (group.length > 1) { + if (members.length > 1) { assigned.add(i); - groups.push({ files: group }); + groupCounter++; + + // Determine "best" image: highest pixel count, tie-break by file size + let bestIdx = 0; + for (let m = 1; m < members.length; m++) { + const curr = fileInfos[members[m].index]; + const best = fileInfos[members[bestIdx].index]; + const currPixels = curr.width * curr.height; + const bestPixels = best.width * best.height; + if ( + currPixels > bestPixels || + (currPixels === bestPixels && curr.fileSize > best.fileSize) + ) { + bestIdx = m; + } + } + + groups.push({ + groupId: groupCounter, + files: members.map((m, idx) => ({ + filename: fileInfos[m.index].filename, + similarity: m.similarity, + width: fileInfos[m.index].width, + height: fileInfos[m.index].height, + fileSize: fileInfos[m.index].fileSize, + format: fileInfos[m.index].format, + isBest: idx === bestIdx, + thumbnail: fileInfos[m.index].thumbnail, + })), + }); + } + } + + // Sort groups by highest similarity descending + groups.sort((a, b) => { + const maxA = Math.max(...a.files.map((f) => f.similarity)); + const maxB = Math.max(...b.files.map((f) => f.similarity)); + return maxB - maxA; + }); + + // Calculate space saveable (sum of non-best duplicate file sizes) + let spaceSaveable = 0; + for (const group of groups) { + for (const file of group.files) { + if (!file.isBest) spaceSaveable += file.fileSize; } } @@ -114,6 +230,7 @@ export function registerFindDuplicates(app: FastifyInstance) { totalImages: files.length, duplicateGroups: groups, uniqueImages: files.length - assigned.size, + spaceSaveable, }); } catch (err) { return reply.status(422).send({ diff --git a/apps/api/src/routes/tools/gif-tools.ts b/apps/api/src/routes/tools/gif-tools.ts index 920b49ca..3054bae7 100644 --- a/apps/api/src/routes/tools/gif-tools.ts +++ b/apps/api/src/routes/tools/gif-tools.ts @@ -1,47 +1,305 @@ -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { zipSync } from "fflate"; import sharp from "sharp"; import { z } from "zod"; import { createToolRoute } from "../tool-factory.js"; +/** + * Assemble multiple single-frame GIF buffers into one animated GIF. + * + * Sharp 0.33.x cannot set the page-height metadata on images constructed + * from raw pixel data, so re-encoding reversed frames through sharp's + * `.gif()` produces a single tall frame instead of an animation. + * + * This helper works at the GIF89a binary level: it takes the header, + * logical screen descriptor, and global color table from the first frame, + * adds a NETSCAPE2.0 looping extension, then appends the graphic control + * extension + image data blocks from every frame. + */ +function assembleAnimatedGif(frameGifs: Buffer[], loop: number): Buffer { + const first = frameGifs[0]; + + // Parse the Logical Screen Descriptor to find the Global Color Table size + const packed = first[10]; // byte 10 = packed field in LSD + const hasGCT = (packed & 0x80) !== 0; + const gctSize = hasGCT ? 3 * (1 << ((packed & 0x07) + 1)) : 0; + const headerEnd = 13 + gctSize; // 6 (sig) + 7 (LSD) + GCT + + // Header + LSD + GCT from the first frame + const header = first.subarray(0, headerEnd); + + // NETSCAPE2.0 application extension for looping + const loopLo = loop & 0xff; + const loopHi = (loop >> 8) & 0xff; + const loopExt = Buffer.from([ + 0x21, + 0xff, + 0x0b, // application extension introducer + ...Buffer.from("NETSCAPE2.0"), + 0x03, + 0x01, + loopLo, + loopHi, // sub-block: loop count + 0x00, // block terminator + ]); + + const parts: Buffer[] = [header, loopExt]; + + // Extract frame data (everything between the header/GCT and the trailer) + for (const gif of frameGifs) { + const p = gif[10]; + const hasTable = (p & 0x80) !== 0; + const tableSize = hasTable ? 3 * (1 << ((p & 0x07) + 1)) : 0; + const dataStart = 13 + tableSize; + const dataEnd = gif.length - 1; // exclude 0x3B trailer + if (dataEnd > dataStart) { + parts.push(gif.subarray(dataStart, dataEnd)); + } + } + + parts.push(Buffer.from([0x3b])); // GIF trailer + return Buffer.concat(parts); +} + const settingsSchema = z.object({ + mode: z.enum(["resize", "optimize", "speed", "reverse", "extract", "rotate"]).default("resize"), + + // Resize width: z.number().min(1).max(4096).optional(), height: z.number().min(1).max(4096).optional(), - extractFrame: z.number().min(0).optional(), - optimize: z.boolean().default(false), + percentage: z.number().min(1).max(500).optional(), + + // Optimize + colors: z.number().min(2).max(256).default(256), + dither: z.number().min(0).max(1).default(1.0), + effort: z.number().min(1).max(10).default(7), + + // Speed + speedFactor: z.number().min(0.1).max(10).default(1.0), + + // Extract + extractMode: z.enum(["single", "range", "all"]).default("single"), + frameNumber: z.number().min(0).default(0), + frameStart: z.number().min(0).default(0), + frameEnd: z.number().min(0).optional(), + extractFormat: z.enum(["png", "webp"]).default("png"), + + // Rotate + angle: z + .number() + .refine((v) => [90, 180, 270].includes(v)) + .optional(), + flipH: z.boolean().default(false), + flipV: z.boolean().default(false), + + // Global + loop: z.number().min(0).max(100).default(0), }); export function registerGifTools(app: FastifyInstance) { + // ── Metadata endpoint ─────────────────────────────────────────── + app.post("/api/v1/tools/gif-tools/info", async (request: FastifyRequest, reply: FastifyReply) => { + let fileBuffer: Buffer | null = null; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk); + } + fileBuffer = Buffer.concat(chunks); + } + } + } catch { + return reply.status(400).send({ error: "Failed to parse request" }); + } + + if (!fileBuffer || fileBuffer.length === 0) { + return reply.status(400).send({ error: "No file provided" }); + } + + try { + const meta = await sharp(fileBuffer).metadata(); + const pages = meta.pages ?? 1; + const delay = meta.delay ?? Array(pages).fill(100); + + return reply.send({ + width: meta.width ?? 0, + height: meta.pageHeight ?? meta.height ?? 0, + pages, + delay, + loop: meta.loop ?? 0, + fileSize: fileBuffer.length, + duration: delay.reduce((sum: number, d: number) => sum + d, 0), + }); + } catch { + return reply.status(422).send({ error: "Could not read image metadata" }); + } + }); + + // ── Processing endpoint ───────────────────────────────────────── createToolRoute(app, { toolId: "gif-tools", settingsSchema, process: async (inputBuffer, settings, filename) => { - if (settings.extractFrame !== undefined) { - // Extract a single frame from animated GIF - const image = sharp(inputBuffer, { page: settings.extractFrame }); + const baseName = filename.replace(/\.[^.]+$/, ""); + const loop = settings.loop; - if (settings.width || settings.height) { - image.resize(settings.width, settings.height, { fit: "inside" }); + switch (settings.mode) { + case "resize": { + const image = sharp(inputBuffer, { animated: true }); + + if (settings.percentage) { + const meta = await image.metadata(); + const w = Math.round(((meta.width ?? 0) * settings.percentage) / 100); + const h = Math.round( + ((meta.pageHeight ?? meta.height ?? 0) * settings.percentage) / 100, + ); + image.resize(w || undefined, h || undefined, { fit: "inside" }); + } else if (settings.width || settings.height) { + image.resize(settings.width, settings.height, { fit: "inside" }); + } + + const buffer = await image.gif({ loop }).toBuffer(); + return { buffer, filename, contentType: "image/gif" }; } - const buffer = await image.png().toBuffer(); - const outName = `${filename.replace(/\.gif$/i, "")}_frame${settings.extractFrame}.png`; - return { buffer, filename: outName, contentType: "image/png" }; + case "optimize": { + const buffer = await sharp(inputBuffer, { animated: true }) + .gif({ + effort: settings.effort, + colours: settings.colors, + dither: settings.dither, + loop, + }) + .toBuffer(); + return { buffer, filename, contentType: "image/gif" }; + } + + case "speed": { + const meta = await sharp(inputBuffer, { animated: true }).metadata(); + const origDelays = meta.delay ?? Array(meta.pages ?? 1).fill(100); + const newDelays = origDelays.map((d: number) => + Math.max(20, Math.round(d / settings.speedFactor)), + ); + + const buffer = await sharp(inputBuffer, { animated: true }) + .gif({ delay: newDelays, loop }) + .toBuffer(); + return { buffer, filename, contentType: "image/gif" }; + } + + case "reverse": { + const meta = await sharp(inputBuffer, { animated: true }).metadata(); + const pageCount = meta.pages ?? 1; + const delays = [...(meta.delay ?? Array(pageCount).fill(100))]; + + if (pageCount <= 1) { + const buffer = await sharp(inputBuffer).gif({ loop }).toBuffer(); + return { buffer, filename, contentType: "image/gif" }; + } + + delays.reverse(); + + // Apply optional speed adjustment (used when "Also adjust speed" is checked) + if (settings.speedFactor !== 1.0) { + for (let i = 0; i < delays.length; i++) { + delays[i] = Math.max(20, Math.round(delays[i] / settings.speedFactor)); + } + } + + // Extract each frame as a single-frame GIF with the correct delay, + // then combine into a multi-frame GIF at the binary level. + // This avoids going through raw pixel data, which loses the + // page-height metadata that sharp/libvips needs for animation. + const frameGifs: Buffer[] = []; + for (let i = pageCount - 1; i >= 0; i--) { + const frameBuf = await sharp(inputBuffer, { page: i }) + .gif({ delay: [delays[pageCount - 1 - i]], loop }) + .toBuffer(); + frameGifs.push(frameBuf); + } + + const buffer = assembleAnimatedGif(frameGifs, loop); + return { buffer, filename, contentType: "image/gif" }; + } + + case "extract": { + if (settings.extractMode === "single") { + const frame = sharp(inputBuffer, { page: settings.frameNumber }); + const ext = settings.extractFormat; + const buffer = + ext === "webp" ? await frame.webp().toBuffer() : await frame.png().toBuffer(); + const outName = `${baseName}_frame${settings.frameNumber}.${ext}`; + return { + buffer, + filename: outName, + contentType: ext === "webp" ? "image/webp" : "image/png", + }; + } + + // Range or All + const meta = await sharp(inputBuffer).metadata(); + const pageCount = meta.pages ?? 1; + const start = settings.extractMode === "all" ? 0 : settings.frameStart; + const end = + settings.extractMode === "all" + ? pageCount - 1 + : Math.min(settings.frameEnd ?? pageCount - 1, pageCount - 1); + + const ext = settings.extractFormat; + const files: Record = {}; + + for (let i = start; i <= end; i++) { + const frame = sharp(inputBuffer, { page: i }); + const buf = + ext === "webp" ? await frame.webp().toBuffer() : await frame.png().toBuffer(); + files[`frame_${String(i).padStart(4, "0")}.${ext}`] = new Uint8Array(buf); + } + + const zipData = zipSync(files); + const zipBuffer = Buffer.from(zipData); + return { + buffer: zipBuffer, + filename: `${baseName}_frames.zip`, + contentType: "application/zip", + }; + } + + case "rotate": { + const meta = await sharp(inputBuffer, { animated: true }).metadata(); + const pageCount = meta.pages ?? 1; + const delays = meta.delay ?? Array(pageCount).fill(100); + + // Sharp cannot rotate multi-page images directly, so process + // each frame individually and reassemble the animation. + const frameGifs: Buffer[] = []; + for (let i = 0; i < pageCount; i++) { + let frame = sharp(inputBuffer, { page: i }); + if (settings.angle) { + frame = frame.rotate(settings.angle); + } + if (settings.flipV) { + frame = frame.flip(); + } + if (settings.flipH) { + frame = frame.flop(); + } + const frameBuf = await frame.gif({ delay: [delays[i]], loop }).toBuffer(); + frameGifs.push(frameBuf); + } + + const buffer = pageCount > 1 ? assembleAnimatedGif(frameGifs, loop) : frameGifs[0]; + return { buffer, filename, contentType: "image/gif" }; + } + + default: { + const buffer = await sharp(inputBuffer, { animated: true }).gif({ loop }).toBuffer(); + return { buffer, filename, contentType: "image/gif" }; + } } - - // Process animated GIF (preserve animation) - const image = sharp(inputBuffer, { animated: true }); - - if (settings.width || settings.height) { - image.resize(settings.width, settings.height, { fit: "inside" }); - } - - if (settings.optimize) { - // Reduce colors for optimization - image.gif({ effort: 10 }); - } - - const buffer = await image.gif().toBuffer(); - return { buffer, filename, contentType: "image/gif" }; }, }); } diff --git a/apps/api/src/routes/tools/split.ts b/apps/api/src/routes/tools/split.ts index 9cb09959..e99a638f 100644 --- a/apps/api/src/routes/tools/split.ts +++ b/apps/api/src/routes/tools/split.ts @@ -8,13 +8,29 @@ import { autoOrient } from "../../lib/auto-orient.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; const settingsSchema = z.object({ - columns: z.number().min(1).max(10).default(2), - rows: z.number().min(1).max(10).default(2), + columns: z.number().min(1).max(20).default(3), + rows: z.number().min(1).max(20).default(3), + tileWidth: z.number().min(10).optional(), + tileHeight: z.number().min(10).optional(), + outputFormat: z.enum(["original", "png", "jpg", "webp"]).default("original"), + quality: z.number().min(1).max(100).default(90), }); -/** - * Split an image into grid parts and return as ZIP. - */ +function resolveOutputFormat( + outputFormat: string, + originalExt: string, +): { sharpFormat: keyof sharp.FormatEnum | null; ext: string } { + if (outputFormat === "original") { + return { sharpFormat: null, ext: originalExt }; + } + const map: Record = { + png: { sharpFormat: "png", ext: ".png" }, + jpg: { sharpFormat: "jpeg", ext: ".jpg" }, + webp: { sharpFormat: "webp", ext: ".webp" }, + }; + return map[outputFormat] ?? { sharpFormat: null, ext: originalExt }; +} + export function registerSplit(app: FastifyInstance) { app.post("/api/v1/tools/split", async (request, reply) => { let fileBuffer: Buffer | null = null; @@ -59,20 +75,30 @@ export function registerSplit(app: FastifyInstance) { } try { - // Decode HEIC/HEIF if needed, then normalize EXIF orientation fileBuffer = await autoOrient(await ensureSharpCompat(fileBuffer)); - const metadata = await sharp(fileBuffer).metadata(); const fullW = metadata.width ?? 0; const fullH = metadata.height ?? 0; - const cellW = Math.floor(fullW / settings.columns); - const cellH = Math.floor(fullH / settings.rows); - const ext = extname(filename) || ".png"; - const baseName = filename.replace(ext, ""); + let cols = settings.columns; + let rows = settings.rows; + if (settings.tileWidth && settings.tileHeight) { + cols = Math.max(1, Math.ceil(fullW / settings.tileWidth)); + rows = Math.max(1, Math.ceil(fullH / settings.tileHeight)); + } + cols = Math.min(cols, 20); + rows = Math.min(rows, 20); + + const cellW = Math.floor(fullW / cols); + const cellH = Math.floor(fullH / rows); + const originalExt = extname(filename) || ".png"; + const baseName = filename.replace(/\.[^.]+$/, ""); + const { sharpFormat, ext: outputExt } = resolveOutputFormat( + settings.outputFormat, + originalExt, + ); const jobId = randomUUID(); - // Set up response headers for ZIP reply.hijack(); reply.raw.writeHead(200, { "Content-Type": "application/zip", @@ -83,20 +109,39 @@ export function registerSplit(app: FastifyInstance) { const archive = archiver("zip", { zlib: { level: 5 } }); archive.pipe(reply.raw); - for (let row = 0; row < settings.rows; row++) { - for (let col = 0; col < settings.columns; col++) { - const left = col * cellW; - const top = row * cellH; - // Ensure we don't go out of bounds on the last row/col - const w = col === settings.columns - 1 ? fullW - left : cellW; - const h = row === settings.rows - 1 ? fullH - top : cellH; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + let left: number; + let top: number; + let w: number; + let h: number; - const partBuffer = await sharp(fileBuffer) - .extract({ left, top, width: w, height: h }) - .toBuffer(); + if (settings.tileWidth && settings.tileHeight) { + left = col * settings.tileWidth; + top = row * settings.tileHeight; + w = col === cols - 1 ? fullW - left : Math.min(settings.tileWidth, fullW - left); + h = row === rows - 1 ? fullH - top : Math.min(settings.tileHeight, fullH - top); + } else { + left = col * cellW; + top = row * cellH; + w = col === cols - 1 ? fullW - left : cellW; + h = row === rows - 1 ? fullH - top : cellH; + } + if (left >= fullW || top >= fullH || w <= 0 || h <= 0) continue; + + let pipeline = sharp(fileBuffer).extract({ left, top, width: w, height: h }); + if (sharpFormat) { + const formatOpts: Record = {}; + if (sharpFormat === "jpeg" || sharpFormat === "webp") { + formatOpts.quality = settings.quality; + } + pipeline = pipeline.toFormat(sharpFormat, formatOpts); + } + + const partBuffer = await pipeline.toBuffer(); archive.append(partBuffer, { - name: `${baseName}_r${row + 1}_c${col + 1}${ext}`, + name: `${baseName}_r${row + 1}_c${col + 1}${outputExt}`, }); } } diff --git a/apps/api/src/routes/tools/stitch.ts b/apps/api/src/routes/tools/stitch.ts index 0cd0f555..a0951471 100644 --- a/apps/api/src/routes/tools/stitch.ts +++ b/apps/api/src/routes/tools/stitch.ts @@ -12,14 +12,19 @@ import { createWorkspace } from "../../lib/workspace.js"; const MAX_CANVAS_PIXELS = 100_000_000; const settingsSchema = z.object({ - direction: z.enum(["horizontal", "vertical"]).default("horizontal"), - resize: z.enum(["fit", "original"]).default("fit"), - gap: z.number().min(0).max(100).default(0), + direction: z.enum(["horizontal", "vertical", "grid"]).default("horizontal"), + gridColumns: z.number().int().min(2).max(10).default(2), + resizeMode: z.enum(["fit", "original", "stretch", "crop"]).default("fit"), + alignment: z.enum(["start", "center", "end"]).default("center"), + gap: z.number().min(0).max(200).default(0), + border: z.number().min(0).max(50).default(0), + cornerRadius: z.number().min(0).max(50).default(0), backgroundColor: z .string() .regex(/^#[0-9a-fA-F]{6}$/) .default("#FFFFFF"), format: z.enum(["png", "jpeg", "webp"]).default("png"), + quality: z.number().min(1).max(100).default(90), }); function parseHexColor(hex: string): { r: number; g: number; b: number } { @@ -30,6 +35,12 @@ function parseHexColor(hex: string): { r: number; g: number; b: number } { }; } +interface PreparedImage { + buffer: Buffer; + width: number; + height: number; +} + export function registerStitch(app: FastifyInstance) { app.post("/api/v1/tools/stitch", async (request, reply) => { const files: Array<{ buffer: Buffer; filename: string }> = []; @@ -65,7 +76,6 @@ export function registerStitch(app: FastifyInstance) { return reply.status(400).send({ error: "At least 2 images are required for stitching" }); } - // Validate all files and decode HEIC/HEIF for (const file of files) { const validation = await validateImageBuffer(file.buffer); if (!validation.valid) { @@ -89,7 +99,6 @@ export function registerStitch(app: FastifyInstance) { } try { - // Read metadata for all images const imageMetas = await Promise.all( files.map(async (file) => { const meta = await sharp(file.buffer).metadata(); @@ -101,85 +110,80 @@ export function registerStitch(app: FastifyInstance) { }), ); - // Resize images if needed const isHorizontal = settings.direction === "horizontal"; - let prepared: Array<{ buffer: Buffer; width: number; height: number }>; + const isGrid = settings.direction === "grid"; - if (settings.resize === "fit") { - if (isHorizontal) { - // Find min height, scale taller images down - const minHeight = Math.min(...imageMetas.map((m) => m.height)); - prepared = await Promise.all( - imageMetas.map(async (img) => { - if (img.height > minHeight) { - const scaledWidth = Math.round((img.width * minHeight) / img.height); - const resized = await sharp(img.buffer).resize(scaledWidth, minHeight).toBuffer(); - return { buffer: resized, width: scaledWidth, height: minHeight }; - } - return img; - }), - ); - } else { - // Find min width, scale wider images down - const minWidth = Math.min(...imageMetas.map((m) => m.width)); - prepared = await Promise.all( - imageMetas.map(async (img) => { - if (img.width > minWidth) { - const scaledHeight = Math.round((img.height * minWidth) / img.width); - const resized = await sharp(img.buffer).resize(minWidth, scaledHeight).toBuffer(); - return { buffer: resized, width: minWidth, height: scaledHeight }; - } - return img; - }), - ); - } + let prepared: PreparedImage[]; + + if (isGrid) { + prepared = await prepareForGrid(imageMetas, settings); + } else if (isHorizontal) { + prepared = await prepareForHorizontal(imageMetas, settings.resizeMode); } else { - prepared = imageMetas; + prepared = await prepareForVertical(imageMetas, settings.resizeMode); } - // Calculate canvas dimensions - const n = prepared.length; let canvasWidth: number; let canvasHeight: number; + const composites: sharp.OverlayOptions[] = []; - if (isHorizontal) { - canvasWidth = prepared.reduce((sum, img) => sum + img.width, 0) + settings.gap * (n - 1); - canvasHeight = Math.max(...prepared.map((img) => img.height)); + if (isGrid) { + const cols = Math.min(settings.gridColumns, prepared.length); + const rows = Math.ceil(prepared.length / cols); + const cellWidth = Math.max(...prepared.map((img) => img.width)); + const cellHeight = Math.max(...prepared.map((img) => img.height)); + + canvasWidth = cols * cellWidth + (cols - 1) * settings.gap + 2 * settings.border; + canvasHeight = rows * cellHeight + (rows - 1) * settings.gap + 2 * settings.border; + + for (let i = 0; i < prepared.length; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const img = prepared[i]; + + const cellLeft = settings.border + col * (cellWidth + settings.gap); + const cellTop = settings.border + row * (cellHeight + settings.gap); + + const left = cellLeft + alignOffset(cellWidth, img.width, settings.alignment); + const top = cellTop + alignOffset(cellHeight, img.height, settings.alignment); + + composites.push({ input: img.buffer, left, top }); + } + } else if (isHorizontal) { + const totalImgWidth = prepared.reduce((sum, img) => sum + img.width, 0); + const maxHeight = Math.max(...prepared.map((img) => img.height)); + + canvasWidth = totalImgWidth + (prepared.length - 1) * settings.gap + 2 * settings.border; + canvasHeight = maxHeight + 2 * settings.border; + + let offset = settings.border; + for (const img of prepared) { + const top = settings.border + alignOffset(maxHeight, img.height, settings.alignment); + composites.push({ input: img.buffer, left: offset, top }); + offset += img.width + settings.gap; + } } else { - canvasWidth = Math.max(...prepared.map((img) => img.width)); - canvasHeight = prepared.reduce((sum, img) => sum + img.height, 0) + settings.gap * (n - 1); + const maxWidth = Math.max(...prepared.map((img) => img.width)); + const totalImgHeight = prepared.reduce((sum, img) => sum + img.height, 0); + + canvasWidth = maxWidth + 2 * settings.border; + canvasHeight = totalImgHeight + (prepared.length - 1) * settings.gap + 2 * settings.border; + + let offset = settings.border; + for (const img of prepared) { + const left = settings.border + alignOffset(maxWidth, img.width, settings.alignment); + composites.push({ input: img.buffer, left, top: offset }); + offset += img.height + settings.gap; + } } - // Canvas size check if (canvasWidth * canvasHeight > MAX_CANVAS_PIXELS) { return reply.status(422).send({ error: `Canvas too large: ${canvasWidth}x${canvasHeight} (${Math.round((canvasWidth * canvasHeight) / 1_000_000)}MP exceeds 100MP limit)`, }); } - // Build composites const background = parseHexColor(settings.backgroundColor); - const composites: sharp.OverlayOptions[] = []; - let offset = 0; - - for (const img of prepared) { - let left: number; - let top: number; - - if (isHorizontal) { - left = offset; - top = Math.round((canvasHeight - img.height) / 2); - offset += img.width + settings.gap; - } else { - left = Math.round((canvasWidth - img.width) / 2); - top = offset; - offset += img.height + settings.gap; - } - - composites.push({ input: img.buffer, left, top }); - } - - // Create canvas and composite let pipeline = sharp({ create: { width: canvasWidth, @@ -189,16 +193,41 @@ export function registerStitch(app: FastifyInstance) { }, }).composite(composites); - // Output in requested format if (settings.format === "jpeg") { - pipeline = pipeline.jpeg({ quality: 90 }); + pipeline = pipeline.jpeg({ quality: settings.quality }); } else if (settings.format === "webp") { - pipeline = pipeline.webp({ quality: 90 }); + pipeline = pipeline.webp({ quality: settings.quality }); } else { pipeline = pipeline.png(); } - const result = await pipeline.toBuffer(); + let result = await pipeline.toBuffer(); + + if (settings.cornerRadius > 0) { + const meta = await sharp(result).metadata(); + const w = meta.width!; + const h = meta.height!; + const r = Math.min(settings.cornerRadius, Math.floor(Math.min(w, h) / 2)); + + const mask = Buffer.from( + ``, + ); + + result = await sharp(result) + .ensureAlpha() + .composite([{ input: mask, blend: "dest-in" }]) + .png() + .toBuffer(); + + if (settings.format === "jpeg") { + result = await sharp(result) + .flatten({ background: { r: background.r, g: background.g, b: background.b } }) + .jpeg({ quality: settings.quality }) + .toBuffer(); + } else if (settings.format === "webp") { + result = await sharp(result).webp({ quality: settings.quality }).toBuffer(); + } + } const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); @@ -220,3 +249,130 @@ export function registerStitch(app: FastifyInstance) { } }); } + +function alignOffset(containerSize: number, itemSize: number, alignment: string): number { + if (alignment === "start") return 0; + if (alignment === "end") return containerSize - itemSize; + return Math.round((containerSize - itemSize) / 2); +} + +async function prepareForHorizontal( + images: PreparedImage[], + resizeMode: string, +): Promise { + if (resizeMode === "original") return images; + + const minHeight = Math.min(...images.map((m) => m.height)); + + return Promise.all( + images.map(async (img) => { + if (img.height === minHeight && resizeMode === "fit") return img; + + if (resizeMode === "fit") { + const scaledWidth = Math.round((img.width * minHeight) / img.height); + const resized = await sharp(img.buffer).resize(scaledWidth, minHeight).toBuffer(); + return { buffer: resized, width: scaledWidth, height: minHeight }; + } + + if (resizeMode === "stretch") { + const resized = await sharp(img.buffer) + .resize(img.width, minHeight, { fit: "fill" }) + .toBuffer(); + return { buffer: resized, width: img.width, height: minHeight }; + } + + if (resizeMode === "crop") { + const scaledWidth = Math.round((img.width * minHeight) / img.height); + const resized = await sharp(img.buffer) + .resize(scaledWidth, minHeight, { fit: "cover" }) + .toBuffer(); + return { buffer: resized, width: scaledWidth, height: minHeight }; + } + + return img; + }), + ); +} + +async function prepareForVertical( + images: PreparedImage[], + resizeMode: string, +): Promise { + if (resizeMode === "original") return images; + + const minWidth = Math.min(...images.map((m) => m.width)); + + return Promise.all( + images.map(async (img) => { + if (img.width === minWidth && resizeMode === "fit") return img; + + if (resizeMode === "fit") { + const scaledHeight = Math.round((img.height * minWidth) / img.width); + const resized = await sharp(img.buffer).resize(minWidth, scaledHeight).toBuffer(); + return { buffer: resized, width: minWidth, height: scaledHeight }; + } + + if (resizeMode === "stretch") { + const resized = await sharp(img.buffer) + .resize(minWidth, img.height, { fit: "fill" }) + .toBuffer(); + return { buffer: resized, width: minWidth, height: img.height }; + } + + if (resizeMode === "crop") { + const scaledHeight = Math.round((img.height * minWidth) / img.width); + const resized = await sharp(img.buffer) + .resize(minWidth, scaledHeight, { fit: "cover" }) + .toBuffer(); + return { buffer: resized, width: minWidth, height: scaledHeight }; + } + + return img; + }), + ); +} + +async function prepareForGrid( + images: PreparedImage[], + settings: { gridColumns: number; resizeMode: string }, +): Promise { + if (settings.resizeMode === "original") return images; + + const medianWidth = median(images.map((m) => m.width)); + const medianHeight = median(images.map((m) => m.height)); + + return Promise.all( + images.map(async (img) => { + if (settings.resizeMode === "fit") { + const scale = Math.min(medianWidth / img.width, medianHeight / img.height); + if (scale >= 1) return img; + const newW = Math.round(img.width * scale); + const newH = Math.round(img.height * scale); + const resized = await sharp(img.buffer).resize(newW, newH).toBuffer(); + return { buffer: resized, width: newW, height: newH }; + } + + if (settings.resizeMode === "stretch") { + const resized = await sharp(img.buffer) + .resize(medianWidth, medianHeight, { fit: "fill" }) + .toBuffer(); + return { buffer: resized, width: medianWidth, height: medianHeight }; + } + + if (settings.resizeMode === "crop") { + const resized = await sharp(img.buffer) + .resize(medianWidth, medianHeight, { fit: "cover" }) + .toBuffer(); + return { buffer: resized, width: medianWidth, height: medianHeight }; + } + + return img; + }), + ); +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid]; +} diff --git a/apps/api/src/routes/tools/vectorize.ts b/apps/api/src/routes/tools/vectorize.ts index 882500b0..4a69ea43 100644 --- a/apps/api/src/routes/tools/vectorize.ts +++ b/apps/api/src/routes/tools/vectorize.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; +import { vectorize as vtrace } from "@neplex/vectorizer"; import type { FastifyInstance } from "fastify"; import potrace from "potrace"; import sharp from "sharp"; @@ -12,12 +13,17 @@ import { createWorkspace } from "../../lib/workspace.js"; const settingsSchema = z.object({ colorMode: z.enum(["bw", "color"]).default("bw"), threshold: z.number().min(0).max(255).default(128), - detail: z.enum(["low", "medium", "high"]).default("medium"), + colorPrecision: z.number().min(1).max(8).default(6), + layerDifference: z.number().min(1).max(64).default(6), + filterSpeckle: z.number().min(1).max(128).default(4), + pathMode: z.enum(["none", "polygon", "spline"]).default("spline"), + cornerThreshold: z.number().min(0).max(180).default(60), + invert: z.boolean().default(false), }); function traceImage( buffer: Buffer, - options: { threshold: number; turdSize: number; color?: string }, + options: { threshold: number; turdSize: number; alphamax: number }, ): Promise { return new Promise((resolve, reject) => { potrace.trace(buffer, options, (err: Error | null, svg: string) => { @@ -27,14 +33,18 @@ function traceImage( }); } -function posterize(buffer: Buffer, options: { steps: number; threshold: number }): Promise { - return new Promise((resolve, reject) => { - potrace.posterize(buffer, options, (err: Error | null, svg: string) => { - if (err) reject(err); - else resolve(svg); - }); - }); -} +// PathSimplifyMode: None=0, Polygon=1, Spline=2 +const PATH_MODE_MAP: Record = { + none: 0, + polygon: 1, + spline: 2, +}; + +const ALPHA_MAX_MAP: Record = { + none: 0, + polygon: 0.5, + spline: 1, +}; export function registerVectorize(app: FastifyInstance) { app.post("/api/v1/tools/vectorize", async (request, reply) => { @@ -80,27 +90,35 @@ export function registerVectorize(app: FastifyInstance) { } try { - // Decode HEIC/HEIF if needed, then normalize EXIF orientation fileBuffer = await autoOrient(await ensureSharpCompat(fileBuffer)); - // Convert to BMP-compatible format for potrace (PNG) - const pngBuffer = await sharp(fileBuffer).grayscale().png().toBuffer(); - - const turdSize = settings.detail === "low" ? 10 : settings.detail === "high" ? 1 : 4; + if (settings.invert) { + fileBuffer = await sharp(fileBuffer).negate({ alpha: false }).toBuffer(); + } let svg: string; if (settings.colorMode === "color") { - // Color mode: posterize - svg = await posterize(pngBuffer, { - steps: settings.detail === "low" ? 3 : settings.detail === "high" ? 8 : 5, - threshold: settings.threshold, + const pngBuffer = await sharp(fileBuffer).png().toBuffer(); + svg = await vtrace(pngBuffer, { + colorMode: 0, // ColorMode.Color + colorPrecision: settings.colorPrecision, + filterSpeckle: settings.filterSpeckle, + cornerThreshold: settings.cornerThreshold, + layerDifference: settings.layerDifference, + hierarchical: 0, // Hierarchical.Stacked + mode: (PATH_MODE_MAP[settings.pathMode] ?? 2) as 0 | 1 | 2, + lengthThreshold: 4, + maxIterations: 2, + spliceThreshold: 45, + pathPrecision: 5, }); } else { - // B&W mode: simple trace + const pngBuffer = await sharp(fileBuffer).grayscale().png().toBuffer(); svg = await traceImage(pngBuffer, { threshold: settings.threshold, - turdSize, + turdSize: settings.filterSpeckle, + alphamax: ALPHA_MAX_MAP[settings.pathMode] ?? 1, }); } @@ -116,7 +134,6 @@ export function registerVectorize(app: FastifyInstance) { downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`, originalSize: fileBuffer.length, processedSize: svgBuffer.length, - svgPreview: svg.length < 50000 ? svg : undefined, }); } catch (err) { return reply.status(422).send({ diff --git a/apps/docs/.vitepress/config.mts b/apps/docs/.vitepress/config.mts index 8f2d8001..e3fa0fb0 100644 --- a/apps/docs/.vitepress/config.mts +++ b/apps/docs/.vitepress/config.mts @@ -92,7 +92,8 @@ export default defineConfig({ }, footer: { - message: 'Released under the AGPLv3 License.', + message: + 'Released under the AGPLv3 License.', copyright: 'AI-friendly docs available at /llms.txt · /llms-full.txt', }, diff --git a/apps/docs/.vitepress/theme/GitHubStars.vue b/apps/docs/.vitepress/theme/GitHubStars.vue index 790fe300..70e19018 100644 --- a/apps/docs/.vitepress/theme/GitHubStars.vue +++ b/apps/docs/.vitepress/theme/GitHubStars.vue @@ -1,5 +1,5 @@