mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Sharp 0.35.1 moved FormatEnum to a namespace export and removed "avif" from FormatEnum (now a separate literal in toFormat). BullMQ 5.78.1 bundles ioredis 5.10.1 while we have 5.11.1, causing structural type mismatch. Also fixes new Biome 1.9 lint rules.
167 lines
5.3 KiB
TypeScript
167 lines
5.3 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { FastifyInstance } from "fastify";
|
|
import sharp from "sharp";
|
|
import { z } from "zod";
|
|
import { autoOrient } from "../../lib/auto-orient.js";
|
|
import { formatZodErrors } from "../../lib/errors.js";
|
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
|
import { sanitizeFilename } from "../../lib/filename.js";
|
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
|
import { putObject } from "../../lib/object-storage.js";
|
|
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
|
|
|
async function decodeBuffer(inputBuffer: Buffer, filename: string): Promise<Buffer> {
|
|
const validation = await validateImageBuffer(inputBuffer, filename);
|
|
if (!validation.valid) {
|
|
throw new Error(`Invalid image: ${validation.reason}`);
|
|
}
|
|
|
|
let decoded = inputBuffer;
|
|
if (validation.format === "heif") {
|
|
decoded = await decodeHeic(decoded);
|
|
} else if (needsCliDecode(validation.format)) {
|
|
const ext = filename.split(".").pop()?.toLowerCase();
|
|
decoded = await decodeToSharpCompat(decoded, validation.format, ext);
|
|
} else if (validation.format === "svg") {
|
|
decoded = decompressSvgz(decoded);
|
|
decoded = sanitizeSvg(decoded);
|
|
}
|
|
|
|
return autoOrient(decoded);
|
|
}
|
|
|
|
const settingsSchema = z.object({
|
|
x: z.number().min(0).default(0),
|
|
y: z.number().min(0).default(0),
|
|
opacity: z.number().min(0).max(100).default(100),
|
|
blendMode: z
|
|
.enum([
|
|
"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 overlayFilename = "overlay";
|
|
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;
|
|
overlayFilename = sanitizeFilename(part.filename ?? "overlay");
|
|
} 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),
|
|
});
|
|
}
|
|
|
|
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: formatZodErrors(result.error.issues) });
|
|
}
|
|
settings = result.data;
|
|
} catch {
|
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
|
}
|
|
|
|
try {
|
|
baseBuffer = await decodeBuffer(baseBuffer, filename);
|
|
overlayBuffer = await decodeBuffer(overlayBuffer, overlayFilename);
|
|
|
|
// 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([
|
|
{
|
|
input: processedOverlay,
|
|
top: settings.y,
|
|
left: settings.x,
|
|
blend: settings.blendMode as sharp.Blend,
|
|
},
|
|
])
|
|
.toBuffer();
|
|
|
|
const jobId = randomUUID();
|
|
await putObject(`outputs/${jobId}/${filename}`, 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",
|
|
});
|
|
}
|
|
});
|
|
}
|