mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
co-authored by
Siddharth Kumar Sah
parent
4e99150a08
commit
a1e11dff74
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user