mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add layout tools (collage, splitting, border/frame)
Add 3 layout and composition tools with API routes and frontend settings: - collage: multi-image grid layout with configurable gap and background - split: image grid splitting with ZIP output (reuses archiver pattern) - border: borders, rounded corners via SVG mask, padding, and shadows
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
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"),
|
||||
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}$/).default("#00000080"),
|
||||
});
|
||||
|
||||
export function registerBorder(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "border",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const image = sharp(inputBuffer);
|
||||
const meta = await image.metadata();
|
||||
const w = meta.width ?? 100;
|
||||
const h = meta.height ?? 100;
|
||||
|
||||
// Parse border color
|
||||
const br = parseInt(settings.borderColor.slice(1, 3), 16);
|
||||
const bg = parseInt(settings.borderColor.slice(3, 5), 16);
|
||||
const bb = parseInt(settings.borderColor.slice(5, 7), 16);
|
||||
|
||||
const totalBorder = settings.borderWidth + settings.padding;
|
||||
const shadowPad = settings.shadowBlur > 0 ? settings.shadowBlur * 2 : 0;
|
||||
|
||||
// Extend image with border
|
||||
let result = sharp(inputBuffer).extend({
|
||||
top: totalBorder + shadowPad,
|
||||
bottom: totalBorder + shadowPad,
|
||||
left: totalBorder + shadowPad,
|
||||
right: totalBorder + shadowPad,
|
||||
background: { r: br, g: bg, b: bb, alpha: 1 },
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
// Create a white padding region behind the image
|
||||
const paddingRect = await sharp({
|
||||
create: {
|
||||
width: w + settings.padding * 2,
|
||||
height: h + settings.padding * 2,
|
||||
channels: 4,
|
||||
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const currentBuf = await result.toBuffer();
|
||||
result = sharp(currentBuf).composite([
|
||||
{
|
||||
input: paddingRect,
|
||||
top: settings.borderWidth + shadowPad,
|
||||
left: settings.borderWidth + shadowPad,
|
||||
},
|
||||
{
|
||||
input: inputBuffer,
|
||||
top: totalBorder + shadowPad,
|
||||
left: totalBorder + shadowPad,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Apply rounded corners via SVG mask
|
||||
if (settings.cornerRadius > 0) {
|
||||
const buf = await result.ensureAlpha().toBuffer();
|
||||
const bufMeta = await sharp(buf).metadata();
|
||||
const maskW = bufMeta.width ?? w;
|
||||
const maskH = bufMeta.height ?? h;
|
||||
const r = Math.min(settings.cornerRadius, maskW / 2, maskH / 2);
|
||||
|
||||
const roundedMask = Buffer.from(
|
||||
`<svg width="${maskW}" height="${maskH}">
|
||||
<rect x="0" y="0" width="${maskW}" height="${maskH}" rx="${r}" ry="${r}" fill="white"/>
|
||||
</svg>`,
|
||||
);
|
||||
|
||||
const maskBuffer = await sharp(roundedMask)
|
||||
.resize(maskW, maskH)
|
||||
.toBuffer();
|
||||
|
||||
result = sharp(buf).composite([
|
||||
{ input: maskBuffer, blend: "dest-in" },
|
||||
]);
|
||||
}
|
||||
|
||||
const buffer = await result.png().toBuffer();
|
||||
return { buffer, filename, contentType: "image/png" };
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user