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 <siddharth123sk@gmail.com>
This commit is contained in:
stirling-image
2026-04-13 16:23:07 +08:00
committed by GitHub
co-authored by Siddharth Kumar Sah
parent 4e99150a08
commit a1e11dff74
65 changed files with 6293 additions and 774 deletions
+9 -4
View File
@@ -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)
+156 -30
View File
@@ -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 += `<polygon points="${points}" fill="${color.fill}" stroke="${color.stroke}" stroke-width="${strokeWidth}"/>`;
// Numbered label above top-left corner
const labelX = pos.topLeft.x;
const labelY = Math.max(pos.topLeft.y - labelPad, fontSize + labelPad);
elements += `<text x="${labelX}" y="${labelY}" font-family="sans-serif" font-size="${fontSize}" font-weight="bold" fill="${color.stroke}">${i + 1}</text>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${elements}</svg>`;
}
/**
* 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",
+119 -70
View File
@@ -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(
`<svg width="${w}" height="${h}"><rect x="0" y="0" width="${w}" height="${h}" rx="${r}" ry="${r}" fill="white"/></svg>`,
);
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(
`<svg width="${maskW}" height="${maskH}">
<rect x="0" y="0" width="${maskW}" height="${maskH}" rx="${r}" ry="${r}" fill="white"/>
</svg>`,
);
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" };
},
});
}
+149 -32
View File
@@ -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<string> {
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<string> {
// 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<FileInfo> {
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<number>();
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({
+284 -26
View File
@@ -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<string, Uint8Array> = {};
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" };
},
});
}
+68 -23
View File
@@ -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<string, { sharpFormat: keyof sharp.FormatEnum; ext: string }> = {
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<string, unknown> = {};
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}`,
});
}
}
+226 -70
View File
@@ -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(
`<svg width="${w}" height="${h}"><rect x="0" y="0" width="${w}" height="${h}" rx="${r}" ry="${r}" fill="white"/></svg>`,
);
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<PreparedImage[]> {
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<PreparedImage[]> {
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<PreparedImage[]> {
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];
}
+39 -22
View File
@@ -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<string> {
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<string> {
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<string, number> = {
none: 0,
polygon: 1,
spline: 2,
};
const ALPHA_MAX_MAP: Record<string, number> = {
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({