feat: add meme generator tool with 101 templates
Full-featured meme generator with 101 bundled templates from Imgflip, searchable gallery with category filters, real-time CSS preview, server-side rendering via Sharp + opentype.js SVG paths, custom image upload with preset layouts, 7 bundled fonts, shared Zustand store.
@@ -12,9 +12,6 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@snapotter/ai": "workspace:*",
|
||||
"@snapotter/image-engine": "workspace:*",
|
||||
"@snapotter/shared": "workspace:*",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/multipart": "^9.0.0",
|
||||
"@fastify/rate-limit": "^10.2.0",
|
||||
@@ -22,6 +19,9 @@
|
||||
"@neplex/vectorizer": "^0.0.5",
|
||||
"@scalar/fastify-api-reference": "^1.49.5",
|
||||
"@sentry/node": "^10.49.0",
|
||||
"@snapotter/ai": "workspace:*",
|
||||
"@snapotter/image-engine": "workspace:*",
|
||||
"@snapotter/shared": "workspace:*",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"dotenv": "^16.4.0",
|
||||
@@ -31,6 +31,7 @@
|
||||
"fflate": "^0.8.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"mupdf": "^1.27.0",
|
||||
"opentype.js": "^2.0.0",
|
||||
"p-queue": "^9.1.0",
|
||||
"pdfkit": "^0.18.0",
|
||||
"piscina": "^5.1.4",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/opentype.js": "^1.3.9",
|
||||
"@types/pdfkit": "^0.17.5",
|
||||
"@types/potrace": "^2.1.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
|
||||
@@ -24,6 +24,7 @@ import { registerBatchRoutes } from "./routes/batch.js";
|
||||
import { docsRoutes } from "./routes/docs.js";
|
||||
import { registerFeatureRoutes } from "./routes/features.js";
|
||||
import { fileRoutes } from "./routes/files.js";
|
||||
import { registerMemeTemplates } from "./routes/meme-templates.js";
|
||||
import { registerPipelineRoutes } from "./routes/pipeline.js";
|
||||
import { recoverStaleJobs, registerProgressRoutes } from "./routes/progress.js";
|
||||
import { rolesRoutes } from "./routes/roles.js";
|
||||
@@ -155,6 +156,9 @@ await fileRoutes(app);
|
||||
// User file library routes (persistent file management with versioning)
|
||||
await userFileRoutes(app);
|
||||
|
||||
// Meme template listing and static serving (before tool routes which have catch-all)
|
||||
await registerMemeTemplates(app);
|
||||
|
||||
// Tool routes (generic factory-based)
|
||||
await registerToolRoutes(app);
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import opentype from "opentype.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TextBox {
|
||||
/** The text content to render */
|
||||
text: string;
|
||||
/** X position as percentage (0-100) of image width */
|
||||
x: number;
|
||||
/** Y position as percentage (0-100) of image height */
|
||||
y: number;
|
||||
/** Width as percentage (0-100) of image width */
|
||||
width: number;
|
||||
/** Height as percentage (0-100) of image height */
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface MemeTextOptions {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
textBoxes: TextBox[];
|
||||
fontFamily: string;
|
||||
fontSize?: number;
|
||||
textColor: string;
|
||||
strokeColor: string;
|
||||
textAlign: "left" | "center" | "right";
|
||||
allCaps: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font loading & caching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FONT_DIR = join(import.meta.dirname, "../../static/fonts");
|
||||
|
||||
const FONT_MAP: Record<string, string> = {
|
||||
anton: "Anton-Regular.ttf",
|
||||
"arial-black": "Anton-Regular.ttf",
|
||||
"comic-sans": "Anton-Regular.ttf",
|
||||
montserrat: "Montserrat-Black.ttf",
|
||||
"bebas-neue": "BebasNeue-Regular.ttf",
|
||||
"permanent-marker": "PermanentMarker-Regular.ttf",
|
||||
roboto: "Roboto-Black.ttf",
|
||||
};
|
||||
|
||||
const fontCache = new Map<string, opentype.Font>();
|
||||
|
||||
/**
|
||||
* Load a font by family key. Unknown fonts fall back to Anton.
|
||||
* Results are cached so repeated calls return the same instance.
|
||||
*/
|
||||
export function loadFont(family: string): opentype.Font {
|
||||
const filename = FONT_MAP[family] ?? FONT_MAP.anton;
|
||||
|
||||
if (fontCache.has(filename)) {
|
||||
return fontCache.get(filename)!;
|
||||
}
|
||||
|
||||
const buf = readFileSync(join(FONT_DIR, filename));
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text measurement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Measure the advance width of a string in the given font at the given size.
|
||||
*/
|
||||
export function measureText(text: string, fontFamily: string, fontSize: number): number {
|
||||
if (text === "") return 0;
|
||||
const font = loadFont(fontFamily);
|
||||
return font.getAdvanceWidth(text, fontSize);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Word wrapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wrap text into lines that fit within maxWidth pixels.
|
||||
* Words that individually exceed maxWidth are placed on their own line.
|
||||
*/
|
||||
export function wrapText(
|
||||
text: string,
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
maxWidth: number,
|
||||
): string[] {
|
||||
if (text === "") return [""];
|
||||
|
||||
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
||||
if (words.length === 0) return [""];
|
||||
|
||||
const lines: string[] = [];
|
||||
let currentLine = words[0];
|
||||
|
||||
for (let i = 1; i < words.length; i++) {
|
||||
const candidate = `${currentLine} ${words[i]}`;
|
||||
const width = measureText(candidate, fontFamily, fontSize);
|
||||
if (width <= maxWidth) {
|
||||
currentLine = candidate;
|
||||
} else {
|
||||
lines.push(currentLine);
|
||||
currentLine = words[i];
|
||||
}
|
||||
}
|
||||
lines.push(currentLine);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-sizing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIN_FONT_SIZE = 8;
|
||||
const DEFAULT_MAX_FONT_SIZE = 200;
|
||||
const LINE_HEIGHT_FACTOR = 1.2;
|
||||
|
||||
/**
|
||||
* Binary search for the largest font size where the text wraps to fit
|
||||
* within boxWidth x boxHeight pixels.
|
||||
*/
|
||||
export function autoSizeFontToFit(
|
||||
text: string,
|
||||
fontFamily: string,
|
||||
boxWidth: number,
|
||||
boxHeight: number,
|
||||
maxFontSize = DEFAULT_MAX_FONT_SIZE,
|
||||
): number {
|
||||
const effectiveMax = Math.min(maxFontSize, Math.floor(boxHeight / 5), Math.floor(boxWidth / 8));
|
||||
let lo = MIN_FONT_SIZE;
|
||||
let hi = Math.max(MIN_FONT_SIZE, effectiveMax);
|
||||
let best = MIN_FONT_SIZE;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
const lines = wrapText(text, fontFamily, mid, boxWidth);
|
||||
const totalHeight = lines.length * mid * LINE_HEIGHT_FACTOR;
|
||||
const maxLineWidth = Math.max(...lines.map((l) => measureText(l, fontFamily, mid)));
|
||||
|
||||
if (totalHeight <= boxHeight && maxLineWidth <= boxWidth) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Escape XML special characters in attribute values. */
|
||||
function escapeXmlAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render meme text boxes to an SVG buffer using opentype.js path conversion.
|
||||
* The SVG uses <path> elements (not <text>) so no system fonts are needed
|
||||
* when Sharp/librsvg rasterises it.
|
||||
*/
|
||||
export function renderMemeTextSvg(opts: MemeTextOptions): Buffer {
|
||||
const {
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
textBoxes,
|
||||
fontFamily,
|
||||
fontSize: fixedFontSize,
|
||||
textColor,
|
||||
strokeColor,
|
||||
textAlign,
|
||||
allCaps,
|
||||
} = opts;
|
||||
|
||||
const font = loadFont(fontFamily);
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const box of textBoxes) {
|
||||
let text = box.text;
|
||||
if (!text || text.trim() === "") continue;
|
||||
if (allCaps) text = text.toUpperCase();
|
||||
|
||||
// Convert percentage coords to pixels
|
||||
const bx = (box.x / 100) * imageWidth;
|
||||
const by = (box.y / 100) * imageHeight;
|
||||
const bw = (box.width / 100) * imageWidth;
|
||||
const bh = (box.height / 100) * imageHeight;
|
||||
|
||||
const pad = Math.max(8, Math.round(bw * 0.05));
|
||||
const innerW = bw - pad * 2;
|
||||
const innerH = bh - pad * 2;
|
||||
const fontSize = fixedFontSize ?? autoSizeFontToFit(text, fontFamily, innerW, innerH);
|
||||
const lineHeight = fontSize * LINE_HEIGHT_FACTOR;
|
||||
const lines = wrapText(text, fontFamily, fontSize, innerW);
|
||||
const strokeWidth = Math.max(1, Math.round(fontSize * 0.04));
|
||||
|
||||
const fillAttr = escapeXmlAttr(textColor);
|
||||
const strokeAttr = escapeXmlAttr(strokeColor);
|
||||
|
||||
const totalTextHeight = lines.length * lineHeight;
|
||||
const yOffset = by + pad + (innerH - totalTextHeight) / 2 + fontSize * 0.85;
|
||||
const innerX = bx + pad;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) continue;
|
||||
|
||||
const lineWidth = font.getAdvanceWidth(line, fontSize);
|
||||
|
||||
let x: number;
|
||||
if (textAlign === "left") {
|
||||
x = innerX;
|
||||
} else if (textAlign === "right") {
|
||||
x = innerX + innerW - lineWidth;
|
||||
} else {
|
||||
x = innerX + (innerW - lineWidth) / 2;
|
||||
}
|
||||
|
||||
const y = yOffset + i * lineHeight;
|
||||
|
||||
let d: string;
|
||||
try {
|
||||
const pathObj = font.getPath(line, x, y, fontSize);
|
||||
d = pathObj.toPathData(2);
|
||||
} catch {
|
||||
// Some fonts have unsupported GSUB features in opentype.js v2.
|
||||
// Fall back to Anton for the failing line.
|
||||
const fallback = loadFont("anton");
|
||||
const pathObj = fallback.getPath(line, x, y, fontSize);
|
||||
d = pathObj.toPathData(2);
|
||||
}
|
||||
|
||||
paths.push(
|
||||
`<path d="${d}" fill="${fillAttr}" stroke="${strokeAttr}" stroke-width="${strokeWidth}" paint-order="stroke fill" stroke-linejoin="round"/>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const svg = [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${imageWidth}" height="${imageHeight}" viewBox="0 0 ${imageWidth} ${imageHeight}">`,
|
||||
...paths,
|
||||
"</svg>",
|
||||
].join("\n");
|
||||
|
||||
return Buffer.from(svg, "utf-8");
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createReadStream, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
const STATIC_DIR = join(import.meta.dirname, "../../static");
|
||||
const TEMPLATES_DIR = join(STATIC_DIR, "meme-templates");
|
||||
const FONTS_DIR = join(STATIC_DIR, "fonts");
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".ttf": "font/ttf",
|
||||
};
|
||||
|
||||
/** Cached manifest (read once at first request, served from memory). */
|
||||
let manifestCache: string | null = 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 parsedManifestCache;
|
||||
}
|
||||
|
||||
function hasPathTraversal(filename: string): boolean {
|
||||
return (
|
||||
filename.includes("..") ||
|
||||
filename.includes("/") ||
|
||||
filename.includes("\\") ||
|
||||
filename.includes("\0")
|
||||
);
|
||||
}
|
||||
|
||||
function getContentType(filename: string): string | undefined {
|
||||
const dot = filename.lastIndexOf(".");
|
||||
if (dot === -1) return undefined;
|
||||
return CONTENT_TYPES[filename.slice(dot).toLowerCase()];
|
||||
}
|
||||
|
||||
function serveStaticFile(
|
||||
dir: string,
|
||||
filename: string,
|
||||
reply: FastifyReply,
|
||||
cacheControl: string,
|
||||
): FastifyReply | void {
|
||||
if (hasPathTraversal(filename)) {
|
||||
return reply.status(400).send({ error: "Invalid filename" });
|
||||
}
|
||||
|
||||
const contentType = getContentType(filename);
|
||||
if (!contentType) {
|
||||
return reply.status(400).send({ error: "Unsupported file type" });
|
||||
}
|
||||
|
||||
const filePath = join(dir, filename);
|
||||
if (!existsSync(filePath)) {
|
||||
return reply.status(404).send({ error: "File not found" });
|
||||
}
|
||||
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on("error", () => {
|
||||
if (!reply.raw.headersSent) {
|
||||
reply.status(404).send({ error: "File not found" });
|
||||
}
|
||||
});
|
||||
|
||||
return reply
|
||||
.header("Content-Type", contentType)
|
||||
.header("Cache-Control", cacheControl)
|
||||
.send(stream);
|
||||
}
|
||||
|
||||
export async function registerMemeTemplates(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/v1/meme-templates -- Return the full manifest JSON
|
||||
app.get("/api/v1/meme-templates", async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
const manifest = getManifest();
|
||||
return reply
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "public, max-age=3600")
|
||||
.send(manifest);
|
||||
});
|
||||
|
||||
// GET /api/v1/meme-templates/full/:filename -- Serve full-size template images
|
||||
app.get(
|
||||
"/api/v1/meme-templates/full/:filename",
|
||||
async (request: FastifyRequest<{ Params: { filename: string } }>, reply: FastifyReply) => {
|
||||
const { filename } = request.params;
|
||||
return serveStaticFile(
|
||||
join(TEMPLATES_DIR, "full"),
|
||||
filename,
|
||||
reply,
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/v1/meme-templates/thumbs/:filename -- Serve thumbnail images
|
||||
app.get(
|
||||
"/api/v1/meme-templates/thumbs/:filename",
|
||||
async (request: FastifyRequest<{ Params: { filename: string } }>, reply: FastifyReply) => {
|
||||
const { filename } = request.params;
|
||||
return serveStaticFile(
|
||||
join(TEMPLATES_DIR, "thumbs"),
|
||||
filename,
|
||||
reply,
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/v1/meme-templates/fonts/:filename -- Serve font files
|
||||
app.get(
|
||||
"/api/v1/meme-templates/fonts/:filename",
|
||||
async (request: FastifyRequest<{ Params: { filename: string } }>, reply: FastifyReply) => {
|
||||
const { filename } = request.params;
|
||||
return serveStaticFile(FONTS_DIR, filename, reply, "public, max-age=31536000, immutable");
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,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";
|
||||
@@ -103,6 +104,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 },
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 762 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 210 KiB |