Files
SnapOtter/apps/api/src/routes/tools/border.ts
T
SnapOtter dee9452c48 fix: format preservation, dispatcher stability, and health reporting
Closes #17, #18, #19, #31, #32, #33, #34

Format preservation (#17, #18, #19):
- Add resolveOutputFormat to rotate, resize, text-overlay, watermark-text,
  border, replace-color, blur-faces, upscale, erase-object, restore-photo
- Alpha-aware fallback: border with corner radius/shadow and replace-color
  with makeTransparent fall back to PNG for non-alpha formats (JPEG)
- Python sidecar tools (blur-faces, upscale, erase-object) now convert
  PNG output back to input format, matching restore-photo/colorize pattern
- Upscale and erase-object default to "auto" format detection instead of PNG

Dispatcher stability (#31, #32):
- Add gc.collect() and torch.cuda.empty_cache() after each dispatcher request
- Add configurable max_requests (default 50) for periodic dispatcher restart
- Add exponential backoff to dispatcher crash recovery in bridge.ts
- Circuit breaker: 5 crashes within 60s permanently disables dispatcher
- Reset crash counter on successful dispatcher startup

Health & security (#33, #34):
- Export getDispatcherStatus() from @snapotter/ai with running/ready/failed/
  gpu/pid/consecutiveCrashes fields
- Admin health endpoint now includes full dispatcher status
- Add pip-audit job to CI workflow for Python dependency scanning
2026-04-26 03:22:26 +08:00

165 lines
5.4 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js";
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/);
const settingsSchema = z.object({
borderWidth: z.number().min(0).max(2000).default(10),
borderColor: hexColor.default("#000000"),
padding: z.number().min(0).max(200).default(0),
paddingColor: hexColor.default("#FFFFFF"),
cornerRadius: z.number().min(0).max(2000).default(0),
shadow: z.boolean().default(false),
shadowBlur: z.number().min(1).max(200).default(15),
shadowOffsetX: z.number().min(-50).max(50).default(0),
shadowOffsetY: z.number().min(-50).max(50).default(5),
shadowColor: hexColor.default("#000000"),
shadowOpacity: z.number().min(0).max(100).default(40),
});
function parseHex(hex: string) {
return {
r: parseInt(hex.slice(1, 3), 16),
g: parseInt(hex.slice(3, 5), 16),
b: parseInt(hex.slice(5, 7), 16),
};
}
export function registerBorder(app: FastifyInstance) {
createToolRoute(app, {
toolId: "border",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
let buf = inputBuffer;
// 1. Add padding
if (settings.padding > 0) {
const c = parseHex(settings.paddingColor);
buf = await sharp(buf)
.extend({
top: settings.padding,
bottom: settings.padding,
left: settings.padding,
right: settings.padding,
background: { r: c.r, g: c.g, b: c.b, alpha: 1 },
})
.toBuffer();
}
// 2. Add border
if (settings.borderWidth > 0) {
const c = parseHex(settings.borderColor);
buf = await sharp(buf)
.extend({
top: settings.borderWidth,
bottom: settings.borderWidth,
left: settings.borderWidth,
right: settings.borderWidth,
background: { r: c.r, g: c.g, b: c.b, alpha: 1 },
})
.toBuffer();
}
// 3. Apply corner radius
if (settings.cornerRadius > 0) {
buf = await sharp(buf).ensureAlpha().png().toBuffer();
const meta = await sharp(buf).metadata();
const w = meta.width ?? 100;
const h = meta.height ?? 100;
const r = Math.min(settings.cornerRadius, 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();
}
// 4. Apply shadow
if (settings.shadow) {
buf = await sharp(buf).ensureAlpha().png().toBuffer();
const meta = await sharp(buf).metadata();
const bW = meta.width ?? 100;
const bH = meta.height ?? 100;
const sc = parseHex(settings.shadowColor);
const alpha = settings.shadowOpacity / 100;
const blur = settings.shadowBlur;
const spread = Math.ceil(blur * 2);
const ox = settings.shadowOffsetX;
const oy = settings.shadowOffsetY;
// Create shadow silhouette matching image shape (respects rounded corners)
const shadowSilhouette = await sharp({
create: {
width: bW,
height: bH,
channels: 4,
background: { r: sc.r, g: sc.g, b: sc.b, alpha },
},
})
.composite([{ input: buf, blend: "dest-in" }])
.extend({
top: spread,
bottom: spread,
left: spread,
right: spread,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.blur(Math.max(blur, 0.3))
.png()
.toBuffer();
// Calculate canvas padding for shadow spread + offset
const padL = Math.max(0, spread - ox);
const padR = Math.max(0, spread + ox);
const padT = Math.max(0, spread - oy);
const padB = Math.max(0, spread + oy);
const canvasW = bW + padL + padR;
const canvasH = bH + padT + padB;
const imgX = padL;
const imgY = padT;
const shadX = Math.max(0, imgX + ox - spread);
const shadY = Math.max(0, imgY + oy - spread);
buf = await sharp({
create: {
width: canvasW,
height: canvasH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([
{ input: shadowSilhouette, left: shadX, top: shadY },
{ input: buf, left: imgX, top: imgY },
])
.png()
.toBuffer();
}
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
const ALPHA_FORMATS = new Set(["png", "webp", "avif", "tiff"]);
const needsAlpha = settings.cornerRadius > 0 || settings.shadow;
if (needsAlpha && !ALPHA_FORMATS.has(outputFormat.format)) {
const buffer = await sharp(buf).png().toBuffer();
const outName = filename.replace(/\.[^.]+$/, ".png");
return { buffer, filename: outName, contentType: "image/png" };
}
const buffer = await sharp(buf)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
return { buffer, filename, contentType: outputFormat.contentType };
},
});
}