refactor: rename Tool.alpha to Tool.experimental

This commit is contained in:
Siddharth Kumar Sah
2026-03-26 01:10:51 +08:00
parent ab370a74fe
commit 585d66f0c9
178 changed files with 5637 additions and 4082 deletions
+64 -70
View File
@@ -1,92 +1,86 @@
import sharp from "sharp";
import jsQR from "jsqr";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { basename } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import jsQR from "jsqr";
import sharp from "sharp";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* Read QR codes and barcodes from uploaded images.
*/
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";
app.post("/api/v1/tools/barcode-read", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
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);
filename = basename(part.filename ?? "image");
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);
filename = basename(part.filename ?? "image");
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
// Validate the uploaded image
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
// Validate the uploaded image
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
// Convert to RGBA raw pixel data for jsQR
const image = sharp(fileBuffer);
const metadata = await image.metadata();
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
try {
// Convert to RGBA raw pixel data for jsQR
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 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",
});
}
const code = jsQR(
new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length),
width,
height,
);
if (!code) {
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,
},
});
} catch (err) {
return reply.status(422).send({
error: "Barcode reading failed",
details: err instanceof Error ? err.message : "Unknown error",
found: false,
text: null,
message: "No QR code found in the image",
});
}
},
);
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,
},
});
} catch (err) {
return reply.status(422).send({
error: "Barcode reading failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+94 -98
View File
@@ -1,116 +1,112 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import { blurFaces } from "@stirling-image/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* Face detection and blurring route.
* Uses MediaPipe for detection, PIL for blurring.
*/
export function registerBlurFaces(app: FastifyInstance) {
app.post(
"/api/v1/tools/blur-faces",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
app.post("/api/v1/tools/blur-faces", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | 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);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
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);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const result = await blurFaces(
fileBuffer,
join(workspacePath, "output"),
{
blurRadius: settings.blurRadius ?? 30,
sensitivity: settings.sensitivity ?? 0.5,
},
onProgress,
);
// Save output
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const result = await blurFaces(
fileBuffer,
join(workspacePath, "output"),
{
blurRadius: settings.blurRadius ?? 30,
sensitivity: settings.sensitivity ?? 0.5,
},
onProgress,
);
// Save output
const outputFilename =
filename.replace(/\.[^.]+$/, "") + "_blurred.png";
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
});
} catch (err) {
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
});
} catch (err) {
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+14 -12
View File
@@ -1,15 +1,21 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
borderWidth: z.number().min(0).max(200).default(10),
borderColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
borderColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#000000"),
cornerRadius: z.number().min(0).max(500).default(0),
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"),
shadowColor: z
.string()
.regex(/^#[0-9a-fA-F]{6,8}$/)
.default("#00000080"),
});
export function registerBorder(app: FastifyInstance) {
@@ -41,8 +47,8 @@ export function registerBorder(app: FastifyInstance) {
// 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 _outerW = w + totalBorder * 2 + shadowPad * 2;
const _outerH = h + totalBorder * 2 + shadowPad * 2;
// Create a white padding region behind the image
const paddingRect = await sharp({
@@ -85,13 +91,9 @@ export function registerBorder(app: FastifyInstance) {
</svg>`,
);
const maskBuffer = await sharp(roundedMask)
.resize(maskW, maskH)
.toBuffer();
const maskBuffer = await sharp(roundedMask).resize(maskW, maskH).toBuffer();
result = sharp(buf).composite([
{ input: maskBuffer, blend: "dest-in" },
]);
result = sharp(buf).composite([{ input: maskBuffer, blend: "dest-in" }]);
}
const buffer = await result.png().toBuffer();
+83 -84
View File
@@ -1,8 +1,8 @@
import { z } from "zod";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { basename, extname } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
const settingsSchema = z.object({
pattern: z.string().min(1).max(200).default("image-{{index}}"),
@@ -14,90 +14,89 @@ const settingsSchema = z.object({
* No image processing - just renames.
*/
export function registerBulkRename(app: FastifyInstance) {
app.post(
"/api/v1/tools/bulk-rename",
async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | null = null;
app.post("/api/v1/tools/bulk-rename", async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | 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);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `file-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
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);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `file-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No files provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
const jobId = randomUUID();
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
for (let i = 0; i < files.length; i++) {
const ext = extname(files[i].filename);
const index = settings.startIndex + i;
const padded = String(index).padStart(
String(files.length + settings.startIndex).length,
"0",
);
const newName =
settings.pattern
.replace(/\{\{index\}\}/g, String(index))
.replace(/\{\{padded\}\}/g, padded)
.replace(/\{\{original\}\}/g, files[i].filename.replace(ext, "")) + ext;
archive.append(files[i].buffer, { name: basename(newName) });
}
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Rename failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No files provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
const jobId = randomUUID();
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
for (let i = 0; i < files.length; i++) {
const ext = extname(files[i].filename);
const index = settings.startIndex + i;
const padded = String(index).padStart(String(files.length + settings.startIndex).length, "0");
const newName =
settings.pattern
.replace(/\{\{index\}\}/g, String(index))
.replace(/\{\{padded\}\}/g, padded)
.replace(/\{\{original\}\}/g, files[i].filename.replace(ext, "")) +
ext;
archive.append(files[i].buffer, { name: basename(newName) });
}
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Rename failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
}
},
);
}
});
}
+113 -111
View File
@@ -1,16 +1,19 @@
import { z } from "zod";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { createWorkspace } from "../../lib/workspace.js";
import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
layout: z.enum(["2x2", "3x3", "1x3", "2x1", "3x1", "1x2"]).default("2x2"),
gap: z.number().min(0).max(50).default(4),
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FFFFFF"),
backgroundColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FFFFFF"),
});
function parseLayout(layout: string): { cols: number; rows: number } {
@@ -19,126 +22,125 @@ function parseLayout(layout: string): { cols: number; rows: number } {
}
export function registerCollage(app: FastifyInstance) {
app.post(
"/api/v1/tools/collage",
async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | null = null;
app.post("/api/v1/tools/collage", async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | 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);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
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);
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No images provided" });
}
// Validate all files
for (const file of files) {
const validation = await validateImageBuffer(file.buffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
if (files.length === 0) {
return reply.status(400).send({ error: "No images provided" });
}
// Validate all files
for (const file of files) {
const validation = await validateImageBuffer(file.buffer);
if (!validation.valid) {
return reply
.status(400)
.send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
}
}
try {
const { cols, rows } = parseLayout(settings.layout);
const totalSlots = cols * rows;
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
// Determine cell size based on first image
const firstMeta = await sharp(files[0].buffer).metadata();
const cellW = firstMeta.width ?? 400;
const cellH = firstMeta.height ?? 400;
try {
const { cols, rows } = parseLayout(settings.layout);
const totalSlots = cols * rows;
// Canvas dimensions
const canvasW = cellW * cols + settings.gap * (cols + 1);
const canvasH = cellH * rows + settings.gap * (rows + 1);
// Determine cell size based on first image
const firstMeta = await sharp(files[0].buffer).metadata();
const cellW = firstMeta.width ?? 400;
const cellH = firstMeta.height ?? 400;
// Parse background color
const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16);
const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16);
const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16);
// Canvas dimensions
const canvasW = cellW * cols + settings.gap * (cols + 1);
const canvasH = cellH * rows + settings.gap * (rows + 1);
// Create canvas
const composites: sharp.OverlayOptions[] = [];
// Parse background color
const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16);
const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16);
const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16);
for (let i = 0; i < Math.min(files.length, totalSlots); i++) {
const row = Math.floor(i / cols);
const col = i % cols;
const x = settings.gap + col * (cellW + settings.gap);
const y = settings.gap + row * (cellH + settings.gap);
// Create canvas
const composites: sharp.OverlayOptions[] = [];
const resized = await sharp(files[i].buffer)
.resize(cellW, cellH, { fit: "cover" })
.toBuffer();
for (let i = 0; i < Math.min(files.length, totalSlots); i++) {
const row = Math.floor(i / cols);
const col = i % cols;
const x = settings.gap + col * (cellW + settings.gap);
const y = settings.gap + row * (cellH + settings.gap);
composites.push({ input: resized, top: y, left: x });
}
const result = await sharp({
create: {
width: canvasW,
height: canvasH,
channels: 3,
background: { r: bgR, g: bgG, b: bgB },
},
})
.composite(composites)
.png()
const resized = await sharp(files[i].buffer)
.resize(cellW, cellH, { fit: "cover" })
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "collage.png";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Collage creation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
composites.push({ input: resized, top: y, left: x });
}
},
);
const result = await sharp({
create: {
width: canvasW,
height: canvasH,
channels: 3,
background: { r: bgR, g: bgG, b: bgB },
},
})
.composite(composites)
.png()
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "collage.png";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Collage creation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+7 -18
View File
@@ -1,16 +1,16 @@
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import {
brightness as adjustBrightness,
contrast as adjustContrast,
saturation as adjustSaturation,
colorChannels,
grayscale,
sepia,
invert,
sepia,
} from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
brightness: z.number().min(-100).max(100).default(0),
@@ -19,9 +19,7 @@ const settingsSchema = z.object({
red: z.number().min(0).max(200).default(100),
green: z.number().min(0).max(200).default(100),
blue: z.number().min(0).max(200).default(100),
effect: z
.enum(["none", "grayscale", "sepia", "invert"])
.default("none"),
effect: z.enum(["none", "grayscale", "sepia", "invert"]).default("none"),
});
/**
@@ -32,12 +30,7 @@ const settingsSchema = z.object({
*/
export function registerColorAdjustments(app: FastifyInstance) {
// Register the same handler under all four color-related tool IDs
const toolIds = [
"brightness-contrast",
"saturation",
"color-channels",
"color-effects",
];
const toolIds = ["brightness-contrast", "saturation", "color-channels", "color-effects"];
for (const toolId of toolIds) {
createToolRoute(app, {
@@ -66,11 +59,7 @@ export function registerColorAdjustments(app: FastifyInstance) {
}
// Apply color channels (only if not default 100/100/100)
if (
settings.red !== 100 ||
settings.green !== 100 ||
settings.blue !== 100
) {
if (settings.red !== 100 || settings.green !== 100 || settings.blue !== 100) {
image = await colorChannels(image, {
red: settings.red,
green: settings.green,
+45 -50
View File
@@ -1,6 +1,6 @@
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { basename } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
/**
* Simple k-means-like color quantization to extract dominant colors.
@@ -19,16 +19,14 @@ function extractColors(pixels: Buffer, channelCount: number, maxColors: number):
}
// Sort by frequency and pick top colors
const sorted = [...colorMap.entries()]
.sort((a, b) => b[1] - a[1]);
const sorted = [...colorMap.entries()].sort((a, b) => b[1] - a[1]);
// Filter similar colors (merge colors within distance 40)
const results: Array<{ r: number; g: number; b: number; count: number }> = [];
for (const [key, count] of sorted) {
const [r, g, b] = key.split(",").map(Number);
const tooClose = results.some(
(c) =>
Math.abs(c.r - r) + Math.abs(c.g - g) + Math.abs(c.b - b) < 48,
(c) => Math.abs(c.r - r) + Math.abs(c.g - g) + Math.abs(c.b - b) < 48,
);
if (!tooClose) {
results.push({ r, g, b, count });
@@ -43,56 +41,53 @@ function extractColors(pixels: Buffer, channelCount: number, maxColors: number):
}
export function registerColorPalette(app: FastifyInstance) {
app.post(
"/api/v1/tools/color-palette",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
app.post("/api/v1/tools/color-palette", async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
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);
filename = basename(part.filename ?? "image");
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);
filename = basename(part.filename ?? "image");
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
try {
// Resize to small image for analysis
const raw = await sharp(fileBuffer)
.resize(50, 50, { fit: "fill" })
.removeAlpha()
.raw()
.toBuffer();
try {
// Resize to small image for analysis
const raw = await sharp(fileBuffer)
.resize(50, 50, { fit: "fill" })
.removeAlpha()
.raw()
.toBuffer();
const colors = extractColors(raw, 3, 8);
const colors = extractColors(raw, 3, 8);
return reply.send({
filename,
colors,
count: colors.length,
});
} catch (err) {
return reply.status(422).send({
error: "Color extraction failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
filename,
colors,
count: colors.length,
});
} catch (err) {
return reply.status(422).send({
error: "Color extraction failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+101 -96
View File
@@ -1,112 +1,117 @@
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { createWorkspace } from "../../lib/workspace.js";
/**
* Compare two images: compute a pixel-level diff and similarity score.
*/
export function registerCompare(app: FastifyInstance) {
app.post(
"/api/v1/tools/compare",
async (request, reply) => {
let bufferA: Buffer | null = null;
let bufferB: Buffer | null = null;
app.post("/api/v1/tools/compare", async (request, reply) => {
let bufferA: Buffer | null = null;
let bufferB: 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);
}
const buf = Buffer.concat(chunks);
if (!bufferA) {
bufferA = buf;
} else {
bufferB = buf;
}
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);
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!bufferA || !bufferB) {
return reply.status(400).send({ error: "Two image files are required for comparison" });
}
try {
// Normalize both to same size for comparison
const metaA = await sharp(bufferA).metadata();
const metaB = await sharp(bufferB).metadata();
const w = Math.max(metaA.width ?? 100, metaB.width ?? 100);
const h = Math.max(metaA.height ?? 100, metaB.height ?? 100);
const rawA = await sharp(bufferA).resize(w, h, { fit: "fill" }).ensureAlpha().raw().toBuffer();
const rawB = await sharp(bufferB).resize(w, h, { fit: "fill" }).ensureAlpha().raw().toBuffer();
// Compute pixel diff
const diffPixels = Buffer.alloc(w * h * 4);
let totalDiff = 0;
const pixelCount = w * h;
for (let i = 0; i < rawA.length; i += 4) {
const dr = Math.abs(rawA[i] - rawB[i]);
const dg = Math.abs(rawA[i + 1] - rawB[i + 1]);
const db = Math.abs(rawA[i + 2] - rawB[i + 2]);
const pixelDiff = (dr + dg + db) / 3;
totalDiff += pixelDiff;
// Red tint for differences, transparent for identical
if (pixelDiff > 10) {
diffPixels[i] = 255; // R
diffPixels[i + 1] = 0; // G
diffPixels[i + 2] = 0; // B
diffPixels[i + 3] = Math.min(255, Math.round(pixelDiff * 3)); // A
const buf = Buffer.concat(chunks);
if (!bufferA) {
bufferA = buf;
} else {
// Slightly show original
diffPixels[i] = rawA[i];
diffPixels[i + 1] = rawA[i + 1];
diffPixels[i + 2] = rawA[i + 2];
diffPixels[i + 3] = 128;
bufferB = buf;
}
}
const similarity = Math.max(0, 100 - (totalDiff / (pixelCount * 255)) * 100);
const diffBuffer = await sharp(diffPixels, {
raw: { width: w, height: h, channels: 4 },
})
.png()
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const diffFilename = "diff.png";
const outputPath = join(workspacePath, "output", diffFilename);
await writeFile(outputPath, diffBuffer);
return reply.send({
jobId,
similarity: Math.round(similarity * 100) / 100,
dimensions: { width: w, height: h },
downloadUrl: `/api/v1/download/${jobId}/${diffFilename}`,
originalSize: bufferA.length + bufferB.length,
processedSize: diffBuffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "Comparison failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!bufferA || !bufferB) {
return reply.status(400).send({ error: "Two image files are required for comparison" });
}
try {
// Normalize both to same size for comparison
const metaA = await sharp(bufferA).metadata();
const metaB = await sharp(bufferB).metadata();
const w = Math.max(metaA.width ?? 100, metaB.width ?? 100);
const h = Math.max(metaA.height ?? 100, metaB.height ?? 100);
const rawA = await sharp(bufferA)
.resize(w, h, { fit: "fill" })
.ensureAlpha()
.raw()
.toBuffer();
const rawB = await sharp(bufferB)
.resize(w, h, { fit: "fill" })
.ensureAlpha()
.raw()
.toBuffer();
// Compute pixel diff
const diffPixels = Buffer.alloc(w * h * 4);
let totalDiff = 0;
const pixelCount = w * h;
for (let i = 0; i < rawA.length; i += 4) {
const dr = Math.abs(rawA[i] - rawB[i]);
const dg = Math.abs(rawA[i + 1] - rawB[i + 1]);
const db = Math.abs(rawA[i + 2] - rawB[i + 2]);
const pixelDiff = (dr + dg + db) / 3;
totalDiff += pixelDiff;
// Red tint for differences, transparent for identical
if (pixelDiff > 10) {
diffPixels[i] = 255; // R
diffPixels[i + 1] = 0; // G
diffPixels[i + 2] = 0; // B
diffPixels[i + 3] = Math.min(255, Math.round(pixelDiff * 3)); // A
} else {
// Slightly show original
diffPixels[i] = rawA[i];
diffPixels[i + 1] = rawA[i + 1];
diffPixels[i + 2] = rawA[i + 2];
diffPixels[i + 3] = 128;
}
}
const similarity = Math.max(0, 100 - (totalDiff / (pixelCount * 255)) * 100);
const diffBuffer = await sharp(diffPixels, {
raw: { width: w, height: h, channels: 4 },
})
.png()
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const diffFilename = "diff.png";
const outputPath = join(workspacePath, "output", diffFilename);
await writeFile(outputPath, diffBuffer);
return reply.send({
jobId,
similarity: Math.round(similarity * 100) / 100,
dimensions: { width: w, height: h },
downloadUrl: `/api/v1/download/${jobId}/${diffFilename}`,
originalSize: bufferA.length + bufferB.length,
processedSize: diffBuffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "Comparison failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+109 -103
View File
@@ -1,11 +1,11 @@
import { z } from "zod";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createWorkspace } from "../../lib/workspace.js";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { sanitizeFilename } from "../../lib/filename.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
x: z.number().min(0).default(0),
@@ -13,119 +13,125 @@ const settingsSchema = z.object({
opacity: z.number().min(0).max(100).default(100),
blendMode: z
.enum([
"over", "multiply", "screen", "overlay",
"darken", "lighten", "hard-light", "soft-light",
"difference", "exclusion",
"over",
"multiply",
"screen",
"overlay",
"darken",
"lighten",
"hard-light",
"soft-light",
"difference",
"exclusion",
])
.default("over"),
});
export function registerCompose(app: FastifyInstance) {
app.post(
"/api/v1/tools/compose",
async (request, reply) => {
let baseBuffer: Buffer | null = null;
let overlayBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
app.post("/api/v1/tools/compose", async (request, reply) => {
let baseBuffer: Buffer | null = null;
let overlayBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | 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);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "overlay") {
overlayBuffer = buf;
} else {
baseBuffer = buf;
filename = sanitizeFilename(part.filename ?? "image");
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
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);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "overlay") {
overlayBuffer = buf;
} else {
baseBuffer = buf;
filename = sanitizeFilename(part.filename ?? "image");
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!baseBuffer || baseBuffer.length === 0) {
return reply.status(400).send({ error: "No base image provided" });
}
if (!overlayBuffer || overlayBuffer.length === 0) {
return reply.status(400).send({ error: "No overlay image provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
// Apply opacity to overlay if needed
let processedOverlay = overlayBuffer;
if (settings.opacity < 100) {
const overlayImg = sharp(overlayBuffer).ensureAlpha();
const overlayBuf = await overlayImg.toBuffer();
const overlayMeta = await sharp(overlayBuf).metadata();
const oW = overlayMeta.width ?? 100;
const oH = overlayMeta.height ?? 100;
const opacityMask = await sharp({
create: {
width: oW,
height: oH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: settings.opacity / 100 },
},
})
.png()
.toBuffer();
processedOverlay = await sharp(overlayBuf)
.composite([{ input: opacityMask, blend: "dest-in" }])
.toBuffer();
}
if (!baseBuffer || baseBuffer.length === 0) {
return reply.status(400).send({ error: "No base image provided" });
}
if (!overlayBuffer || overlayBuffer.length === 0) {
return reply.status(400).send({ error: "No overlay image provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
// Apply opacity to overlay if needed
let processedOverlay = overlayBuffer;
if (settings.opacity < 100) {
const overlayImg = sharp(overlayBuffer).ensureAlpha();
const overlayBuf = await overlayImg.toBuffer();
const overlayMeta = await sharp(overlayBuf).metadata();
const oW = overlayMeta.width ?? 100;
const oH = overlayMeta.height ?? 100;
const opacityMask = await sharp({
create: {
width: oW,
height: oH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: settings.opacity / 100 },
},
})
.png()
.toBuffer();
processedOverlay = await sharp(overlayBuf)
.composite([{ input: opacityMask, blend: "dest-in" }])
.toBuffer();
}
const result = await sharp(baseBuffer)
.composite([{
const result = await sharp(baseBuffer)
.composite([
{
input: processedOverlay,
top: settings.y,
left: settings.x,
blend: settings.blendMode as import("sharp").Blend,
}])
.toBuffer();
},
])
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
originalSize: baseBuffer.length,
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Image processing failed",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
originalSize: baseBuffer.length,
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Image processing failed",
});
}
});
}
+3 -3
View File
@@ -1,8 +1,8 @@
import { compress } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { compress } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
mode: z.enum(["quality", "targetSize"]).default("quality"),
+5 -6
View File
@@ -1,9 +1,9 @@
import { extname } from "node:path";
import { convert } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { convert } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { extname } from "node:path";
const FORMAT_CONTENT_TYPES: Record<string, string> = {
jpg: "image/jpeg",
@@ -33,8 +33,7 @@ export function registerConvert(app: FastifyInstance) {
const baseName = ext ? filename.slice(0, -ext.length) : filename;
const outputFilename = `${baseName}.${settings.format}`;
const contentType =
FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
return { buffer, filename: outputFilename, contentType };
},
+3 -3
View File
@@ -1,8 +1,8 @@
import { crop } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { crop } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
left: z.number().int().min(0),
+100 -104
View File
@@ -1,122 +1,118 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import { inpaint } from "@stirling-image/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* Object eraser / inpainting route.
* Accepts an image and a mask image, erases masked areas.
*/
export function registerEraseObject(app: FastifyInstance) {
app.post(
"/api/v1/tools/erase-object",
async (request: FastifyRequest, reply: FastifyReply) => {
let imageBuffer: Buffer | null = null;
let maskBuffer: Buffer | null = null;
let filename = "image";
let clientJobId: string | null = null;
app.post("/api/v1/tools/erase-object", async (request: FastifyRequest, reply: FastifyReply) => {
let imageBuffer: Buffer | null = null;
let maskBuffer: Buffer | null = null;
let filename = "image";
let clientJobId: string | 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);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "mask") {
maskBuffer = buf;
} else {
imageBuffer = buf;
filename = basename(part.filename ?? "image");
}
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
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);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "mask") {
maskBuffer = buf;
} else {
imageBuffer = buf;
filename = basename(part.filename ?? "image");
}
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!imageBuffer || imageBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!maskBuffer || maskBuffer.length === 0) {
return reply.status(400).send({
error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'",
});
}
const imageValidation = await validateImageBuffer(imageBuffer);
if (!imageValidation.valid) {
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
}
const maskValidation = await validateImageBuffer(maskBuffer);
if (!maskValidation.valid) {
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
}
try {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, imageBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const resultBuffer = await inpaint(
imageBuffer,
maskBuffer,
join(workspacePath, "output"),
onProgress,
);
// Save output
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_erased.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, resultBuffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
if (!imageBuffer || imageBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!maskBuffer || maskBuffer.length === 0) {
return reply
.status(400)
.send({ error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'" });
}
const imageValidation = await validateImageBuffer(imageBuffer);
if (!imageValidation.valid) {
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
}
const maskValidation = await validateImageBuffer(maskBuffer);
if (!maskValidation.valid) {
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
}
try {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, imageBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const resultBuffer = await inpaint(
imageBuffer,
maskBuffer,
join(workspacePath, "output"),
onProgress,
);
// Save output
const outputFilename =
filename.replace(/\.[^.]+$/, "") + "_erased.png";
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, resultBuffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: imageBuffer.length,
processedSize: resultBuffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: imageBuffer.length,
processedSize: resultBuffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+68 -74
View File
@@ -1,7 +1,7 @@
import sharp from "sharp";
import { randomUUID } from "node:crypto";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import sharp from "sharp";
const FAVICON_SIZES = [
{ name: "favicon-16x16.png", size: 16, format: "png" as const },
@@ -13,97 +13,91 @@ const FAVICON_SIZES = [
];
export function registerFavicon(app: FastifyInstance) {
app.post(
"/api/v1/tools/favicon",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
app.post("/api/v1/tools/favicon", async (request, reply) => {
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);
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 (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
try {
const jobId = randomUUID();
try {
const jobId = randomUUID();
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
// Generate each size
for (const icon of FAVICON_SIZES) {
const buffer = await sharp(fileBuffer)
.resize(icon.size, icon.size, { fit: "cover" })
.png()
.toBuffer();
archive.append(buffer, { name: icon.name });
}
// Generate ICO (use 16x16 and 32x32 PNGs embedded)
// Simple ICO format: just include the 32x32 PNG as an ICO
const ico32 = await sharp(fileBuffer)
.resize(32, 32, { fit: "cover" })
// Generate each size
for (const icon of FAVICON_SIZES) {
const buffer = await sharp(fileBuffer)
.resize(icon.size, icon.size, { fit: "cover" })
.png()
.toBuffer();
archive.append(ico32, { name: "favicon.ico" });
// Generate manifest.json (for PWA)
const manifest = {
name: "App",
short_name: "App",
icons: [
{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
],
theme_color: "#ffffff",
background_color: "#ffffff",
display: "standalone",
};
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
archive.append(buffer, { name: icon.name });
}
// Generate HTML snippet
const htmlSnippet = `<!-- Favicons -->
// Generate ICO (use 16x16 and 32x32 PNGs embedded)
// Simple ICO format: just include the 32x32 PNG as an ICO
const ico32 = await sharp(fileBuffer).resize(32, 32, { fit: "cover" }).png().toBuffer();
archive.append(ico32, { name: "favicon.ico" });
// Generate manifest.json (for PWA)
const manifest = {
name: "App",
short_name: "App",
icons: [
{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
],
theme_color: "#ffffff",
background_color: "#ffffff",
display: "standalone",
};
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
// Generate HTML snippet
const htmlSnippet = `<!-- Favicons -->
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json">
`;
archive.append(htmlSnippet, { name: "favicon-snippet.html" });
archive.append(htmlSnippet, { name: "favicon-snippet.html" });
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Favicon generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Favicon generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}
});
}
+74 -79
View File
@@ -1,17 +1,13 @@
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { basename } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
/**
* 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 pixels = await sharp(buffer).resize(9, 8, { fit: "fill" }).grayscale().raw().toBuffer();
let hash = "";
for (let y = 0; y < 8; y++) {
@@ -36,88 +32,87 @@ function hammingDistance(a: string, b: string): number {
}
export function registerFindDuplicates(app: FastifyInstance) {
app.post(
"/api/v1/tools/find-duplicates",
async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
app.post("/api/v1/tools/find-duplicates", async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
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);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
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);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length < 2) {
return reply
.status(400)
.send({ error: "At least 2 images are required for duplicate detection" });
}
try {
// Compute hashes for all images
const hashes: Array<{ filename: string; hash: string }> = [];
for (const file of files) {
const hash = await computeDHash(file.buffer);
hashes.push({ filename: file.filename, hash });
}
if (files.length < 2) {
return reply.status(400).send({ error: "At least 2 images are required for duplicate detection" });
}
// Compare all pairs, group duplicates
const threshold = 10; // Hamming distance threshold for "similar"
const groups: Array<{
files: Array<{ filename: string; similarity: number }>;
}> = [];
const assigned = new Set<number>();
try {
// Compute hashes for all images
const hashes: Array<{ filename: string; hash: string }> = [];
for (const file of files) {
const hash = await computeDHash(file.buffer);
hashes.push({ filename: file.filename, hash });
}
for (let i = 0; i < hashes.length; i++) {
if (assigned.has(i)) continue;
// Compare all pairs, group duplicates
const threshold = 10; // Hamming distance threshold for "similar"
const groups: Array<{
files: Array<{ filename: string; similarity: number }>;
}> = [];
const assigned = new Set<number>();
const group: Array<{ filename: string; similarity: number }> = [
{ filename: hashes[i].filename, similarity: 100 },
];
for (let i = 0; i < hashes.length; i++) {
if (assigned.has(i)) continue;
const group: Array<{ filename: string; similarity: number }> = [
{ filename: hashes[i].filename, similarity: 100 },
];
for (let j = i + 1; j < hashes.length; j++) {
if (assigned.has(j)) continue;
const dist = hammingDistance(hashes[i].hash, hashes[j].hash);
if (dist <= threshold) {
const similarity = Math.round((1 - dist / 64) * 10000) / 100;
group.push({ filename: hashes[j].filename, similarity });
assigned.add(j);
}
}
if (group.length > 1) {
assigned.add(i);
groups.push({ files: group });
for (let j = i + 1; j < hashes.length; j++) {
if (assigned.has(j)) continue;
const dist = hammingDistance(hashes[i].hash, hashes[j].hash);
if (dist <= threshold) {
const similarity = Math.round((1 - dist / 64) * 10000) / 100;
group.push({ filename: hashes[j].filename, similarity });
assigned.add(j);
}
}
return reply.send({
totalImages: files.length,
duplicateGroups: groups,
uniqueImages: files.length - assigned.size,
});
} catch (err) {
return reply.status(422).send({
error: "Duplicate detection failed",
details: err instanceof Error ? err.message : "Unknown error",
});
if (group.length > 1) {
assigned.add(i);
groups.push({ files: group });
}
}
},
);
return reply.send({
totalImages: files.length,
duplicateGroups: groups,
uniqueImages: files.length - assigned.size,
});
} catch (err) {
return reply.status(422).send({
error: "Duplicate detection failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+3 -3
View File
@@ -1,7 +1,7 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
width: z.number().min(1).max(4096).optional(),
@@ -24,7 +24,7 @@ export function registerGifTools(app: FastifyInstance) {
}
const buffer = await image.png().toBuffer();
const outName = filename.replace(/\.gif$/i, "") + `_frame${settings.extractFrame}.png`;
const outName = `${filename.replace(/\.gif$/i, "")}_frame${settings.extractFrame}.png`;
return { buffer, filename: outName, contentType: "image/png" };
}
+116 -121
View File
@@ -1,10 +1,10 @@
import { z } from "zod";
import sharp from "sharp";
import PDFDocument from "pdfkit";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify";
import PDFDocument from "pdfkit";
import sharp from "sharp";
import { z } from "zod";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
@@ -21,128 +21,123 @@ const PAGE_SIZES: Record<string, [number, number]> = {
};
export function registerImageToPdf(app: FastifyInstance) {
app.post(
"/api/v1/tools/image-to-pdf",
async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | null = null;
app.post("/api/v1/tools/image-to-pdf", async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | 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);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
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);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No image files provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
let [pageW, pageH] = PAGE_SIZES[settings.pageSize] ?? PAGE_SIZES.A4;
if (settings.orientation === "landscape") {
[pageW, pageH] = [pageH, pageW];
}
const margin = settings.margin;
const contentW = pageW - margin * 2;
const contentH = pageH - margin * 2;
// Create PDF
const doc = new PDFDocument({
size: [pageW, pageH],
margin,
autoFirstPage: false,
});
const pdfChunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => pdfChunks.push(chunk));
const pdfDone = new Promise<Buffer>((resolve) => {
doc.on("end", () => resolve(Buffer.concat(pdfChunks)));
});
for (const file of files) {
doc.addPage({ size: [pageW, pageH], margin });
// Convert to PNG for PDFKit compatibility
const pngBuffer = await sharp(file.buffer).png().toBuffer();
const meta = await sharp(pngBuffer).metadata();
const imgW = meta.width ?? 100;
const imgH = meta.height ?? 100;
// Scale to fit within content area
const scale = Math.min(contentW / imgW, contentH / imgH, 1);
const scaledW = imgW * scale;
const scaledH = imgH * scale;
// Center on page
const x = margin + (contentW - scaledW) / 2;
const y = margin + (contentH - scaledH) / 2;
doc.image(pngBuffer, x, y, {
width: scaledW,
height: scaledH,
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No image files provided" });
}
doc.end();
const pdfBuffer = await pdfDone;
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "images.pdf";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, pdfBuffer);
try {
let [pageW, pageH] = PAGE_SIZES[settings.pageSize] ?? PAGE_SIZES.A4;
if (settings.orientation === "landscape") {
[pageW, pageH] = [pageH, pageW];
}
const margin = settings.margin;
const contentW = pageW - margin * 2;
const contentH = pageH - margin * 2;
// Create PDF
const doc = new PDFDocument({
size: [pageW, pageH],
margin,
autoFirstPage: false,
});
const pdfChunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => pdfChunks.push(chunk));
const pdfDone = new Promise<Buffer>((resolve) => {
doc.on("end", () => resolve(Buffer.concat(pdfChunks)));
});
for (const file of files) {
doc.addPage({ size: [pageW, pageH], margin });
// Convert to PNG for PDFKit compatibility
const pngBuffer = await sharp(file.buffer)
.png()
.toBuffer();
const meta = await sharp(pngBuffer).metadata();
const imgW = meta.width ?? 100;
const imgH = meta.height ?? 100;
// Scale to fit within content area
const scale = Math.min(contentW / imgW, contentH / imgH, 1);
const scaledW = imgW * scale;
const scaledH = imgH * scale;
// Center on page
const x = margin + (contentW - scaledW) / 2;
const y = margin + (contentH - scaledH) / 2;
doc.image(pngBuffer, x, y, {
width: scaledW,
height: scaledH,
});
}
doc.end();
const pdfBuffer = await pdfDone;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "images.pdf";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, pdfBuffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
processedSize: pdfBuffer.length,
pages: files.length,
});
} catch (err) {
return reply.status(422).send({
error: "PDF creation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
processedSize: pdfBuffer.length,
pages: files.length,
});
} catch (err) {
return reply.status(422).send({
error: "PDF creation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+31 -31
View File
@@ -1,44 +1,44 @@
import type { FastifyInstance } from "fastify";
import { registerResize } from "./resize.js";
import { registerCrop } from "./crop.js";
import { registerRotate } from "./rotate.js";
import { registerConvert } from "./convert.js";
import { registerCompress } from "./compress.js";
import { registerStripMetadata } from "./strip-metadata.js";
import { registerColorAdjustments } from "./color-adjustments.js";
// Phase 3: Watermark & Overlay
import { registerWatermarkText } from "./watermark-text.js";
import { registerWatermarkImage } from "./watermark-image.js";
import { registerTextOverlay } from "./text-overlay.js";
import { registerCompose } from "./compose.js";
// Phase 3: Utilities
import { registerInfo } from "./info.js";
import { registerCompare } from "./compare.js";
import { registerFindDuplicates } from "./find-duplicates.js";
import { registerColorPalette } from "./color-palette.js";
import { registerQrGenerate } from "./qr-generate.js";
import { registerBarcodeRead } from "./barcode-read.js";
// Phase 3: Layout & Composition
import { registerCollage } from "./collage.js";
import { registerSplit } from "./split.js";
import { registerBlurFaces } from "./blur-faces.js";
import { registerBorder } from "./border.js";
// Phase 3: Format & Conversion
import { registerSvgToRaster } from "./svg-to-raster.js";
import { registerVectorize } from "./vectorize.js";
import { registerGifTools } from "./gif-tools.js";
// Phase 3: Optimization extras
import { registerBulkRename } from "./bulk-rename.js";
// Phase 3: Layout & Composition
import { registerCollage } from "./collage.js";
import { registerColorAdjustments } from "./color-adjustments.js";
import { registerColorPalette } from "./color-palette.js";
import { registerCompare } from "./compare.js";
import { registerCompose } from "./compose.js";
import { registerCompress } from "./compress.js";
import { registerConvert } from "./convert.js";
import { registerCrop } from "./crop.js";
import { registerEraseObject } from "./erase-object.js";
import { registerFavicon } from "./favicon.js";
import { registerFindDuplicates } from "./find-duplicates.js";
import { registerGifTools } from "./gif-tools.js";
import { registerImageToPdf } from "./image-to-pdf.js";
// Phase 3: Adjustments extra
import { registerReplaceColor } from "./replace-color.js";
// Phase 3: Utilities
import { registerInfo } from "./info.js";
import { registerOcr } from "./ocr.js";
import { registerQrGenerate } from "./qr-generate.js";
// Phase 4: AI Tools
import { registerRemoveBackground } from "./remove-background.js";
import { registerUpscale } from "./upscale.js";
import { registerOcr } from "./ocr.js";
import { registerBlurFaces } from "./blur-faces.js";
import { registerEraseObject } from "./erase-object.js";
// Phase 3: Adjustments extra
import { registerReplaceColor } from "./replace-color.js";
import { registerResize } from "./resize.js";
import { registerRotate } from "./rotate.js";
import { registerSmartCrop } from "./smart-crop.js";
import { registerSplit } from "./split.js";
import { registerStripMetadata } from "./strip-metadata.js";
// Phase 3: Format & Conversion
import { registerSvgToRaster } from "./svg-to-raster.js";
import { registerTextOverlay } from "./text-overlay.js";
import { registerUpscale } from "./upscale.js";
import { registerVectorize } from "./vectorize.js";
import { registerWatermarkImage } from "./watermark-image.js";
// Phase 3: Watermark & Overlay
import { registerWatermarkText } from "./watermark-text.js";
/**
* Registry that imports and registers all tool routes.
+61 -64
View File
@@ -1,80 +1,77 @@
import sharp from "sharp";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { basename } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
/**
* Image info route - read-only, returns JSON metadata.
* Does NOT use createToolRoute since it doesn't produce a processed file.
*/
export function registerInfo(app: FastifyInstance) {
app.post(
"/api/v1/tools/info",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
app.post("/api/v1/tools/info", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
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);
filename = basename(part.filename ?? "image");
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);
filename = basename(part.filename ?? "image");
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
try {
const metadata = await sharp(fileBuffer).metadata();
const stats = await sharp(fileBuffer).stats();
try {
const metadata = await sharp(fileBuffer).metadata();
const stats = await sharp(fileBuffer).stats();
// Build histogram data from stats
const histogram = stats.channels.map((ch, i) => ({
channel: ["red", "green", "blue", "alpha"][i] ?? `channel-${i}`,
min: ch.min,
max: ch.max,
mean: Math.round(ch.mean * 100) / 100,
stdev: Math.round(ch.stdev * 100) / 100,
}));
// Build histogram data from stats
const histogram = stats.channels.map((ch, i) => ({
channel: ["red", "green", "blue", "alpha"][i] ?? `channel-${i}`,
min: ch.min,
max: ch.max,
mean: Math.round(ch.mean * 100) / 100,
stdev: Math.round(ch.stdev * 100) / 100,
}));
return reply.send({
filename,
fileSize: fileBuffer.length,
width: metadata.width ?? 0,
height: metadata.height ?? 0,
format: metadata.format ?? "unknown",
channels: metadata.channels ?? 0,
hasAlpha: metadata.hasAlpha ?? false,
colorSpace: metadata.space ?? "unknown",
density: metadata.density ?? null,
isProgressive: metadata.isProgressive ?? false,
orientation: metadata.orientation ?? null,
hasProfile: metadata.hasProfile ?? false,
hasExif: !!metadata.exif,
hasIcc: !!metadata.icc,
hasXmp: !!metadata.xmp,
bitDepth: metadata.depth ?? null,
pages: metadata.pages ?? 1,
histogram,
});
} catch (err) {
return reply.status(422).send({
error: "Failed to read image metadata",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
filename,
fileSize: fileBuffer.length,
width: metadata.width ?? 0,
height: metadata.height ?? 0,
format: metadata.format ?? "unknown",
channels: metadata.channels ?? 0,
hasAlpha: metadata.hasAlpha ?? false,
colorSpace: metadata.space ?? "unknown",
density: metadata.density ?? null,
isProgressive: metadata.isProgressive ?? false,
orientation: metadata.orientation ?? null,
hasProfile: metadata.hasProfile ?? false,
hasExif: !!metadata.exif,
hasIcc: !!metadata.icc,
hasXmp: !!metadata.xmp,
bitDepth: metadata.depth ?? null,
pages: metadata.pages ?? 1,
histogram,
});
} catch (err) {
return reply.status(422).send({
error: "Failed to read image metadata",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+88 -89
View File
@@ -1,11 +1,11 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
import { z } from "zod";
import { extractText } from "@stirling-image/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
const settingsSchema = z.object({
engine: z.enum(["tesseract", "paddleocr"]).default("tesseract"),
@@ -17,103 +17,102 @@ const settingsSchema = z.object({
* Returns JSON with extracted text rather than an image.
*/
export function registerOcr(app: FastifyInstance) {
app.post(
"/api/v1/tools/ocr",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
app.post("/api/v1/tools/ocr", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | 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);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
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);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
let settings: z.infer<typeof settingsSchema>;
try {
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
: undefined;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const result = await extractText(
fileBuffer,
workspacePath,
{
engine: settings.engine,
language: settings.language,
},
onProgress,
);
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const result = await extractText(
fileBuffer,
workspacePath,
{
engine: settings.engine,
language: settings.language,
},
onProgress,
);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
return reply.send({
jobId,
filename,
text: result.text,
engine: result.engine,
});
} catch (err) {
return reply.status(422).send({
error: "OCR failed",
details: err instanceof Error ? err.message : "Unknown error",
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
},
);
return reply.send({
jobId,
filename,
text: result.text,
engine: result.engine,
});
} catch (err) {
return reply.status(422).send({
error: "OCR failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+58 -55
View File
@@ -1,17 +1,23 @@
import { z } from "zod";
import QRCode from "qrcode";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import QRCode from "qrcode";
import { z } from "zod";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
text: z.string().min(1).max(2000),
size: z.number().min(100).max(2000).default(400),
errorCorrection: z.enum(["L", "M", "Q", "H"]).default("M"),
foreground: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
background: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FFFFFF"),
foreground: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#000000"),
background: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FFFFFF"),
});
/**
@@ -19,59 +25,56 @@ const settingsSchema = z.object({
* images from text input, not from uploaded files.
*/
export function registerQrGenerate(app: FastifyInstance) {
app.post(
"/api/v1/tools/qr-generate",
async (request: FastifyRequest, reply: FastifyReply) => {
let body: unknown;
try {
body = request.body;
} catch {
return reply.status(400).send({ error: "Invalid request body" });
}
app.post("/api/v1/tools/qr-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: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
});
}
const result = settingsSchema.safeParse(body);
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
});
}
const settings = result.data;
const settings = result.data;
try {
const buffer = await QRCode.toBuffer(settings.text, {
width: settings.size,
errorCorrectionLevel: settings.errorCorrection,
color: {
dark: settings.foreground,
light: settings.background,
},
type: "png",
margin: 2,
});
try {
const buffer = await QRCode.toBuffer(settings.text, {
width: settings.size,
errorCorrectionLevel: settings.errorCorrection,
color: {
dark: settings.foreground,
light: settings.background,
},
type: "png",
margin: 2,
});
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "qrcode.png";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, buffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "qrcode.png";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, buffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: 0,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "QR code generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: 0,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "QR code generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
@@ -1,11 +1,11 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import { removeBackground } from "@stirling-image/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* AI background removal route.
@@ -81,7 +81,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
);
// Save output
const outputFilename = filename.replace(/\.[^.]+$/, "") + "_nobg.png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, resultBuffer);
+22 -8
View File
@@ -1,11 +1,17 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
sourceColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FF0000"),
targetColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#00FF00"),
sourceColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FF0000"),
targetColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#00FF00"),
makeTransparent: z.boolean().default(false),
tolerance: z.number().min(0).max(255).default(30),
});
@@ -19,8 +25,12 @@ function hexToRgb(hex: string): { r: number; g: number; b: number } {
}
function colorDistance(
r1: number, g1: number, b1: number,
r2: number, g2: number, b2: number,
r1: number,
g1: number,
b1: number,
r2: number,
g2: number,
b2: number,
): number {
return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2);
}
@@ -42,8 +52,12 @@ export function registerReplaceColor(app: FastifyInstance) {
for (let i = 0; i < pixels.length; i += 4) {
const dist = colorDistance(
pixels[i], pixels[i + 1], pixels[i + 2],
source.r, source.g, source.b,
pixels[i],
pixels[i + 1],
pixels[i + 2],
source.r,
source.g,
source.b,
);
if (dist <= maxDist) {
+4 -6
View File
@@ -1,15 +1,13 @@
import { resize } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { resize } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
width: z.number().positive().optional(),
height: z.number().positive().optional(),
fit: z
.enum(["contain", "cover", "fill", "inside", "outside"])
.default("contain"),
fit: z.enum(["contain", "cover", "fill", "inside", "outside"]).default("contain"),
withoutEnlargement: z.boolean().default(false),
percentage: z.number().positive().optional(),
});
+3 -3
View File
@@ -1,8 +1,8 @@
import { flip, rotate } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { rotate, flip } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
angle: z.number().default(0),
+3 -3
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -26,7 +26,7 @@ export function registerSmartCrop(app: FastifyInstance) {
.png()
.toBuffer();
const outputFilename = filename.replace(/\.[^.]+$/, "") + "_smartcrop.png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_smartcrop.png`;
return { buffer: result, filename: outputFilename, contentType: "image/png" };
},
});
+80 -83
View File
@@ -1,9 +1,9 @@
import { z } from "zod";
import sharp from "sharp";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { basename, extname } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
const settingsSchema = z.object({
columns: z.number().min(1).max(10).default(2),
@@ -14,99 +14,96 @@ const settingsSchema = z.object({
* Split an image into grid parts and return as ZIP.
*/
export function registerSplit(app: FastifyInstance) {
app.post(
"/api/v1/tools/split",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
app.post("/api/v1/tools/split", async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | 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);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
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);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
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, "");
try {
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, "");
const jobId = randomUUID();
const jobId = randomUUID();
// Set up response headers for ZIP
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="split-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
// Set up response headers for ZIP
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="split-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
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 < 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;
const partBuffer = await sharp(fileBuffer)
.extract({ left, top, width: w, height: h })
.toBuffer();
const partBuffer = await sharp(fileBuffer)
.extract({ left, top, width: w, height: h })
.toBuffer();
archive.append(partBuffer, {
name: `${baseName}_r${row + 1}_c${col + 1}${ext}`,
});
}
}
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Split failed",
details: err instanceof Error ? err.message : "Unknown error",
archive.append(partBuffer, {
name: `${baseName}_r${row + 1}_c${col + 1}${ext}`,
});
}
}
},
);
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Split failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
}
});
}
+18 -13
View File
@@ -1,10 +1,10 @@
import { basename } from "node:path";
import { stripMetadata } from "@stirling-image/image-engine";
import exifReader from "exif-reader";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { stripMetadata } from "@stirling-image/image-engine";
import sharp from "sharp";
import exifReader from "exif-reader";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { basename } from "node:path";
const settingsSchema = z.object({
stripExif: z.boolean().default(false),
@@ -116,7 +116,7 @@ function parseIccProfile(iccBuffer: Buffer): Record<string, string> {
const major = iccBuffer[8];
const minor = (iccBuffer[9] >> 4) & 0xf;
if (major) info["Version"] = `${major}.${minor}`;
if (major) info.Version = `${major}.${minor}`;
// Extract description tag from ICC tag table
const tagCount = iccBuffer.readUInt32BE(128);
@@ -132,8 +132,10 @@ function parseIccProfile(iccBuffer: Buffer): Record<string, string> {
if (descType === "desc") {
const strLen = iccBuffer.readUInt32BE(dataOffset + 8);
if (strLen > 0 && strLen < 256) {
const desc = iccBuffer.subarray(dataOffset + 12, dataOffset + 12 + strLen - 1).toString("ascii");
info["Description"] = desc;
const desc = iccBuffer
.subarray(dataOffset + 12, dataOffset + 12 + strLen - 1)
.toString("ascii");
info.Description = desc;
}
} else if (descType === "mluc") {
const recCount = iccBuffer.readUInt32BE(dataOffset + 8);
@@ -141,7 +143,10 @@ function parseIccProfile(iccBuffer: Buffer): Record<string, string> {
const strOffset = iccBuffer.readUInt32BE(dataOffset + 20);
const strLength = iccBuffer.readUInt32BE(dataOffset + 16);
if (strOffset && strLength && dataOffset + strOffset + strLength <= iccBuffer.length) {
const raw = iccBuffer.subarray(dataOffset + strOffset, dataOffset + strOffset + strLength);
const raw = iccBuffer.subarray(
dataOffset + strOffset,
dataOffset + strOffset + strLength,
);
// ICC mluc strings are UTF-16BE: swap bytes for Node's utf16le decoder
const swapped = Buffer.alloc(raw.length);
for (let j = 0; j < raw.length - 1; j += 2) {
@@ -149,7 +154,7 @@ function parseIccProfile(iccBuffer: Buffer): Record<string, string> {
swapped[j + 1] = raw[j];
}
const desc = swapped.toString("utf16le");
info["Description"] = desc.replace(/\0/g, "");
info.Description = desc.replace(/\0/g, "");
}
}
}
@@ -230,9 +235,9 @@ export function registerStripMetadata(app: FastifyInstance) {
gpsData[k] = sanitizeValue(v);
}
const coords = parseGpsCoordinates(parsed.GPSInfo as Record<string, unknown>);
if (coords.latitude !== null) gpsData["_latitude"] = coords.latitude;
if (coords.longitude !== null) gpsData["_longitude"] = coords.longitude;
if (coords.altitude !== null) gpsData["_altitude"] = coords.altitude;
if (coords.latitude !== null) gpsData._latitude = coords.latitude;
if (coords.longitude !== null) gpsData._longitude = coords.longitude;
if (coords.altitude !== null) gpsData._altitude = coords.altitude;
}
if (Object.keys(exifData).length > 0) result.exif = exifData;
+107 -108
View File
@@ -1,15 +1,18 @@
import { z } from "zod";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
width: z.number().min(1).max(8192).default(1024),
height: z.number().min(1).max(8192).optional(),
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6,8}$/).default("#00000000"),
backgroundColor: z
.string()
.regex(/^#[0-9a-fA-F]{6,8}$/)
.default("#00000000"),
outputFormat: z.enum(["png", "jpg", "webp"]).default("png"),
});
@@ -51,115 +54,111 @@ function sanitizeSvg(buffer: Buffer): Buffer {
* Custom route since input is SVG (not validated as image by magic bytes).
*/
export function registerSvgToRaster(app: FastifyInstance) {
app.post(
"/api/v1/tools/svg-to-raster",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "output";
let settingsRaw: string | null = null;
app.post("/api/v1/tools/svg-to-raster", async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "output";
let settingsRaw: string | 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);
filename = basename(part.filename ?? "output").replace(/\.svg$/i, "");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
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);
filename = basename(part.filename ?? "output").replace(/\.svg$/i, "");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No SVG file provided" });
}
// Sanitize SVG to prevent XXE, SSRF, and script injection
try {
fileBuffer = sanitizeSvg(fileBuffer);
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Invalid SVG",
});
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
let image = sharp(fileBuffer, { density: 300 }).resize(
settings.width,
settings.height ?? undefined,
{ fit: "inside" },
);
// Apply background if not transparent
if (settings.backgroundColor !== "#00000000") {
const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16);
const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16);
const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16);
image = image.flatten({ background: { r: bgR, g: bgG, b: bgB } });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No SVG file provided" });
let buffer: Buffer;
let ext: string;
let _contentType: string;
switch (settings.outputFormat) {
case "jpg":
buffer = await image.jpeg({ quality: 90 }).toBuffer();
ext = "jpg";
_contentType = "image/jpeg";
break;
case "webp":
buffer = await image.webp({ quality: 90 }).toBuffer();
ext = "webp";
_contentType = "image/webp";
break;
default:
buffer = await image.png().toBuffer();
ext = "png";
_contentType = "image/png";
break;
}
// Sanitize SVG to prevent XXE, SSRF, and script injection
try {
fileBuffer = sanitizeSvg(fileBuffer);
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Invalid SVG",
});
}
const outFilename = `${filename}.${ext}`;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, buffer);
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
let image = sharp(fileBuffer, { density: 300 }).resize(
settings.width,
settings.height ?? undefined,
{ fit: "inside" },
);
// Apply background if not transparent
if (settings.backgroundColor !== "#00000000") {
const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16);
const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16);
const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16);
image = image.flatten({ background: { r: bgR, g: bgG, b: bgB } });
}
let buffer: Buffer;
let ext: string;
let contentType: string;
switch (settings.outputFormat) {
case "jpg":
buffer = await image.jpeg({ quality: 90 }).toBuffer();
ext = "jpg";
contentType = "image/jpeg";
break;
case "webp":
buffer = await image.webp({ quality: 90 }).toBuffer();
ext = "webp";
contentType = "image/webp";
break;
case "png":
default:
buffer = await image.png().toBuffer();
ext = "png";
contentType = "image/png";
break;
}
const outFilename = `${filename}.${ext}`;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, buffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
originalSize: fileBuffer.length,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "SVG conversion failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
originalSize: fileBuffer.length,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "SVG conversion failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+10 -5
View File
@@ -1,15 +1,21 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
text: z.string().min(1).max(500),
fontSize: z.number().min(8).max(200).default(48),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FFFFFF"),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FFFFFF"),
position: z.enum(["top", "center", "bottom"]).default("bottom"),
backgroundBox: z.boolean().default(false),
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
backgroundColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#000000"),
shadow: z.boolean().default(true),
});
@@ -43,7 +49,6 @@ export function registerTextOverlay(app: FastifyInstance) {
case "center":
y = height / 2;
break;
case "bottom":
default:
y = height - pad;
break;
+94 -98
View File
@@ -1,116 +1,112 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import { upscale } from "@stirling-image/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* AI image upscaling route.
* Uses Real-ESRGAN when available, falls back to Lanczos.
*/
export function registerUpscale(app: FastifyInstance) {
app.post(
"/api/v1/tools/upscale",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
app.post("/api/v1/tools/upscale", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | 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);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
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);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
const scale = Number(settings.scale) || 2;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const result = await upscale(
fileBuffer,
join(workspacePath, "output"),
{ scale },
onProgress,
);
// Save output
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
const scale = Number(settings.scale) || 2;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const result = await upscale(
fileBuffer,
join(workspacePath, "output"),
{ scale },
onProgress,
);
// Save output
const outputFilename =
filename.replace(/\.[^.]+$/, "") + `_${scale}x.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
width: result.width,
height: result.height,
method: result.method,
});
} catch (err) {
return reply.status(422).send({
error: "Upscaling failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
width: result.width,
height: result.height,
method: result.method,
});
} catch (err) {
return reply.status(422).send({
error: "Upscaling failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+84 -93
View File
@@ -1,10 +1,10 @@
import { z } from "zod";
import sharp from "sharp";
import potrace from "potrace";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify";
import potrace from "potrace";
import sharp from "sharp";
import { z } from "zod";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
@@ -25,10 +25,7 @@ function traceImage(
});
}
function posterize(
buffer: Buffer,
options: { steps: number; threshold: number },
): Promise<string> {
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);
@@ -38,95 +35,89 @@ function posterize(
}
export function registerVectorize(app: FastifyInstance) {
app.post(
"/api/v1/tools/vectorize",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "output";
let settingsRaw: string | null = null;
app.post("/api/v1/tools/vectorize", async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "output";
let settingsRaw: string | 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);
filename = basename(part.filename ?? "output").replace(/\.[^.]+$/, "");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
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);
filename = basename(part.filename ?? "output").replace(/\.[^.]+$/, "");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
// 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;
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,
});
} else {
// B&W mode: simple trace
svg = await traceImage(pngBuffer, {
threshold: settings.threshold,
turdSize,
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const svgBuffer = Buffer.from(svg, "utf-8");
const outFilename = `${filename}.svg`;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, svgBuffer);
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
// 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;
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,
});
} else {
// B&W mode: simple trace
svg = await traceImage(pngBuffer, {
threshold: settings.threshold,
turdSize,
});
}
const svgBuffer = Buffer.from(svg, "utf-8");
const outFilename = `${filename}.svg`;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, svgBuffer);
return reply.send({
jobId,
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({
error: "Vectorization failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
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({
error: "Vectorization failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}
+134 -140
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
const settingsSchema = z.object({
position: z
@@ -12,156 +12,150 @@ const settingsSchema = z.object({
export function registerWatermarkImage(app: FastifyInstance) {
// Custom route since we need two file uploads
app.post(
"/api/v1/tools/watermark-image",
async (request, reply) => {
let mainBuffer: Buffer | null = null;
let watermarkBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
app.post("/api/v1/tools/watermark-image", async (request, reply) => {
let mainBuffer: Buffer | null = null;
let watermarkBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | 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);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "watermark") {
watermarkBuffer = buf;
} else {
mainBuffer = buf;
filename = part.filename ?? "image";
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
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);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "watermark") {
watermarkBuffer = buf;
} else {
mainBuffer = buf;
filename = part.filename ?? "image";
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!mainBuffer || mainBuffer.length === 0) {
return reply.status(400).send({ error: "No main image file provided" });
if (!mainBuffer || mainBuffer.length === 0) {
return reply.status(400).send({ error: "No main image file provided" });
}
// Parse settings
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
// Parse settings
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
// If no watermark uploaded, just return the image
if (!watermarkBuffer || watermarkBuffer.length === 0) {
return reply.status(400).send({ error: "No watermark image provided" });
}
// If no watermark uploaded, just return the image
if (!watermarkBuffer || watermarkBuffer.length === 0) {
return reply.status(400).send({ error: "No watermark image provided" });
}
try {
const mainImage = sharp(mainBuffer);
const mainMeta = await mainImage.metadata();
const mainW = mainMeta.width ?? 800;
const mainH = mainMeta.height ?? 600;
try {
const mainImage = sharp(mainBuffer);
const mainMeta = await mainImage.metadata();
const mainW = mainMeta.width ?? 800;
const mainH = mainMeta.height ?? 600;
// Scale watermark
const wmWidth = Math.round((mainW * settings.scale) / 100);
let wmImage = sharp(watermarkBuffer).resize({ width: wmWidth });
// Scale watermark
const wmWidth = Math.round((mainW * settings.scale) / 100);
let wmImage = sharp(watermarkBuffer).resize({ width: wmWidth });
// Apply opacity via ensureAlpha + modulate
if (settings.opacity < 100) {
const wmBuf = await wmImage.ensureAlpha().toBuffer();
const wmMeta = await sharp(wmBuf).metadata();
const wmW = wmMeta.width ?? wmWidth;
const wmH = wmMeta.height ?? wmWidth;
// Create an opacity mask
const opacityOverlay = await sharp({
create: {
width: wmW,
height: wmH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: settings.opacity / 100 },
},
})
.png()
.toBuffer();
wmImage = sharp(wmBuf).composite([
{ input: opacityOverlay, blend: "dest-in" },
]);
}
const wmBuffer = await wmImage.toBuffer();
const wmMeta = await sharp(wmBuffer).metadata();
// Apply opacity via ensureAlpha + modulate
if (settings.opacity < 100) {
const wmBuf = await wmImage.ensureAlpha().toBuffer();
const wmMeta = await sharp(wmBuf).metadata();
const wmW = wmMeta.width ?? wmWidth;
const wmH = wmMeta.height ?? 0;
// Calculate position
const pad = 20;
let top = 0;
let left = 0;
switch (settings.position) {
case "top-left":
top = pad;
left = pad;
break;
case "top-right":
top = pad;
left = Math.max(0, mainW - wmW - pad);
break;
case "bottom-left":
top = Math.max(0, mainH - wmH - pad);
left = pad;
break;
case "bottom-right":
top = Math.max(0, mainH - wmH - pad);
left = Math.max(0, mainW - wmW - pad);
break;
case "center":
default:
top = Math.max(0, Math.round((mainH - wmH) / 2));
left = Math.max(0, Math.round((mainW - wmW) / 2));
break;
}
const result = await sharp(mainBuffer)
.composite([{ input: wmBuffer, top, left }])
const wmH = wmMeta.height ?? wmWidth;
// Create an opacity mask
const opacityOverlay = await sharp({
create: {
width: wmW,
height: wmH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: settings.opacity / 100 },
},
})
.png()
.toBuffer();
// Use tool-factory's workspace pattern
const { randomUUID } = await import("node:crypto");
const { writeFile } = await import("node:fs/promises");
const { join } = await import("node:path");
const { createWorkspace } = await import("../../lib/workspace.js");
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
originalSize: mainBuffer.length,
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Image processing failed",
});
wmImage = sharp(wmBuf).composite([{ input: opacityOverlay, blend: "dest-in" }]);
}
},
);
const wmBuffer = await wmImage.toBuffer();
const wmMeta = await sharp(wmBuffer).metadata();
const wmW = wmMeta.width ?? wmWidth;
const wmH = wmMeta.height ?? 0;
// Calculate position
const pad = 20;
let top = 0;
let left = 0;
switch (settings.position) {
case "top-left":
top = pad;
left = pad;
break;
case "top-right":
top = pad;
left = Math.max(0, mainW - wmW - pad);
break;
case "bottom-left":
top = Math.max(0, mainH - wmH - pad);
left = pad;
break;
case "bottom-right":
top = Math.max(0, mainH - wmH - pad);
left = Math.max(0, mainW - wmW - pad);
break;
default:
top = Math.max(0, Math.round((mainH - wmH) / 2));
left = Math.max(0, Math.round((mainW - wmW) / 2));
break;
}
const result = await sharp(mainBuffer)
.composite([{ input: wmBuffer, top, left }])
.toBuffer();
// Use tool-factory's workspace pattern
const { randomUUID } = await import("node:crypto");
const { writeFile } = await import("node:fs/promises");
const { join } = await import("node:path");
const { createWorkspace } = await import("../../lib/workspace.js");
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
originalSize: mainBuffer.length,
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Image processing failed",
});
}
});
}
+6 -4
View File
@@ -1,12 +1,15 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
text: z.string().min(1).max(500),
fontSize: z.number().min(8).max(200).default(48),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#000000"),
opacity: z.number().min(0).max(100).default(50),
position: z
.enum(["center", "top-left", "top-right", "bottom-left", "bottom-right", "tiled"])
@@ -86,7 +89,6 @@ export function registerWatermarkText(app: FastifyInstance) {
y = height - pad;
anchor = "end";
break;
case "center":
default:
x = width / 2;
y = height / 2;