From 6ec3a51fa4e15de1e7fca5e79c6effda782011ab Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 8 May 2026 16:13:34 +0800 Subject: [PATCH] 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. --- apps/api/src/lib/meme-text-renderer.ts | 10 +- apps/api/src/routes/meme-templates.ts | 9 +- apps/api/src/routes/tools/index.ts | 2 + apps/api/src/routes/tools/meme-generator.ts | 285 +++++++++++++++++++ tests/integration/meme-generator.test.ts | 290 ++++++++++++++++++++ tests/integration/test-server.ts | 4 + 6 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/routes/tools/meme-generator.ts create mode 100644 tests/integration/meme-generator.test.ts diff --git a/apps/api/src/lib/meme-text-renderer.ts b/apps/api/src/lib/meme-text-renderer.ts index 1b2eb697..1d0f6277 100644 --- a/apps/api/src/lib/meme-text-renderer.ts +++ b/apps/api/src/lib/meme-text-renderer.ts @@ -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; } diff --git a/apps/api/src/routes/meme-templates.ts b/apps/api/src/routes/meme-templates.ts index 52861e72..0cf8a1df 100644 --- a/apps/api/src/routes/meme-templates.ts +++ b/apps/api/src/routes/meme-templates.ts @@ -17,11 +17,14 @@ const CONTENT_TYPES: Record = { /** 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 { diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index 1cbcc054..3573911e 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -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 { { id: "watermark-image", register: registerWatermarkImage }, { id: "text-overlay", register: registerTextOverlay }, { id: "compose", register: registerCompose }, + { id: "meme-generator", register: registerMemeGenerator }, // Utilities { id: "info", register: registerInfo }, diff --git a/apps/api/src/routes/tools/meme-generator.ts b/apps/api/src/routes/tools/meme-generator.ts new file mode 100644 index 00000000..15eccf0b --- /dev/null +++ b/apps/api/src/routes/tools/meme-generator.ts @@ -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; + +// --------------------------------------------------------------------------- +// 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, + 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", + }); + } + }); +} diff --git a/tests/integration/meme-generator.test.ts b/tests/integration/meme-generator.test.ts new file mode 100644 index 00000000..e30aada7 --- /dev/null +++ b/tests/integration/meme-generator.test.ts @@ -0,0 +1,290 @@ +/** + * Integration tests for the meme-generator tool (/api/v1/tools/meme-generator). + * + * Supports two input modes: + * 1. Template mode: JSON body with templateId (no file upload) + * 2. Custom image mode: multipart with file upload + settings + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +/** First template ID from the real manifest, loaded in beforeAll. */ +let firstTemplateId: string; +/** Text box IDs for the first template. */ +let firstTemplateTextBoxIds: string[]; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); + + // Read the first template from the actual manifest + const manifestRes = await app.inject({ + method: "GET", + url: "/api/v1/meme-templates", + headers: { authorization: `Bearer ${adminToken}` }, + }); + const manifest = JSON.parse(manifestRes.body); + const firstTemplate = manifest.templates[0]; + firstTemplateId = firstTemplate.id; + firstTemplateTextBoxIds = firstTemplate.textBoxes.map((tb: { id: string }) => tb.id); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Meme Generator", () => { + // ── Template listing sanity check ───────────────────────────────── + + it("GET /api/v1/meme-templates returns valid manifest", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/meme-templates", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(200); + const manifest = JSON.parse(res.body); + expect(manifest.templates).toBeDefined(); + expect(manifest.templates.length).toBeGreaterThan(0); + expect(manifest.templates[0].id).toBeDefined(); + expect(manifest.templates[0].textBoxes).toBeDefined(); + }); + + // ── Template mode ───────────────────────────────────────────────── + + it("template mode: valid templateId + text boxes returns 200 with downloadUrl", async () => { + const textBoxes = firstTemplateTextBoxIds.map((id) => ({ + id, + text: `Test text for ${id}`, + })); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/meme-generator", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + templateId: firstTemplateId, + textBoxes, + }, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.jobId).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + // ── Custom image mode ───────────────────────────────────────────── + + it("custom image mode: file upload + textLayout + text boxes returns 200", async () => { + const settings = { + textLayout: "top-bottom", + textBoxes: [ + { id: "top", text: "TOP TEXT" }, + { id: "bottom", text: "BOTTOM TEXT" }, + ], + }; + + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "meme.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/meme-generator", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.jobId).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + // ── Validation: invalid templateId ──────────────────────────────── + + it("invalid templateId returns 400", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/meme-generator", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + templateId: "nonexistent-template-that-does-not-exist", + textBoxes: [{ id: "top", text: "Hello" }], + }, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/template/i); + }); + + // ── Validation: neither templateId nor file ─────────────────────── + + it("neither templateId nor file returns 400", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/meme-generator", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + textBoxes: [{ id: "top", text: "Hello" }], + }, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toBeDefined(); + }); + + // ── Empty text boxes still generates image ──────────────────────── + + it("empty text boxes returns 200 (image without text)", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/meme-generator", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + templateId: firstTemplateId, + textBoxes: [], + }, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + // ── Every font family ───────────────────────────────────────────── + + const FONT_FAMILIES = [ + "anton", + "arial-black", + "comic-sans", + "montserrat", + "bebas-neue", + "permanent-marker", + ] as const; + + for (const font of FONT_FAMILIES) { + it(`font family "${font}" returns 200`, async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/meme-generator", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + templateId: firstTemplateId, + fontFamily: font, + textBoxes: firstTemplateTextBoxIds.map((id) => ({ + id, + text: `Test with ${font}`, + })), + }, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + } + + // ── All text layout presets with custom image ───────────────────── + + const TEXT_LAYOUTS = ["top-bottom", "top-only", "bottom-only", "center", "side-by-side"] as const; + + for (const layout of TEXT_LAYOUTS) { + it(`text layout "${layout}" with custom image returns 200`, async () => { + // Build text boxes matching the layout preset IDs + const textBoxMap: Record = { + "top-bottom": [ + { id: "top", text: "TOP" }, + { id: "bottom", text: "BOTTOM" }, + ], + "top-only": [{ id: "top", text: "TOP ONLY" }], + "bottom-only": [{ id: "bottom", text: "BOTTOM ONLY" }], + center: [{ id: "center", text: "CENTER TEXT" }], + "side-by-side": [ + { id: "left", text: "LEFT" }, + { id: "right", text: "RIGHT" }, + ], + }; + + const settings = { + textLayout: layout, + textBoxes: textBoxMap[layout], + }; + + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "meme.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/meme-generator", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + } + + // ── Authentication ──────────────────────────────────────────────── + + it("rejects unauthenticated requests", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/meme-generator", + headers: { + "content-type": "application/json", + }, + payload: { + templateId: firstTemplateId, + textBoxes: [], + }, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index 505635be..569adff3 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -38,6 +38,7 @@ import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js"; import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js"; import { docsRoutes } from "../../apps/api/src/routes/docs.js"; import { fileRoutes } from "../../apps/api/src/routes/files.js"; +import { registerMemeTemplates } from "../../apps/api/src/routes/meme-templates.js"; import { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js"; import { registerProgressRoutes } from "../../apps/api/src/routes/progress.js"; import { rolesRoutes } from "../../apps/api/src/routes/roles.js"; @@ -90,6 +91,9 @@ export async function buildTestApp(): Promise { // User file library routes (persistent file management with versioning) await userFileRoutes(app); + // Meme template routes + await registerMemeTemplates(app); + // Tool routes await registerToolRoutes(app);