mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 5a - image gap-fill (11 tools) (#225)
This commit is contained in:
@@ -104,6 +104,82 @@ export async function encodeQoi(inputBuffer: Buffer): Promise<Buffer> {
|
||||
return Buffer.from(encoded);
|
||||
}
|
||||
|
||||
export async function encodePpm(inputBuffer: Buffer): Promise<Buffer> {
|
||||
const cmd = await findMagickCmd();
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `ppm-enc-in-${id}.png`);
|
||||
const outputPath = join(tmpdir(), `ppm-enc-out-${id}.ppm`);
|
||||
try {
|
||||
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||
await writeFile(inputPath, pngBuffer);
|
||||
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `ppm:${outputPath}`]), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
return await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function encodeEps(inputBuffer: Buffer): Promise<Buffer> {
|
||||
const cmd = await findMagickCmd();
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `eps-enc-in-${id}.png`);
|
||||
const outputPath = join(tmpdir(), `eps-enc-out-${id}.eps`);
|
||||
try {
|
||||
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||
await writeFile(inputPath, pngBuffer);
|
||||
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `eps:${outputPath}`]), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
return await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function encodeTga(inputBuffer: Buffer): Promise<Buffer> {
|
||||
const cmd = await findMagickCmd();
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `tga-enc-in-${id}.png`);
|
||||
const outputPath = join(tmpdir(), `tga-enc-out-${id}.tga`);
|
||||
try {
|
||||
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||
await writeFile(inputPath, pngBuffer);
|
||||
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `tga:${outputPath}`]), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
return await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function encodeMultiIco(pngPaths: string[], outputPath: string): Promise<void> {
|
||||
const cmd = await findMagickCmd();
|
||||
const args =
|
||||
cmd === "magick" ? [...pngPaths, `ico:${outputPath}`] : [...pngPaths, `ico:${outputPath}`];
|
||||
await execFileAsync(cmd, cmd === "magick" ? ["convert", ...args] : args, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether ImageMagick is available on this system.
|
||||
* Returns true if magick or convert can be found; false otherwise.
|
||||
*/
|
||||
export async function hasMagick(): Promise<boolean> {
|
||||
try {
|
||||
await findMagickCmd();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function encodeJxl(inputBuffer: Buffer, quality?: number): Promise<Buffer> {
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `jxl-enc-in-${id}.png`);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import bwipjs from "bwip-js/node";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { putObject } from "../../lib/object-storage.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
text: z.string().min(1).max(256),
|
||||
type: z.enum(["code128", "ean13", "upca", "code39", "itf14", "datamatrix"]).default("code128"),
|
||||
scale: z.number().int().min(1).max(8).default(3),
|
||||
includeText: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* Barcode generator - custom route (not factory) since it generates
|
||||
* images from text input, not from uploaded files.
|
||||
* Mirrors qr-generate.ts exactly in route shape.
|
||||
*/
|
||||
export function registerBarcodeGenerate(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/barcode-generate",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = request.body;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid request body" });
|
||||
}
|
||||
|
||||
const result = settingsSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
|
||||
const settings = result.data;
|
||||
|
||||
try {
|
||||
const buffer = await bwipjs.toBuffer({
|
||||
bcid: settings.type,
|
||||
text: settings.text,
|
||||
scale: settings.scale,
|
||||
includetext: settings.includeText,
|
||||
});
|
||||
|
||||
const jobId = randomUUID();
|
||||
const filename = "barcode.png";
|
||||
await putObject(`outputs/${jobId}/${filename}`, buffer);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
|
||||
originalSize: 0,
|
||||
processedSize: buffer.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message.split("\n")[0] : "Unknown error";
|
||||
return reply.status(400).send({
|
||||
error: `Invalid text for this barcode type: ${msg}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import Papa from "papaparse";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
kind: z.enum(["bar", "line", "pie"]).default("bar"),
|
||||
title: z.string().max(120).optional(),
|
||||
width: z.number().int().min(320).max(2048).default(960),
|
||||
height: z.number().int().min(240).max(1536).default(540),
|
||||
});
|
||||
|
||||
const PALETTE = [
|
||||
"#4e79a7",
|
||||
"#f28e2b",
|
||||
"#e15759",
|
||||
"#76b7b2",
|
||||
"#59a14f",
|
||||
"#edc948",
|
||||
"#b07aa1",
|
||||
"#ff9da7",
|
||||
"#9c755f",
|
||||
"#bab0ac",
|
||||
];
|
||||
|
||||
/** XML-escape user-supplied text before embedding in SVG. */
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
interface DataPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function parseInput(buf: Buffer, filename: string): DataPoint[] {
|
||||
const lower = filename.toLowerCase();
|
||||
|
||||
if (lower.endsWith(".json")) {
|
||||
const raw: unknown = JSON.parse(buf.toString("utf8"));
|
||||
// Array of {label, value}
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((item: Record<string, unknown>) => ({
|
||||
label: String(item.label ?? ""),
|
||||
value: Number(item.value),
|
||||
}));
|
||||
}
|
||||
// Object: key -> number
|
||||
if (raw && typeof raw === "object") {
|
||||
return Object.entries(raw as Record<string, unknown>).map(([label, value]) => ({
|
||||
label,
|
||||
value: Number(value),
|
||||
}));
|
||||
}
|
||||
throw new Error("JSON must be an array of {label,value} or an object");
|
||||
}
|
||||
|
||||
// CSV: column 1 = label, column 2 = numeric value
|
||||
const parsed = Papa.parse<string[]>(buf.toString("utf8"), {
|
||||
header: false,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
|
||||
if (parsed.errors.length > 0) {
|
||||
throw new Error(`CSV parse failed: ${parsed.errors[0].message}`);
|
||||
}
|
||||
|
||||
const rows = parsed.data;
|
||||
// Skip header row if first row col2 is non-numeric
|
||||
let start = 0;
|
||||
if (rows.length > 1 && Number.isNaN(Number(rows[0][1]))) {
|
||||
start = 1;
|
||||
}
|
||||
|
||||
return rows.slice(start).map((row) => ({
|
||||
label: String(row[0] ?? ""),
|
||||
value: Number(row[1]),
|
||||
}));
|
||||
}
|
||||
|
||||
function renderBarSvg(data: DataPoint[], w: number, h: number, title: string | undefined): string {
|
||||
const margin = { top: title ? 40 : 20, right: 20, bottom: 60, left: 50 };
|
||||
const plotW = w - margin.left - margin.right;
|
||||
const plotH = h - margin.top - margin.bottom;
|
||||
const maxVal = Math.max(...data.map((d) => d.value), 1);
|
||||
const barW = plotW / data.length;
|
||||
const rotateLabels = data.length > 8;
|
||||
|
||||
let bars = "";
|
||||
let labels = "";
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const barH = (data[i].value / maxVal) * plotH;
|
||||
const x = margin.left + i * barW + barW * 0.1;
|
||||
const y = margin.top + plotH - barH;
|
||||
const bw = barW * 0.8;
|
||||
bars += `<rect x="${x}" y="${y}" width="${bw}" height="${barH}" fill="${PALETTE[i % PALETTE.length]}"/>`;
|
||||
const lx = margin.left + i * barW + barW / 2;
|
||||
const ly = margin.top + plotH + 14;
|
||||
if (rotateLabels) {
|
||||
labels += `<text x="${lx}" y="${ly}" text-anchor="end" font-size="10" font-family="sans-serif" transform="rotate(-45,${lx},${ly})">${escapeXml(data[i].label)}</text>`;
|
||||
} else {
|
||||
labels += `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="10" font-family="sans-serif">${escapeXml(data[i].label)}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Axis line
|
||||
const axisLine = `<line x1="${margin.left}" y1="${margin.top + plotH}" x2="${margin.left + plotW}" y2="${margin.top + plotH}" stroke="#333" stroke-width="1"/>`;
|
||||
|
||||
const titleSvg = title
|
||||
? `<text x="${w / 2}" y="24" text-anchor="middle" font-size="14" font-weight="bold" font-family="sans-serif">${escapeXml(title)}</text>`
|
||||
: "";
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="white"/>${titleSvg}${axisLine}${bars}${labels}</svg>`;
|
||||
}
|
||||
|
||||
function renderLineSvg(data: DataPoint[], w: number, h: number, title: string | undefined): string {
|
||||
const margin = { top: title ? 40 : 20, right: 20, bottom: 60, left: 50 };
|
||||
const plotW = w - margin.left - margin.right;
|
||||
const plotH = h - margin.top - margin.bottom;
|
||||
const maxVal = Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
const points: string[] = [];
|
||||
let dots = "";
|
||||
let labels = "";
|
||||
const rotateLabels = data.length > 8;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = margin.left + (i / Math.max(data.length - 1, 1)) * plotW;
|
||||
const y = margin.top + plotH - (data[i].value / maxVal) * plotH;
|
||||
points.push(`${x},${y}`);
|
||||
dots += `<circle cx="${x}" cy="${y}" r="3" fill="${PALETTE[0]}"/>`;
|
||||
|
||||
const ly = margin.top + plotH + 14;
|
||||
if (rotateLabels) {
|
||||
labels += `<text x="${x}" y="${ly}" text-anchor="end" font-size="10" font-family="sans-serif" transform="rotate(-45,${x},${ly})">${escapeXml(data[i].label)}</text>`;
|
||||
} else {
|
||||
labels += `<text x="${x}" y="${ly}" text-anchor="middle" font-size="10" font-family="sans-serif">${escapeXml(data[i].label)}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
const polyline = `<polyline points="${points.join(" ")}" fill="none" stroke="${PALETTE[0]}" stroke-width="2"/>`;
|
||||
const axisLine = `<line x1="${margin.left}" y1="${margin.top + plotH}" x2="${margin.left + plotW}" y2="${margin.top + plotH}" stroke="#333" stroke-width="1"/>`;
|
||||
|
||||
const titleSvg = title
|
||||
? `<text x="${w / 2}" y="24" text-anchor="middle" font-size="14" font-weight="bold" font-family="sans-serif">${escapeXml(title)}</text>`
|
||||
: "";
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="white"/>${titleSvg}${axisLine}${polyline}${dots}${labels}</svg>`;
|
||||
}
|
||||
|
||||
function renderPieSvg(data: DataPoint[], w: number, h: number, title: string | undefined): string {
|
||||
const cx = w / 2 - 60;
|
||||
const cy = h / 2 + (title ? 10 : 0);
|
||||
const r = Math.min(cx, cy) - 30;
|
||||
const total = data.reduce((s, d) => s + d.value, 0) || 1;
|
||||
|
||||
let angle = -Math.PI / 2;
|
||||
let slices = "";
|
||||
let legend = "";
|
||||
const legendX = cx + r + 30;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const fraction = data[i].value / total;
|
||||
const endAngle = angle + fraction * Math.PI * 2;
|
||||
|
||||
const x1 = cx + r * Math.cos(angle);
|
||||
const y1 = cy + r * Math.sin(angle);
|
||||
const x2 = cx + r * Math.cos(endAngle);
|
||||
const y2 = cy + r * Math.sin(endAngle);
|
||||
const largeArc = fraction > 0.5 ? 1 : 0;
|
||||
|
||||
slices += `<path d="M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc} 1 ${x2},${y2} Z" fill="${PALETTE[i % PALETTE.length]}"/>`;
|
||||
|
||||
const ly = 40 + i * 18;
|
||||
legend += `<rect x="${legendX}" y="${ly - 8}" width="10" height="10" fill="${PALETTE[i % PALETTE.length]}"/>`;
|
||||
legend += `<text x="${legendX + 14}" y="${ly}" font-size="10" font-family="sans-serif">${escapeXml(data[i].label)}</text>`;
|
||||
|
||||
angle = endAngle;
|
||||
}
|
||||
|
||||
const titleSvg = title
|
||||
? `<text x="${w / 2}" y="24" text-anchor="middle" font-size="14" font-weight="bold" font-family="sans-serif">${escapeXml(title)}</text>`
|
||||
: "";
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="white"/>${titleSvg}${slices}${legend}</svg>`;
|
||||
}
|
||||
|
||||
export function registerChartMaker(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "chart-maker",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("chart-maker is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const input = ctx.inputs[0];
|
||||
const base = input.filename.replace(/\.[^.]+$/, "");
|
||||
|
||||
let data: DataPoint[];
|
||||
try {
|
||||
data = parseInput(input.buffer, input.filename);
|
||||
} catch (err) {
|
||||
throw new Error(err instanceof Error ? err.message : "Failed to parse input");
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new Error("No data points found in input");
|
||||
}
|
||||
if (data.length > 100) {
|
||||
throw new Error("Too many data points (max 100)");
|
||||
}
|
||||
|
||||
// Validate numeric values
|
||||
for (const point of data) {
|
||||
if (Number.isNaN(point.value)) {
|
||||
throw new Error("Column 2 must be numeric");
|
||||
}
|
||||
}
|
||||
|
||||
let svg: string;
|
||||
switch (settings.kind) {
|
||||
case "bar":
|
||||
svg = renderBarSvg(data, settings.width, settings.height, settings.title);
|
||||
break;
|
||||
case "line":
|
||||
svg = renderLineSvg(data, settings.width, settings.height, settings.title);
|
||||
break;
|
||||
case "pie":
|
||||
svg = renderPieSvg(data, settings.width, settings.height, settings.title);
|
||||
break;
|
||||
}
|
||||
|
||||
const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
|
||||
|
||||
return {
|
||||
buffer: pngBuffer,
|
||||
filename: `${base}_chart.png`,
|
||||
contentType: "image/png",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerCircleCrop(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "circle-crop",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, _settings, filename) => {
|
||||
const meta = await sharp(inputBuffer).metadata();
|
||||
const w = meta.width ?? 1;
|
||||
const h = meta.height ?? 1;
|
||||
const d = Math.min(w, h);
|
||||
|
||||
// Extract centered square
|
||||
const left = Math.floor((w - d) / 2);
|
||||
const top = Math.floor((h - d) / 2);
|
||||
const squareBuf = await sharp(inputBuffer)
|
||||
.extract({ left, top, width: d, height: d })
|
||||
.toBuffer();
|
||||
|
||||
// Create SVG circle mask
|
||||
const r = d / 2;
|
||||
const mask = Buffer.from(
|
||||
`<svg width="${d}" height="${d}"><circle cx="${r}" cy="${r}" r="${r}" fill="white"/></svg>`,
|
||||
);
|
||||
|
||||
// Composite with dest-in blend to mask
|
||||
const buffer = await sharp(squareBuf)
|
||||
.ensureAlpha()
|
||||
.composite([{ input: await sharp(mask).resize(d, d).toBuffer(), blend: "dest-in" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_circle.png`,
|
||||
contentType: "image/png",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,10 +10,13 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
encodeBmp,
|
||||
encodeEps,
|
||||
encodeIco,
|
||||
encodeJp2,
|
||||
encodeJxl,
|
||||
encodePpm,
|
||||
encodeQoi,
|
||||
encodeTga,
|
||||
} from "../../lib/format-encoders.js";
|
||||
import { encodeHeic } from "../../lib/heic-converter.js";
|
||||
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
|
||||
@@ -56,16 +59,24 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
||||
jp2: "image/jp2",
|
||||
qoi: "image/x-qoi",
|
||||
psd: "image/vnd.adobe.photoshop",
|
||||
ppm: "image/x-portable-pixmap",
|
||||
eps: "application/postscript",
|
||||
tga: "image/x-tga",
|
||||
};
|
||||
|
||||
const CLI_ENCODERS: Record<string, (buf: Buffer, quality?: number) => Promise<Buffer>> = {
|
||||
bmp: encodeBmp,
|
||||
eps: encodeEps,
|
||||
ico: encodeIco,
|
||||
jp2: encodeJp2,
|
||||
jxl: encodeJxl,
|
||||
ppm: encodePpm,
|
||||
qoi: encodeQoi,
|
||||
tga: encodeTga,
|
||||
};
|
||||
|
||||
const ANIMATABLE_FORMATS = new Set(["gif", "webp"]);
|
||||
|
||||
const settingsSchema = z.object({
|
||||
format: z.enum([
|
||||
"jpg",
|
||||
@@ -82,6 +93,9 @@ const settingsSchema = z.object({
|
||||
"jp2",
|
||||
"qoi",
|
||||
"psd",
|
||||
"ppm",
|
||||
"eps",
|
||||
"tga",
|
||||
]),
|
||||
quality: z.number().min(1).max(100).optional(),
|
||||
});
|
||||
@@ -105,7 +119,14 @@ export function registerConvert(app: FastifyInstance) {
|
||||
};
|
||||
}
|
||||
|
||||
const sharpOpts = isSvgBuffer(inputBuffer) ? { density: 300 } : undefined;
|
||||
const inputExt = extname(filename).toLowerCase().replace(".", "");
|
||||
const sharpOpts: import("sharp").SharpOptions = isSvgBuffer(inputBuffer)
|
||||
? { density: 300 }
|
||||
: {};
|
||||
// Preserve animation frames when both input and output are animatable formats
|
||||
if (ANIMATABLE_FORMATS.has(inputExt) && ANIMATABLE_FORMATS.has(settings.format)) {
|
||||
sharpOpts.animated = true;
|
||||
}
|
||||
const image = sharp(inputBuffer, sharpOpts);
|
||||
|
||||
let buffer: Buffer;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
||||
|
||||
const settingsSchema = z.object({
|
||||
shadow: hexColor.default("#1e3a8a"),
|
||||
highlight: hexColor.default("#fbbf24"),
|
||||
});
|
||||
|
||||
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 registerDuotone(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "duotone",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const a = parseHex(settings.shadow);
|
||||
const b = parseHex(settings.highlight);
|
||||
|
||||
// Duotone math: output = shadow + (highlight - shadow) * luminance
|
||||
// .linear(multipliers, offsets) with per-channel arrays
|
||||
const multipliers = [(b.r - a.r) / 255, (b.g - a.g) / 255, (b.b - a.b) / 255];
|
||||
const offsets = [a.r, a.g, a.b];
|
||||
|
||||
// Grayscale to single channel, then expand back to 3-channel sRGB
|
||||
// so that .linear() can apply per-channel multipliers/offsets
|
||||
const grayBuf = await sharp(inputBuffer)
|
||||
.removeAlpha()
|
||||
.grayscale()
|
||||
.toColourspace("srgb")
|
||||
.toBuffer();
|
||||
|
||||
const buf = await sharp(grayBuf).linear(multipliers, offsets).toBuffer();
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const buffer = await sharp(buf)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
const ext = outputFormat.extension;
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_duotone.${ext}`,
|
||||
contentType: outputFormat.contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
@@ -9,6 +12,7 @@ import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../../lib/filename.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||
import { encodeMultiIco, hasMagick } from "../../lib/format-encoders.js";
|
||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||
|
||||
@@ -172,8 +176,36 @@ export function registerFavicon(app: FastifyInstance) {
|
||||
archive.append(buffer, { name: `${prefix}${icon.name}` });
|
||||
}
|
||||
|
||||
const ico32 = await sharp(file.buffer).resize(32, 32, { fit: "cover" }).png().toBuffer();
|
||||
archive.append(ico32, { name: `${prefix}favicon.ico` });
|
||||
// Generate a true multi-size ICO (16/32/48/256) via ImageMagick
|
||||
// when available; fall back to a single 32px PNG renamed .ico otherwise.
|
||||
const magickReady = await hasMagick();
|
||||
if (magickReady) {
|
||||
const icoId = randomUUID();
|
||||
const icoSizes = [16, 32, 48, 256];
|
||||
const icoPaths: string[] = [];
|
||||
const icoOutPath = join(tmpdir(), `favicon-${icoId}.ico`);
|
||||
try {
|
||||
for (const sz of icoSizes) {
|
||||
const pngPath = join(tmpdir(), `favicon-${icoId}-${sz}.png`);
|
||||
const buf = await sharp(file.buffer)
|
||||
.resize(sz, sz, { fit: "cover" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
await writeFile(pngPath, buf);
|
||||
icoPaths.push(pngPath);
|
||||
}
|
||||
await encodeMultiIco(icoPaths, icoOutPath);
|
||||
const icoData = await readFile(icoOutPath);
|
||||
archive.append(icoData, { name: `${prefix}favicon.ico` });
|
||||
} finally {
|
||||
for (const p of [...icoPaths, icoOutPath]) {
|
||||
await rm(p, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const ico32 = await sharp(file.buffer).resize(32, 32, { fit: "cover" }).png().toBuffer();
|
||||
archive.append(ico32, { name: `${prefix}favicon.ico` });
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
name: stem,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { extname } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerGifWebp(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "gif-webp",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, _settings, filename) => {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
|
||||
// Route-level extension guard: image modality has no 415 gate
|
||||
if (ext !== ".gif" && ext !== ".webp") {
|
||||
throw new InputValidationError("Only GIF and WebP inputs are supported");
|
||||
}
|
||||
|
||||
if (ext === ".gif") {
|
||||
// GIF -> WebP (preserving animation)
|
||||
const buffer = await sharp(inputBuffer, { animated: true }).webp().toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}.webp`,
|
||||
contentType: "image/webp",
|
||||
};
|
||||
}
|
||||
|
||||
// WebP -> GIF (preserving animation)
|
||||
const buffer = await sharp(inputBuffer, { animated: true }).gif().toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}.gif`,
|
||||
contentType: "image/gif",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerHistogram(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "histogram",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("histogram is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const inputBuffer = ctx.inputs[0].buffer;
|
||||
const filename = ctx.inputs[0].filename;
|
||||
|
||||
// Extract raw RGB pixel data (no alpha)
|
||||
const { data } = await sharp(inputBuffer)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
// Build 256-bin histograms per channel in a single pass
|
||||
const rBins = new Uint32Array(256);
|
||||
const gBins = new Uint32Array(256);
|
||||
const bBins = new Uint32Array(256);
|
||||
|
||||
let rSum = 0;
|
||||
let gSum = 0;
|
||||
let bSum = 0;
|
||||
const pixelCount = data.length / 3;
|
||||
|
||||
for (let i = 0; i < data.length; i += 3) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
rBins[r]++;
|
||||
gBins[g]++;
|
||||
bBins[b]++;
|
||||
rSum += r;
|
||||
gSum += g;
|
||||
bSum += b;
|
||||
}
|
||||
|
||||
// Find max bin value for normalization
|
||||
let maxBin = 0;
|
||||
let rMax = 0;
|
||||
let gMax = 0;
|
||||
let bMax = 0;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
if (rBins[i] > maxBin) maxBin = rBins[i];
|
||||
if (gBins[i] > maxBin) maxBin = gBins[i];
|
||||
if (bBins[i] > maxBin) maxBin = bBins[i];
|
||||
if (rBins[i] > rMax) rMax = rBins[i];
|
||||
if (gBins[i] > gMax) gMax = gBins[i];
|
||||
if (bBins[i] > bMax) bMax = bBins[i];
|
||||
}
|
||||
|
||||
// Render a 512x320 SVG with three semi-transparent polylines
|
||||
const svgW = 512;
|
||||
const svgH = 320;
|
||||
const scaleX = svgW / 255;
|
||||
const scaleY = maxBin > 0 ? svgH / maxBin : 1;
|
||||
|
||||
const buildPoints = (bins: Uint32Array): string => {
|
||||
const pts: string[] = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = Math.round(i * scaleX);
|
||||
const y = Math.round(svgH - bins[i] * scaleY);
|
||||
pts.push(`${x},${y}`);
|
||||
}
|
||||
return pts.join(" ");
|
||||
};
|
||||
|
||||
const svg = Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">` +
|
||||
`<rect width="100%" height="100%" fill="#1a1a2e"/>` +
|
||||
`<polyline points="${buildPoints(rBins)}" fill="none" stroke="rgba(255,0,0,0.6)" stroke-width="1.5"/>` +
|
||||
`<polyline points="${buildPoints(gBins)}" fill="none" stroke="rgba(0,255,0,0.6)" stroke-width="1.5"/>` +
|
||||
`<polyline points="${buildPoints(bBins)}" fill="none" stroke="rgba(0,128,255,0.6)" stroke-width="1.5"/>` +
|
||||
`</svg>`,
|
||||
);
|
||||
|
||||
// Rasterize to PNG
|
||||
const buffer = await sharp(svg).png().toBuffer();
|
||||
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_histogram.png`,
|
||||
contentType: "image/png",
|
||||
resultPayload: {
|
||||
mean: {
|
||||
r: Math.round(rSum / pixelCount),
|
||||
g: Math.round(gSum / pixelCount),
|
||||
b: Math.round(bSum / pixelCount),
|
||||
},
|
||||
max: {
|
||||
r: rMax,
|
||||
g: gMax,
|
||||
b: bMax,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
target: z.enum(["16:9", "9:16", "1:1", "4:3", "3:4"]).default("1:1"),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#ffffff"),
|
||||
});
|
||||
|
||||
/** Compute canvas dimensions for the given target aspect ratio. */
|
||||
export function canvasFor(w: number, h: number, target: string): { cw: number; ch: number } {
|
||||
const [tw, th] = target.split(":").map(Number);
|
||||
const targetRatio = tw / th;
|
||||
const srcRatio = w / h;
|
||||
|
||||
let cw: number;
|
||||
let ch: number;
|
||||
|
||||
if (srcRatio > targetRatio) {
|
||||
// Image is wider than target: expand height
|
||||
cw = w;
|
||||
ch = Math.round(w / targetRatio);
|
||||
} else {
|
||||
// Image is taller than target: expand width
|
||||
ch = h;
|
||||
cw = Math.round(h * targetRatio);
|
||||
}
|
||||
|
||||
return { cw, ch };
|
||||
}
|
||||
|
||||
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 registerImagePad(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "image-pad",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const meta = await sharp(inputBuffer).metadata();
|
||||
const w = meta.width ?? 1;
|
||||
const h = meta.height ?? 1;
|
||||
|
||||
const { cw, ch } = canvasFor(w, h, settings.target);
|
||||
const c = parseHex(settings.color);
|
||||
|
||||
const padTop = Math.floor((ch - h) / 2);
|
||||
const padBottom = ch - h - padTop;
|
||||
const padLeft = Math.floor((cw - w) / 2);
|
||||
const padRight = cw - w - padLeft;
|
||||
|
||||
const buf = await sharp(inputBuffer)
|
||||
.extend({
|
||||
top: padTop,
|
||||
bottom: padBottom,
|
||||
left: padLeft,
|
||||
right: padRight,
|
||||
background: { r: c.r, g: c.g, b: c.b, alpha: 1 },
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const buffer = await sharp(buf)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
const ext = outputFormat.extension;
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_padded.${ext}`,
|
||||
contentType: outputFormat.contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { registerAspectPad } from "./aspect-pad.js";
|
||||
import { registerAudioChannels } from "./audio-channels.js";
|
||||
import { registerAudioMetadata } from "./audio-metadata.js";
|
||||
import { registerAudioSpeed } from "./audio-speed.js";
|
||||
import { registerBarcodeGenerate } from "./barcode-generate.js";
|
||||
import { registerBarcodeRead } from "./barcode-read.js";
|
||||
import { registerBeautify } from "./beautify.js";
|
||||
import { registerBlurFaces } from "./blur-faces.js";
|
||||
@@ -17,6 +18,8 @@ import { registerBorder } from "./border.js";
|
||||
import { registerBulkRename } from "./bulk-rename.js";
|
||||
import { registerBurnSubtitles } from "./burn-subtitles.js";
|
||||
import { registerChangeFps } from "./change-fps.js";
|
||||
import { registerChartMaker } from "./chart-maker.js";
|
||||
import { registerCircleCrop } from "./circle-crop.js";
|
||||
import { registerCollage } from "./collage.js";
|
||||
import { registerColorBlindness } from "./color-blindness.js";
|
||||
import { registerColorPalette } from "./color-palette.js";
|
||||
@@ -39,6 +42,7 @@ import { registerCropPdf } from "./crop-pdf.js";
|
||||
import { registerCropVideo } from "./crop-video.js";
|
||||
import { registerCsvExcel } from "./csv-excel.js";
|
||||
import { registerCsvJson } from "./csv-json.js";
|
||||
import { registerDuotone } from "./duotone.js";
|
||||
import { registerEditMetadata } from "./edit-metadata.js";
|
||||
import { registerEmbedSubtitles } from "./embed-subtitles.js";
|
||||
import { registerEnhanceFaces } from "./enhance-faces.js";
|
||||
@@ -55,16 +59,20 @@ import { registerFindDuplicates } from "./find-duplicates.js";
|
||||
import { registerFlattenPdf } from "./flatten-pdf.js";
|
||||
import { registerGifToVideo } from "./gif-to-video.js";
|
||||
import { registerGifTools } from "./gif-tools.js";
|
||||
import { registerGifWebp } from "./gif-webp.js";
|
||||
import { registerGrayscalePdf } from "./grayscale-pdf.js";
|
||||
import { registerHistogram } from "./histogram.js";
|
||||
import { registerHtmlToImage } from "./html-to-image.js";
|
||||
import { registerHtmlToPdf } from "./html-to-pdf.js";
|
||||
import { registerImageEnhancement } from "./image-enhancement.js";
|
||||
import { registerImagePad } from "./image-pad.js";
|
||||
import { registerImageToBase64 } from "./image-to-base64.js";
|
||||
import { registerImageToPdf } from "./image-to-pdf.js";
|
||||
import { registerImagesToVideo } from "./images-to-video.js";
|
||||
import { registerInfo } from "./info.js";
|
||||
import { registerJsonXml } from "./json-xml.js";
|
||||
import { registerLinearizePdf } from "./linearize-pdf.js";
|
||||
import { registerLqipPlaceholder } from "./lqip-placeholder.js";
|
||||
import { registerMarkdownToDocx } from "./markdown-to-docx.js";
|
||||
import { registerMarkdownToHtml } from "./markdown-to-html.js";
|
||||
import { registerMarkdownToPdf } from "./markdown-to-pdf.js";
|
||||
@@ -89,6 +97,7 @@ import { registerPdfToText } from "./pdf-to-text.js";
|
||||
import { registerPdfToWord } from "./pdf-to-word.js";
|
||||
import { registerPdfaConvert } from "./pdfa-convert.js";
|
||||
import { registerPitchShift } from "./pitch-shift.js";
|
||||
import { registerPixelate } from "./pixelate.js";
|
||||
import { registerPowerpointToPdf } from "./powerpoint-to-pdf.js";
|
||||
import { registerProtectPdf } from "./protect-pdf.js";
|
||||
import { registerQrGenerate } from "./qr-generate.js";
|
||||
@@ -115,6 +124,7 @@ import { registerSplit } from "./split.js";
|
||||
import { registerSplitAudio } from "./split-audio.js";
|
||||
import { registerSplitCsv } from "./split-csv.js";
|
||||
import { registerSplitPdf } from "./split-pdf.js";
|
||||
import { registerSpriteSheet } from "./sprite-sheet.js";
|
||||
import { registerStabilizeVideo } from "./stabilize-video.js";
|
||||
import { registerStitch } from "./stitch.js";
|
||||
import { registerStripMetadata } from "./strip-metadata.js";
|
||||
@@ -134,6 +144,7 @@ import { registerVideoSpeed } from "./video-speed.js";
|
||||
import { registerVideoToFrames } from "./video-to-frames.js";
|
||||
import { registerVideoToGif } from "./video-to-gif.js";
|
||||
import { registerVideoToWebp } from "./video-to-webp.js";
|
||||
import { registerVignette } from "./vignette.js";
|
||||
import { registerVolumeAdjust } from "./volume-adjust.js";
|
||||
import { registerWatermarkImage } from "./watermark-image.js";
|
||||
import { registerWatermarkPdf } from "./watermark-pdf.js";
|
||||
@@ -201,6 +212,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "color-palette", register: registerColorPalette },
|
||||
{ id: "qr-generate", register: registerQrGenerate },
|
||||
{ id: "html-to-image", register: registerHtmlToImage },
|
||||
{ id: "barcode-generate", register: registerBarcodeGenerate },
|
||||
{ id: "barcode-read", register: registerBarcodeRead },
|
||||
{ id: "image-to-base64", register: registerImageToBase64 },
|
||||
|
||||
@@ -210,11 +222,20 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "split", register: registerSplit },
|
||||
{ id: "border", register: registerBorder },
|
||||
{ id: "beautify", register: registerBeautify },
|
||||
{ id: "circle-crop", register: registerCircleCrop },
|
||||
{ id: "duotone", register: registerDuotone },
|
||||
{ id: "histogram", register: registerHistogram },
|
||||
{ id: "image-pad", register: registerImagePad },
|
||||
{ id: "lqip-placeholder", register: registerLqipPlaceholder },
|
||||
{ id: "pixelate", register: registerPixelate },
|
||||
{ id: "sprite-sheet", register: registerSpriteSheet },
|
||||
{ id: "vignette", register: registerVignette },
|
||||
|
||||
// Format & Conversion
|
||||
{ id: "svg-to-raster", register: registerSvgToRaster },
|
||||
{ id: "vectorize", register: registerVectorize },
|
||||
{ id: "gif-tools", register: registerGifTools },
|
||||
{ id: "gif-webp", register: registerGifWebp },
|
||||
{ id: "pdf-to-image", register: registerPdfToImage },
|
||||
|
||||
// Optimization extras
|
||||
@@ -313,6 +334,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "to-epub", register: registerToEpub },
|
||||
|
||||
// Data Files
|
||||
{ id: "chart-maker", register: registerChartMaker },
|
||||
{ id: "create-zip", register: registerCreateZip },
|
||||
{ id: "csv-excel", register: registerCsvExcel },
|
||||
{ id: "csv-json", register: registerCsvJson },
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
width: z.number().int().min(4).max(64).default(16),
|
||||
blur: z.number().min(0).max(20).default(2),
|
||||
});
|
||||
|
||||
export function registerLqipPlaceholder(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "lqip-placeholder",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("lqip-placeholder is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const inputBuffer = ctx.inputs[0].buffer;
|
||||
const filename = ctx.inputs[0].filename;
|
||||
|
||||
let pipeline = sharp(inputBuffer).resize(settings.width);
|
||||
|
||||
if (settings.blur > 0) {
|
||||
pipeline = pipeline.blur(settings.blur);
|
||||
}
|
||||
|
||||
const buffer = await pipeline.webp({ quality: 50 }).toBuffer();
|
||||
|
||||
const meta = await sharp(buffer).metadata();
|
||||
const dataUri = `data:image/webp;base64,${buffer.toString("base64")}`;
|
||||
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_lqip.webp`,
|
||||
contentType: "image/webp",
|
||||
resultPayload: {
|
||||
dataUri,
|
||||
width: meta.width ?? settings.width,
|
||||
height: meta.height ?? 0,
|
||||
bytes: buffer.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
blockSize: z.number().int().min(2).max(128).default(12),
|
||||
region: z
|
||||
.object({
|
||||
left: z.number().int().min(0),
|
||||
top: z.number().int().min(0),
|
||||
width: z.number().int().min(1),
|
||||
height: z.number().int().min(1),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export function registerPixelate(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "pixelate",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const meta = await sharp(inputBuffer).metadata();
|
||||
const w = meta.width ?? 1;
|
||||
const h = meta.height ?? 1;
|
||||
const bs = settings.blockSize;
|
||||
|
||||
let buf: Buffer;
|
||||
|
||||
if (settings.region) {
|
||||
const r = settings.region;
|
||||
// Validate region bounds
|
||||
if (r.left + r.width > w || r.top + r.height > h) {
|
||||
throw new InputValidationError("Region exceeds image bounds");
|
||||
}
|
||||
|
||||
// Extract region, pixelate it, composite back
|
||||
const rw = Math.max(1, Math.round(r.width / bs));
|
||||
const rh = Math.max(1, Math.round(r.height / bs));
|
||||
|
||||
const pixelatedRegion = await sharp(inputBuffer)
|
||||
.extract({ left: r.left, top: r.top, width: r.width, height: r.height })
|
||||
.resize(rw, rh, { kernel: sharp.kernel.nearest })
|
||||
.resize(r.width, r.height, { kernel: sharp.kernel.nearest })
|
||||
.toBuffer();
|
||||
|
||||
buf = await sharp(inputBuffer)
|
||||
.composite([{ input: pixelatedRegion, left: r.left, top: r.top }])
|
||||
.toBuffer();
|
||||
} else {
|
||||
// Full image pixelation
|
||||
const smallW = Math.max(1, Math.round(w / bs));
|
||||
const smallH = Math.max(1, Math.round(h / bs));
|
||||
|
||||
buf = await sharp(inputBuffer)
|
||||
.resize(smallW, smallH, { kernel: sharp.kernel.nearest })
|
||||
.resize(w, h, { kernel: sharp.kernel.nearest })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const buffer = await sharp(buf)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
const ext = outputFormat.extension;
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_pixelated.${ext}`,
|
||||
contentType: outputFormat.contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import QRCode from "qrcode";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { putObject } from "../../lib/object-storage.js";
|
||||
@@ -17,6 +18,11 @@ const settingsSchema = z.object({
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#FFFFFF"),
|
||||
logoDataUri: z
|
||||
.string()
|
||||
.regex(/^data:image\/(png|jpeg);base64,[A-Za-z0-9+/=]+$/)
|
||||
.max(700000)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -43,9 +49,13 @@ export function registerQrGenerate(app: FastifyInstance) {
|
||||
const settings = result.data;
|
||||
|
||||
try {
|
||||
const buffer = await QRCode.toBuffer(settings.text, {
|
||||
// When a logo is present, force max error correction so the QR
|
||||
// remains scannable despite the logo occluding the center.
|
||||
const ecLevel = settings.logoDataUri ? "H" : settings.errorCorrection;
|
||||
|
||||
let buffer = await QRCode.toBuffer(settings.text, {
|
||||
width: settings.size,
|
||||
errorCorrectionLevel: settings.errorCorrection,
|
||||
errorCorrectionLevel: ecLevel,
|
||||
color: {
|
||||
dark: settings.foreground,
|
||||
light: settings.background,
|
||||
@@ -54,6 +64,34 @@ export function registerQrGenerate(app: FastifyInstance) {
|
||||
margin: 2,
|
||||
});
|
||||
|
||||
if (settings.logoDataUri) {
|
||||
// Decode the data-URI base64 payload into a buffer
|
||||
const base64Part = settings.logoDataUri.split(",")[1];
|
||||
let logoBuffer: Buffer;
|
||||
try {
|
||||
logoBuffer = Buffer.from(base64Part, "base64");
|
||||
// Validate that sharp can decode it
|
||||
await sharp(logoBuffer).metadata();
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid logo image" });
|
||||
}
|
||||
|
||||
// Resize logo to 22% of QR size and composite centered
|
||||
const logoSize = Math.round(settings.size * 0.22);
|
||||
const resizedLogo = await sharp(logoBuffer)
|
||||
.resize(logoSize, logoSize, {
|
||||
fit: "contain",
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
buffer = await sharp(buffer)
|
||||
.composite([{ input: resizedLogo, gravity: "centre" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const jobId = randomUUID();
|
||||
const filename = "qrcode.png";
|
||||
await putObject(`outputs/${jobId}/${filename}`, buffer);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
columns: z.number().int().min(1).max(16).default(4),
|
||||
padding: z.number().int().min(0).max(64).default(0),
|
||||
background: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#ffffff"),
|
||||
});
|
||||
|
||||
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 registerSpriteSheet(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "sprite-sheet",
|
||||
maxInputs: 64,
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("sprite-sheet is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
if (ctx.inputs.length < 2) {
|
||||
throw new InputValidationError("Provide at least two images");
|
||||
}
|
||||
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const n = ctx.inputs.length;
|
||||
const cols = Math.min(settings.columns, n);
|
||||
const rows = Math.ceil(n / cols);
|
||||
|
||||
// Use the FIRST image's dimensions as the cell size
|
||||
const firstMeta = await sharp(ctx.inputs[0].buffer).metadata();
|
||||
const cellW = firstMeta.width ?? 1;
|
||||
const cellH = firstMeta.height ?? 1;
|
||||
|
||||
const pad = settings.padding;
|
||||
const canvasW = cols * cellW + (cols - 1) * pad;
|
||||
const canvasH = rows * cellH + (rows - 1) * pad;
|
||||
|
||||
const bg = parseHex(settings.background);
|
||||
const composites: sharp.OverlayOptions[] = [];
|
||||
const frames: Array<{
|
||||
index: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const left = col * (cellW + pad);
|
||||
const top = row * (cellH + pad);
|
||||
|
||||
// Resize each image to cover the cell size (first image keeps its size)
|
||||
let cellBuf: Buffer;
|
||||
if (i === 0) {
|
||||
cellBuf = ctx.inputs[i].buffer;
|
||||
} else {
|
||||
cellBuf = await sharp(ctx.inputs[i].buffer)
|
||||
.resize(cellW, cellH, { fit: "cover" })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
composites.push({ input: cellBuf, left, top });
|
||||
frames.push({ index: i, left, top, width: cellW, height: cellH });
|
||||
}
|
||||
|
||||
const buffer = await sharp({
|
||||
create: {
|
||||
width: canvasW,
|
||||
height: canvasH,
|
||||
channels: 4,
|
||||
background: { r: bg.r, g: bg.g, b: bg.b, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
buffer,
|
||||
filename: "sprite.png",
|
||||
contentType: "image/png",
|
||||
resultPayload: { frames },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
strength: z.number().min(0.1).max(1).default(0.5),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#000000"),
|
||||
});
|
||||
|
||||
export function registerVignette(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "vignette",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const meta = await sharp(inputBuffer).metadata();
|
||||
const w = meta.width ?? 1;
|
||||
const h = meta.height ?? 1;
|
||||
|
||||
// Build radial-gradient SVG overlay
|
||||
const svg = Buffer.from(
|
||||
`<svg width="${w}" height="${h}">` +
|
||||
`<defs><radialGradient id="v" cx="50%" cy="50%" r="70%">` +
|
||||
`<stop offset="50%" stop-color="${settings.color}" stop-opacity="0"/>` +
|
||||
`<stop offset="100%" stop-color="${settings.color}" stop-opacity="${settings.strength}"/>` +
|
||||
`</radialGradient></defs>` +
|
||||
`<rect width="100%" height="100%" fill="url(#v)"/>` +
|
||||
`</svg>`,
|
||||
);
|
||||
|
||||
const overlay = await sharp(svg).resize(w, h).toBuffer();
|
||||
|
||||
const buf = await sharp(inputBuffer)
|
||||
.composite([{ input: overlay, blend: "over" }])
|
||||
.toBuffer();
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const buffer = await sharp(buf)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
const ext = outputFormat.extension;
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_vignette.${ext}`,
|
||||
contentType: outputFormat.contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user