feat(meme-generator): add meme generation API route with template and custom image modes

Custom route handler supporting template mode (JSON body with templateId)
and custom image mode (multipart upload). Registers process function for
pipeline compatibility. Includes 18 integration tests.
This commit is contained in:
SnapOtter
2026-05-08 16:13:34 +08:00
parent b0cb17ff55
commit 6ec3a51fa4
6 changed files with 596 additions and 4 deletions
+9 -1
View File
@@ -61,7 +61,15 @@ export function loadFont(family: string): opentype.Font {
}
const buf = readFileSync(join(FONT_DIR, filename));
const font = opentype.parse(buf.buffer as ArrayBuffer);
let font: opentype.Font;
try {
font = opentype.parse(buf.buffer as ArrayBuffer);
} catch {
if (filename !== FONT_MAP.anton) {
return loadFont("anton");
}
throw new Error(`Failed to parse font: ${filename}`);
}
fontCache.set(filename, font);
return font;
}
+6 -3
View File
@@ -17,11 +17,14 @@ const CONTENT_TYPES: Record<string, string> = {
/** Cached manifest (read once at first request, served from memory). */
let manifestCache: string | null = null;
function getManifest(): string {
if (manifestCache === null) {
let parsedManifestCache: unknown = null;
function getManifest(): unknown {
if (parsedManifestCache === null) {
manifestCache = readFileSync(join(TEMPLATES_DIR, "meme-templates.json"), "utf-8");
parsedManifestCache = JSON.parse(manifestCache);
}
return manifestCache;
return parsedManifestCache;
}
function hasPathTraversal(filename: string): boolean {
+2
View File
@@ -26,6 +26,7 @@ import { registerImageEnhancement } from "./image-enhancement.js";
import { registerImageToBase64 } from "./image-to-base64.js";
import { registerImageToPdf } from "./image-to-pdf.js";
import { registerInfo } from "./info.js";
import { registerMemeGenerator } from "./meme-generator.js";
import { registerNoiseRemoval } from "./noise-removal.js";
import { registerOcr } from "./ocr.js";
import { registerOptimizeForWeb } from "./optimize-for-web.js";
@@ -101,6 +102,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "watermark-image", register: registerWatermarkImage },
{ id: "text-overlay", register: registerTextOverlay },
{ id: "compose", register: registerCompose },
{ id: "meme-generator", register: registerMemeGenerator },
// Utilities
{ id: "info", register: registerInfo },
+285
View File
@@ -0,0 +1,285 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
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 { ensureSharpCompat } from "../../lib/heic-converter.js";
import { renderMemeTextSvg } from "../../lib/meme-text-renderer.js";
import { createWorkspace } from "../../lib/workspace.js";
import { registerToolProcessFn } from "../tool-factory.js";
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const settingsSchema = z.object({
templateId: z.string().optional(),
textLayout: z
.enum(["top-bottom", "top-only", "bottom-only", "center", "side-by-side"])
.default("top-bottom"),
textBoxes: z.array(z.object({ id: z.string(), text: z.string() })).default([]),
fontFamily: z
.enum([
"anton",
"arial-black",
"comic-sans",
"montserrat",
"bebas-neue",
"permanent-marker",
"roboto",
])
.default("anton"),
fontSize: z.number().min(8).max(200).optional(),
textColor: z.string().default("#ffffff"),
strokeColor: z.string().default("#000000"),
textAlign: z.enum(["left", "center", "right"]).default("center"),
allCaps: z.boolean().default(true),
});
type Settings = z.infer<typeof settingsSchema>;
// ---------------------------------------------------------------------------
// Preset text layouts (for custom images)
// ---------------------------------------------------------------------------
const PRESET_LAYOUTS: Record<
string,
Array<{ id: string; x: number; y: number; width: number; height: number }>
> = {
"top-bottom": [
{ id: "top", x: 5, y: 2, width: 90, height: 20 },
{ id: "bottom", x: 5, y: 78, width: 90, height: 20 },
],
"top-only": [{ id: "top", x: 5, y: 2, width: 90, height: 25 }],
"bottom-only": [{ id: "bottom", x: 5, y: 75, width: 90, height: 23 }],
center: [{ id: "center", x: 10, y: 35, width: 80, height: 30 }],
"side-by-side": [
{ id: "left", x: 2, y: 35, width: 46, height: 30 },
{ id: "right", x: 52, y: 35, width: 46, height: 30 },
],
};
// ---------------------------------------------------------------------------
// Template manifest
// ---------------------------------------------------------------------------
interface TemplateTextBox {
id: string;
x: number;
y: number;
width: number;
height: number;
defaultText?: string;
}
interface Template {
id: string;
filename: string;
width: number;
height: number;
textBoxes: TemplateTextBox[];
}
interface Manifest {
templates: Template[];
}
const STATIC_DIR = join(import.meta.dirname, "../../../static");
const TEMPLATES_DIR = join(STATIC_DIR, "meme-templates");
let manifestCache: Manifest | null = null;
function getManifest(): Manifest {
if (manifestCache === null) {
const raw = readFileSync(join(TEMPLATES_DIR, "meme-templates.json"), "utf-8");
manifestCache = JSON.parse(raw) as Manifest;
}
return manifestCache;
}
function findTemplate(templateId: string): Template | undefined {
return getManifest().templates.find((t) => t.id === templateId);
}
// ---------------------------------------------------------------------------
// Core processing function (shared by HTTP route and pipeline registry)
// ---------------------------------------------------------------------------
async function processMeme(
imageBuffer: Buffer,
settings: Settings,
filename: string,
templateTextBoxes?: TemplateTextBox[],
): Promise<{ buffer: Buffer; filename: string; contentType: string }> {
const meta = await sharp(imageBuffer).metadata();
const imageWidth = meta.width ?? 800;
const imageHeight = meta.height ?? 600;
// Resolve text box positions: template boxes or preset layout
const layoutBoxes =
templateTextBoxes ?? PRESET_LAYOUTS[settings.textLayout] ?? PRESET_LAYOUTS["top-bottom"];
// Map settings.textBoxes onto layout positions
const textBoxes = layoutBoxes
.map((box) => {
const userBox = settings.textBoxes.find((tb) => tb.id === box.id);
return {
text: userBox?.text ?? "",
x: box.x,
y: box.y,
width: box.width,
height: box.height,
};
})
.filter((box) => box.text.length > 0);
let result: Buffer;
if (textBoxes.length > 0) {
const svgBuffer = renderMemeTextSvg({
imageWidth,
imageHeight,
textBoxes,
fontFamily: settings.fontFamily,
fontSize: settings.fontSize,
textColor: settings.textColor,
strokeColor: settings.strokeColor,
textAlign: settings.textAlign,
allCaps: settings.allCaps,
});
result = await sharp(imageBuffer)
.composite([{ input: svgBuffer }])
.toBuffer();
} else {
// No text -- just pass the image through
result = await sharp(imageBuffer).toBuffer();
}
return {
buffer: result,
filename,
contentType: "image/png",
};
}
// ---------------------------------------------------------------------------
// Route registration
// ---------------------------------------------------------------------------
export function registerMemeGenerator(app: FastifyInstance) {
// Register process function for pipeline/batch compatibility
registerToolProcessFn({
toolId: "meme-generator",
settingsSchema: settingsSchema as z.ZodType<unknown, z.ZodTypeDef, unknown>,
process: async (inputBuffer: Buffer, settings: unknown, filename: string) => {
const parsed = settingsSchema.parse(settings);
const buf = await autoOrient(await ensureSharpCompat(inputBuffer));
return processMeme(buf, parsed, filename);
},
});
app.post("/api/v1/tools/meme-generator", async (request, reply) => {
const contentTypeHeader = request.headers["content-type"] ?? "";
const isMultipart = contentTypeHeader.includes("multipart/form-data");
let imageBuffer: Buffer | null = null;
let settingsRaw: unknown = null;
let filename = "meme.png";
// ── Parse request ───────────────────────────────────────────────
if (isMultipart) {
// Custom image mode: multipart with file + settings
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file" && part.fieldname === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
imageBuffer = Buffer.concat(chunks);
if (part.filename) {
filename = part.filename;
}
} else if (part.type === "field" && part.fieldname === "settings") {
try {
settingsRaw = JSON.parse(part.value as string);
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} else {
// Template mode: JSON body
settingsRaw = request.body;
}
// ── Validate settings ───────────────────────────────────────────
const result = settingsSchema.safeParse(settingsRaw ?? {});
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: formatZodErrors(result.error.issues),
});
}
const settings = result.data;
// ── Resolve image source ────────────────────────────────────────
let templateTextBoxes: TemplateTextBox[] | undefined;
if (settings.templateId) {
// Template mode
const template = findTemplate(settings.templateId);
if (!template) {
return reply.status(400).send({ error: `Template not found: ${settings.templateId}` });
}
const templatePath = join(TEMPLATES_DIR, "full", template.filename);
if (!existsSync(templatePath)) {
return reply.status(400).send({ error: `Template image not found: ${template.filename}` });
}
imageBuffer = readFileSync(templatePath);
templateTextBoxes = template.textBoxes;
filename = `meme-${template.id}.png`;
} else if (!imageBuffer || imageBuffer.length === 0) {
return reply.status(400).send({ error: "Either templateId or an image file is required" });
}
// ── Process ─────────────────────────────────────────────────────
try {
// Normalize the image for Sharp compatibility
imageBuffer = await autoOrient(await ensureSharpCompat(imageBuffer!));
const output = await processMeme(imageBuffer, settings, filename, templateTextBoxes);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", output.filename);
await writeFile(outputPath, output.buffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(output.filename)}`,
originalSize: imageBuffer.length,
processedSize: output.buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Meme generation failed",
});
}
});
}