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);