feat(beautify): add HTTP route, process pipeline, and pipeline registration

This commit is contained in:
SnapOtter
2026-05-08 16:17:17 +08:00
parent 69e107fbab
commit a90ba372df
2 changed files with 329 additions and 0 deletions
+327
View File
@@ -0,0 +1,327 @@
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 { autoOrient } from "../../lib/auto-orient.js";
import {
type BackgroundOpts,
generateBackground,
getDominantBackground,
} from "../../lib/beautify/backgrounds.js";
import {
type BeautifySettings,
DEVICE_FRAMES,
SHADOW_PRESETS,
SOCIAL_PRESETS,
settingsSchema,
} from "../../lib/beautify/constants.js";
import { renderFrame } from "../../lib/beautify/frames.js";
import { applyShadow } from "../../lib/beautify/shadow.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { registerToolProcessFn } from "../tool-factory.js";
const ALPHA_FORMATS = new Set(["png", "webp", "avif", "tiff"]);
function escapeXml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** Resolve shadow preset to concrete values. */
function resolveShadow(settings: BeautifySettings) {
return settings.shadowPreset === "custom"
? {
blur: settings.shadowBlur,
offsetX: settings.shadowOffsetX,
offsetY: settings.shadowOffsetY,
color: settings.shadowColor,
opacity: settings.shadowOpacity,
}
: SHADOW_PRESETS[settings.shadowPreset];
}
/** Check whether the output needs an alpha channel. */
function needsAlphaOutput(settings: BeautifySettings): boolean {
const shadow = resolveShadow(settings);
const hasShadow = shadow.opacity > 0 && shadow.blur > 0;
return (
hasShadow ||
settings.borderRadius > 0 ||
settings.backgroundType === "transparent" ||
settings.frame !== "none"
);
}
/** Compute the output filename with the correct extension. */
function resolveOutputFilename(filename: string, settings: BeautifySettings): string {
const forcedPng = needsAlphaOutput(settings) && !ALPHA_FORMATS.has(settings.outputFormat);
const ext = forcedPng ? ".png" : `.${settings.outputFormat}`;
return filename.replace(/\.[^.]+$/, ext);
}
export async function processBeautify(
inputBuffer: Buffer,
settings: BeautifySettings,
_filename: string,
bgImageBuffer?: Buffer,
): Promise<Buffer> {
// 1. Decode & Prepare
let buf = await autoOrient(await ensureSharpCompat(inputBuffer));
buf = await sharp(buf).ensureAlpha().png().toBuffer();
// 2. Apply Border Radius (skip for device frames that have their own bezels)
const hasDeviceFrame = settings.frame !== "none" && DEVICE_FRAMES.has(settings.frame);
if (settings.borderRadius > 0 && !hasDeviceFrame) {
const meta = await sharp(buf).metadata();
const w = meta.width ?? 100;
const h = meta.height ?? 100;
const r = Math.min(settings.borderRadius, w / 2, h / 2);
const mask = Buffer.from(
`<svg width="${w}" height="${h}"><rect x="0" y="0" width="${w}" height="${h}" rx="${r}" ry="${r}" fill="white"/></svg>`,
);
buf = await sharp(buf)
.composite([{ input: await sharp(mask).resize(w, h).toBuffer(), blend: "dest-in" }])
.png()
.toBuffer();
}
// 3. Render Device/SVG Frame
buf = await renderFrame(buf, settings.frame, settings.frameTitle);
// 4-5. Resolve & Apply Shadow
const shadowOpts = resolveShadow(settings);
const hasShadow = shadowOpts.opacity > 0 && shadowOpts.blur > 0;
if (hasShadow) {
const result = await applyShadow(buf, shadowOpts);
buf = result.buffer;
}
// 6. Generate Background
const framedMeta = await sharp(buf).metadata();
const framedW = framedMeta.width ?? 100;
const framedH = framedMeta.height ?? 100;
const padding = settings.padding;
const canvasW = framedW + padding * 2;
const canvasH = framedH + padding * 2;
let bgOpts: BackgroundOpts;
if (settings.backgroundType === "image" && bgImageBuffer) {
bgOpts = { type: "image", imageBuffer: bgImageBuffer, width: canvasW, height: canvasH };
} else if (settings.backgroundType === "solid") {
bgOpts = { type: "solid", color: settings.backgroundColor, width: canvasW, height: canvasH };
} else if (
settings.backgroundType === "linear-gradient" ||
settings.backgroundType === "radial-gradient"
) {
bgOpts = {
type: settings.backgroundType,
stops: settings.gradientStops,
angle: settings.gradientAngle,
width: canvasW,
height: canvasH,
};
} else {
bgOpts = { type: "transparent", width: canvasW, height: canvasH };
}
const background = await generateBackground(bgOpts);
// 7. Composite onto Background
buf = await sharp(background)
.composite([{ input: buf, left: padding, top: padding }])
.png()
.toBuffer();
// 8. Apply Watermark
if (settings.watermarkText) {
const wmMeta = await sharp(buf).metadata();
const wmW = wmMeta.width ?? canvasW;
const wmH = wmMeta.height ?? canvasH;
const fontSize = Math.max(12, Math.round(Math.min(wmW, wmH) * 0.03));
const alpha = settings.watermarkOpacity / 100;
const escaped = escapeXml(settings.watermarkText);
let textX: number;
let textY: number;
let anchor: string;
switch (settings.watermarkPosition) {
case "top-left":
textX = fontSize;
textY = fontSize * 2;
anchor = "start";
break;
case "top-right":
textX = wmW - fontSize;
textY = fontSize * 2;
anchor = "end";
break;
case "bottom-left":
textX = fontSize;
textY = wmH - fontSize;
anchor = "start";
break;
case "center":
textX = wmW / 2;
textY = wmH / 2;
anchor = "middle";
break;
default:
// bottom-right
textX = wmW - fontSize;
textY = wmH - fontSize;
anchor = "end";
break;
}
const wmSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${wmW}" height="${wmH}">
<text x="${textX}" y="${textY}" text-anchor="${anchor}" font-family="sans-serif" font-size="${fontSize}" fill="white" opacity="${alpha}">${escaped}</text>
</svg>`;
buf = await sharp(buf)
.composite([{ input: Buffer.from(wmSvg) }])
.png()
.toBuffer();
}
// 9. Resize to Social Preset
const preset = SOCIAL_PRESETS[settings.socialPreset];
if (preset) {
const dominant = getDominantBackground({
type: settings.backgroundType,
color: settings.backgroundColor,
stops: settings.gradientStops,
});
buf = await sharp(buf)
.resize(preset.width, preset.height, {
fit: "contain",
background: dominant,
})
.png()
.toBuffer();
}
// 10. Encode Output
if (needsAlphaOutput(settings) && !ALPHA_FORMATS.has(settings.outputFormat)) {
return sharp(buf).png().toBuffer();
}
switch (settings.outputFormat) {
case "jpeg":
return sharp(buf).flatten({ background: "#ffffff" }).jpeg({ quality: 90 }).toBuffer();
case "webp":
return sharp(buf).webp({ quality: 90 }).toBuffer();
default:
return sharp(buf).png().toBuffer();
}
}
export function registerBeautify(app: FastifyInstance) {
// Custom HTTP route (multi-file upload: main image + optional background image)
app.post("/api/v1/tools/beautify", async (request, reply) => {
let mainBuffer: Buffer | null = null;
let bgImageBuffer: 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 === "backgroundImage") {
bgImageBuffer = buf;
} else {
mainBuffer = 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),
});
}
if (!mainBuffer || mainBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
let settings: BeautifySettings;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
const originalSize = mainBuffer.length;
const outputBuf = await processBeautify(
mainBuffer,
settings,
filename,
bgImageBuffer ?? undefined,
);
const outFilename = resolveOutputFilename(filename, settings);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, outputBuf);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
originalSize,
processedSize: outputBuf.length,
});
} catch (err) {
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Image processing failed",
});
}
});
// Register for pipeline/batch support
registerToolProcessFn({
toolId: "beautify",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const s = settings as BeautifySettings;
if (s.backgroundType === "image") {
throw new Error("Image backgrounds are not supported in pipeline mode.");
}
const buffer = await processBeautify(inputBuffer, s, filename);
const outFilename = resolveOutputFilename(filename, s);
const forcedPng = needsAlphaOutput(s) && !ALPHA_FORMATS.has(s.outputFormat);
const ext = forcedPng ? "png" : s.outputFormat;
const contentType =
ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
return { buffer, filename: outFilename, contentType };
},
});
}
+2
View File
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { db, schema } from "../../db/index.js";
import { registerBarcodeRead } from "./barcode-read.js";
import { registerBeautify } from "./beautify.js";
import { registerBlurFaces } from "./blur-faces.js";
import { registerBorder } from "./border.js";
import { registerBulkRename } from "./bulk-rename.js";
@@ -117,6 +118,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "stitch", register: registerStitch },
{ id: "split", register: registerSplit },
{ id: "border", register: registerBorder },
{ id: "beautify", register: registerBeautify },
// Format & Conversion
{ id: "svg-to-raster", register: registerSvgToRaster },