mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
refactor: rename Tool.alpha to Tool.experimental
This commit is contained in:
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user