diff --git a/apps/api/package.json b/apps/api/package.json index 624cee26..96a1d3e5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c75837fb..424276fe 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/lib/meme-text-renderer.ts b/apps/api/src/lib/meme-text-renderer.ts new file mode 100644 index 00000000..c933b465 --- /dev/null +++ b/apps/api/src/lib/meme-text-renderer.ts @@ -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 = { + 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(); + +/** + * 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, ">"); +} + +/** + * Render meme text boxes to an SVG buffer using opentype.js path conversion. + * The SVG uses elements (not ) 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( + ``, + ); + } + } + + const svg = [ + ``, + ...paths, + "", + ].join("\n"); + + return Buffer.from(svg, "utf-8"); +} diff --git a/apps/api/src/routes/meme-templates.ts b/apps/api/src/routes/meme-templates.ts new file mode 100644 index 00000000..0cf8a1df --- /dev/null +++ b/apps/api/src/routes/meme-templates.ts @@ -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 = { + ".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 { + // 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"); + }, + ); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index e56b9049..aede7ca1 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -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 { { 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/apps/api/static/fonts/Anton-Regular.ttf b/apps/api/static/fonts/Anton-Regular.ttf new file mode 100644 index 00000000..4d65707d Binary files /dev/null and b/apps/api/static/fonts/Anton-Regular.ttf differ diff --git a/apps/api/static/fonts/BebasNeue-Regular.ttf b/apps/api/static/fonts/BebasNeue-Regular.ttf new file mode 100644 index 00000000..c328c6e0 Binary files /dev/null and b/apps/api/static/fonts/BebasNeue-Regular.ttf differ diff --git a/apps/api/static/fonts/Montserrat-Black.ttf b/apps/api/static/fonts/Montserrat-Black.ttf new file mode 100644 index 00000000..c7676ee7 Binary files /dev/null and b/apps/api/static/fonts/Montserrat-Black.ttf differ diff --git a/apps/api/static/fonts/PermanentMarker-Regular.ttf b/apps/api/static/fonts/PermanentMarker-Regular.ttf new file mode 100644 index 00000000..3218fc5b Binary files /dev/null and b/apps/api/static/fonts/PermanentMarker-Regular.ttf differ diff --git a/apps/api/static/fonts/Roboto-Black.ttf b/apps/api/static/fonts/Roboto-Black.ttf new file mode 100644 index 00000000..e25c2391 Binary files /dev/null and b/apps/api/static/fonts/Roboto-Black.ttf differ diff --git a/apps/api/static/meme-templates/full/0-days-without-lenny-simpsons.jpg b/apps/api/static/meme-templates/full/0-days-without-lenny-simpsons.jpg new file mode 100644 index 00000000..46a5150a Binary files /dev/null and b/apps/api/static/meme-templates/full/0-days-without-lenny-simpsons.jpg differ diff --git a/apps/api/static/meme-templates/full/a-train-hitting-a-school-bus.jpg b/apps/api/static/meme-templates/full/a-train-hitting-a-school-bus.jpg new file mode 100644 index 00000000..3bc22dfc Binary files /dev/null and b/apps/api/static/meme-templates/full/a-train-hitting-a-school-bus.jpg differ diff --git a/apps/api/static/meme-templates/full/absolute-cinema.jpg b/apps/api/static/meme-templates/full/absolute-cinema.jpg new file mode 100644 index 00000000..493c8a71 Binary files /dev/null and b/apps/api/static/meme-templates/full/absolute-cinema.jpg differ diff --git a/apps/api/static/meme-templates/full/aj-styles-undertaker.jpg b/apps/api/static/meme-templates/full/aj-styles-undertaker.jpg new file mode 100644 index 00000000..b7c5318f Binary files /dev/null and b/apps/api/static/meme-templates/full/aj-styles-undertaker.jpg differ diff --git a/apps/api/static/meme-templates/full/all-my-homies-hate.jpg b/apps/api/static/meme-templates/full/all-my-homies-hate.jpg new file mode 100644 index 00000000..6885b454 Binary files /dev/null and b/apps/api/static/meme-templates/full/all-my-homies-hate.jpg differ diff --git a/apps/api/static/meme-templates/full/always-has-been.jpg b/apps/api/static/meme-templates/full/always-has-been.jpg new file mode 100644 index 00000000..eb17983d Binary files /dev/null and b/apps/api/static/meme-templates/full/always-has-been.jpg differ diff --git a/apps/api/static/meme-templates/full/american-chopper-argument.jpg b/apps/api/static/meme-templates/full/american-chopper-argument.jpg new file mode 100644 index 00000000..8c1e4870 Binary files /dev/null and b/apps/api/static/meme-templates/full/american-chopper-argument.jpg differ diff --git a/apps/api/static/meme-templates/full/anakin-padme-4-panel.jpg b/apps/api/static/meme-templates/full/anakin-padme-4-panel.jpg new file mode 100644 index 00000000..668fa91a Binary files /dev/null and b/apps/api/static/meme-templates/full/anakin-padme-4-panel.jpg differ diff --git a/apps/api/static/meme-templates/full/ancient-aliens.jpg b/apps/api/static/meme-templates/full/ancient-aliens.jpg new file mode 100644 index 00000000..39c74711 Binary files /dev/null and b/apps/api/static/meme-templates/full/ancient-aliens.jpg differ diff --git a/apps/api/static/meme-templates/full/anime-girl-hiding-from-terminator.jpg b/apps/api/static/meme-templates/full/anime-girl-hiding-from-terminator.jpg new file mode 100644 index 00000000..1edd55eb Binary files /dev/null and b/apps/api/static/meme-templates/full/anime-girl-hiding-from-terminator.jpg differ diff --git a/apps/api/static/meme-templates/full/batman-slapping-robin.jpg b/apps/api/static/meme-templates/full/batman-slapping-robin.jpg new file mode 100644 index 00000000..5d9b163f Binary files /dev/null and b/apps/api/static/meme-templates/full/batman-slapping-robin.jpg differ diff --git a/apps/api/static/meme-templates/full/bell-curve.jpg b/apps/api/static/meme-templates/full/bell-curve.jpg new file mode 100644 index 00000000..84c33d5c Binary files /dev/null and b/apps/api/static/meme-templates/full/bell-curve.jpg differ diff --git a/apps/api/static/meme-templates/full/bernie-i-am-once-again-asking-for-your-support.jpg b/apps/api/static/meme-templates/full/bernie-i-am-once-again-asking-for-your-support.jpg new file mode 100644 index 00000000..b281b40d Binary files /dev/null and b/apps/api/static/meme-templates/full/bernie-i-am-once-again-asking-for-your-support.jpg differ diff --git a/apps/api/static/meme-templates/full/bernie-sanders-once-again-asking.jpg b/apps/api/static/meme-templates/full/bernie-sanders-once-again-asking.jpg new file mode 100644 index 00000000..5d72577f Binary files /dev/null and b/apps/api/static/meme-templates/full/bernie-sanders-once-again-asking.jpg differ diff --git a/apps/api/static/meme-templates/full/bike-fall.jpg b/apps/api/static/meme-templates/full/bike-fall.jpg new file mode 100644 index 00000000..1c5c3323 Binary files /dev/null and b/apps/api/static/meme-templates/full/bike-fall.jpg differ diff --git a/apps/api/static/meme-templates/full/blank-nut-button.jpg b/apps/api/static/meme-templates/full/blank-nut-button.jpg new file mode 100644 index 00000000..73b94b65 Binary files /dev/null and b/apps/api/static/meme-templates/full/blank-nut-button.jpg differ diff --git a/apps/api/static/meme-templates/full/boardroom-meeting-suggestion.jpg b/apps/api/static/meme-templates/full/boardroom-meeting-suggestion.jpg new file mode 100644 index 00000000..40badf3d Binary files /dev/null and b/apps/api/static/meme-templates/full/boardroom-meeting-suggestion.jpg differ diff --git a/apps/api/static/meme-templates/full/buff-doge-vs-cheems.jpg b/apps/api/static/meme-templates/full/buff-doge-vs-cheems.jpg new file mode 100644 index 00000000..b44113ea Binary files /dev/null and b/apps/api/static/meme-templates/full/buff-doge-vs-cheems.jpg differ diff --git a/apps/api/static/meme-templates/full/change-my-mind.jpg b/apps/api/static/meme-templates/full/change-my-mind.jpg new file mode 100644 index 00000000..c3412a21 Binary files /dev/null and b/apps/api/static/meme-templates/full/change-my-mind.jpg differ diff --git a/apps/api/static/meme-templates/full/charlie-conspiracy-always-sunny-in-philidelphia.jpg b/apps/api/static/meme-templates/full/charlie-conspiracy-always-sunny-in-philidelphia.jpg new file mode 100644 index 00000000..016e4356 Binary files /dev/null and b/apps/api/static/meme-templates/full/charlie-conspiracy-always-sunny-in-philidelphia.jpg differ diff --git a/apps/api/static/meme-templates/full/clown-applying-makeup.jpg b/apps/api/static/meme-templates/full/clown-applying-makeup.jpg new file mode 100644 index 00000000..daa766cf Binary files /dev/null and b/apps/api/static/meme-templates/full/clown-applying-makeup.jpg differ diff --git a/apps/api/static/meme-templates/full/disappointed-black-guy.jpg b/apps/api/static/meme-templates/full/disappointed-black-guy.jpg new file mode 100644 index 00000000..aa83e84f Binary files /dev/null and b/apps/api/static/meme-templates/full/disappointed-black-guy.jpg differ diff --git a/apps/api/static/meme-templates/full/disaster-girl.jpg b/apps/api/static/meme-templates/full/disaster-girl.jpg new file mode 100644 index 00000000..62517849 Binary files /dev/null and b/apps/api/static/meme-templates/full/disaster-girl.jpg differ diff --git a/apps/api/static/meme-templates/full/distracted-boyfriend.jpg b/apps/api/static/meme-templates/full/distracted-boyfriend.jpg new file mode 100644 index 00000000..0c8781aa Binary files /dev/null and b/apps/api/static/meme-templates/full/distracted-boyfriend.jpg differ diff --git a/apps/api/static/meme-templates/full/domino-effect.jpg b/apps/api/static/meme-templates/full/domino-effect.jpg new file mode 100644 index 00000000..2501a33f Binary files /dev/null and b/apps/api/static/meme-templates/full/domino-effect.jpg differ diff --git a/apps/api/static/meme-templates/full/drake-hotline-bling.jpg b/apps/api/static/meme-templates/full/drake-hotline-bling.jpg new file mode 100644 index 00000000..682b38c3 Binary files /dev/null and b/apps/api/static/meme-templates/full/drake-hotline-bling.jpg differ diff --git a/apps/api/static/meme-templates/full/epic-handshake.jpg b/apps/api/static/meme-templates/full/epic-handshake.jpg new file mode 100644 index 00000000..187aa9a0 Binary files /dev/null and b/apps/api/static/meme-templates/full/epic-handshake.jpg differ diff --git a/apps/api/static/meme-templates/full/evil-kermit.jpg b/apps/api/static/meme-templates/full/evil-kermit.jpg new file mode 100644 index 00000000..1fa26fc1 Binary files /dev/null and b/apps/api/static/meme-templates/full/evil-kermit.jpg differ diff --git a/apps/api/static/meme-templates/full/expanding-brain.jpg b/apps/api/static/meme-templates/full/expanding-brain.jpg new file mode 100644 index 00000000..8d4f8781 Binary files /dev/null and b/apps/api/static/meme-templates/full/expanding-brain.jpg differ diff --git a/apps/api/static/meme-templates/full/finding-neverland.jpg b/apps/api/static/meme-templates/full/finding-neverland.jpg new file mode 100644 index 00000000..6764acbf Binary files /dev/null and b/apps/api/static/meme-templates/full/finding-neverland.jpg differ diff --git a/apps/api/static/meme-templates/full/flex-tape.jpg b/apps/api/static/meme-templates/full/flex-tape.jpg new file mode 100644 index 00000000..d0ffd00e Binary files /dev/null and b/apps/api/static/meme-templates/full/flex-tape.jpg differ diff --git a/apps/api/static/meme-templates/full/friendship-ended.jpg b/apps/api/static/meme-templates/full/friendship-ended.jpg new file mode 100644 index 00000000..79ff4c0e Binary files /dev/null and b/apps/api/static/meme-templates/full/friendship-ended.jpg differ diff --git a/apps/api/static/meme-templates/full/futurama-fry.jpg b/apps/api/static/meme-templates/full/futurama-fry.jpg new file mode 100644 index 00000000..a6484a2d Binary files /dev/null and b/apps/api/static/meme-templates/full/futurama-fry.jpg differ diff --git a/apps/api/static/meme-templates/full/george-bush-9-11.jpg b/apps/api/static/meme-templates/full/george-bush-9-11.jpg new file mode 100644 index 00000000..8a910cc7 Binary files /dev/null and b/apps/api/static/meme-templates/full/george-bush-9-11.jpg differ diff --git a/apps/api/static/meme-templates/full/grandma-finds-the-internet.jpg b/apps/api/static/meme-templates/full/grandma-finds-the-internet.jpg new file mode 100644 index 00000000..1bab7967 Binary files /dev/null and b/apps/api/static/meme-templates/full/grandma-finds-the-internet.jpg differ diff --git a/apps/api/static/meme-templates/full/grant-gustin-over-grave.jpg b/apps/api/static/meme-templates/full/grant-gustin-over-grave.jpg new file mode 100644 index 00000000..4006d5c6 Binary files /dev/null and b/apps/api/static/meme-templates/full/grant-gustin-over-grave.jpg differ diff --git a/apps/api/static/meme-templates/full/grim-reaper-knocking-door.jpg b/apps/api/static/meme-templates/full/grim-reaper-knocking-door.jpg new file mode 100644 index 00000000..f19dc830 Binary files /dev/null and b/apps/api/static/meme-templates/full/grim-reaper-knocking-door.jpg differ diff --git a/apps/api/static/meme-templates/full/gru-s-plan.jpg b/apps/api/static/meme-templates/full/gru-s-plan.jpg new file mode 100644 index 00000000..4887bd7c Binary files /dev/null and b/apps/api/static/meme-templates/full/gru-s-plan.jpg differ diff --git a/apps/api/static/meme-templates/full/grumpy-cat.jpg b/apps/api/static/meme-templates/full/grumpy-cat.jpg new file mode 100644 index 00000000..d40c04bb Binary files /dev/null and b/apps/api/static/meme-templates/full/grumpy-cat.jpg differ diff --git a/apps/api/static/meme-templates/full/hide-the-pain-harold.jpg b/apps/api/static/meme-templates/full/hide-the-pain-harold.jpg new file mode 100644 index 00000000..12c8939b Binary files /dev/null and b/apps/api/static/meme-templates/full/hide-the-pain-harold.jpg differ diff --git a/apps/api/static/meme-templates/full/i-bet-he-s-thinking-about-other-women.jpg b/apps/api/static/meme-templates/full/i-bet-he-s-thinking-about-other-women.jpg new file mode 100644 index 00000000..3945783f Binary files /dev/null and b/apps/api/static/meme-templates/full/i-bet-he-s-thinking-about-other-women.jpg differ diff --git a/apps/api/static/meme-templates/full/i-m-the-captain-now.jpg b/apps/api/static/meme-templates/full/i-m-the-captain-now.jpg new file mode 100644 index 00000000..4c8a4d55 Binary files /dev/null and b/apps/api/static/meme-templates/full/i-m-the-captain-now.jpg differ diff --git a/apps/api/static/meme-templates/full/imagination-spongebob.jpg b/apps/api/static/meme-templates/full/imagination-spongebob.jpg new file mode 100644 index 00000000..43ab3fc1 Binary files /dev/null and b/apps/api/static/meme-templates/full/imagination-spongebob.jpg differ diff --git a/apps/api/static/meme-templates/full/inhaling-seagull.jpg b/apps/api/static/meme-templates/full/inhaling-seagull.jpg new file mode 100644 index 00000000..039eef41 Binary files /dev/null and b/apps/api/static/meme-templates/full/inhaling-seagull.jpg differ diff --git a/apps/api/static/meme-templates/full/is-this-a-pigeon.jpg b/apps/api/static/meme-templates/full/is-this-a-pigeon.jpg new file mode 100644 index 00000000..2e64ba34 Binary files /dev/null and b/apps/api/static/meme-templates/full/is-this-a-pigeon.jpg differ diff --git a/apps/api/static/meme-templates/full/is-this-butterfly.jpg b/apps/api/static/meme-templates/full/is-this-butterfly.jpg new file mode 100644 index 00000000..36cfe3f1 Binary files /dev/null and b/apps/api/static/meme-templates/full/is-this-butterfly.jpg differ diff --git a/apps/api/static/meme-templates/full/laughing-leo.jpg b/apps/api/static/meme-templates/full/laughing-leo.jpg new file mode 100644 index 00000000..56022c91 Binary files /dev/null and b/apps/api/static/meme-templates/full/laughing-leo.jpg differ diff --git a/apps/api/static/meme-templates/full/left-exit-12-off-ramp.jpg b/apps/api/static/meme-templates/full/left-exit-12-off-ramp.jpg new file mode 100644 index 00000000..cfdea680 Binary files /dev/null and b/apps/api/static/meme-templates/full/left-exit-12-off-ramp.jpg differ diff --git a/apps/api/static/meme-templates/full/leonardo-dicaprio-cheers.jpg b/apps/api/static/meme-templates/full/leonardo-dicaprio-cheers.jpg new file mode 100644 index 00000000..4f411bca Binary files /dev/null and b/apps/api/static/meme-templates/full/leonardo-dicaprio-cheers.jpg differ diff --git a/apps/api/static/meme-templates/full/look-at-me.jpg b/apps/api/static/meme-templates/full/look-at-me.jpg new file mode 100644 index 00000000..99ae2f3c Binary files /dev/null and b/apps/api/static/meme-templates/full/look-at-me.jpg differ diff --git a/apps/api/static/meme-templates/full/marked-safe-from.jpg b/apps/api/static/meme-templates/full/marked-safe-from.jpg new file mode 100644 index 00000000..1f50d303 Binary files /dev/null and b/apps/api/static/meme-templates/full/marked-safe-from.jpg differ diff --git a/apps/api/static/meme-templates/full/megamind-no-bitches.jpg b/apps/api/static/meme-templates/full/megamind-no-bitches.jpg new file mode 100644 index 00000000..79e333ab Binary files /dev/null and b/apps/api/static/meme-templates/full/megamind-no-bitches.jpg differ diff --git a/apps/api/static/meme-templates/full/megamind-peeking.jpg b/apps/api/static/meme-templates/full/megamind-peeking.jpg new file mode 100644 index 00000000..52bf5e53 Binary files /dev/null and b/apps/api/static/meme-templates/full/megamind-peeking.jpg differ diff --git a/apps/api/static/meme-templates/full/mocking-spongebob.jpg b/apps/api/static/meme-templates/full/mocking-spongebob.jpg new file mode 100644 index 00000000..7d3d33ee Binary files /dev/null and b/apps/api/static/meme-templates/full/mocking-spongebob.jpg differ diff --git a/apps/api/static/meme-templates/full/monkey-puppet.jpg b/apps/api/static/meme-templates/full/monkey-puppet.jpg new file mode 100644 index 00000000..e2da8f15 Binary files /dev/null and b/apps/api/static/meme-templates/full/monkey-puppet.jpg differ diff --git a/apps/api/static/meme-templates/full/mother-ignoring-kid-drowning-in-a-pool.jpg b/apps/api/static/meme-templates/full/mother-ignoring-kid-drowning-in-a-pool.jpg new file mode 100644 index 00000000..76dd96a4 Binary files /dev/null and b/apps/api/static/meme-templates/full/mother-ignoring-kid-drowning-in-a-pool.jpg differ diff --git a/apps/api/static/meme-templates/full/no-yes.jpg b/apps/api/static/meme-templates/full/no-yes.jpg new file mode 100644 index 00000000..847714a3 Binary files /dev/null and b/apps/api/static/meme-templates/full/no-yes.jpg differ diff --git a/apps/api/static/meme-templates/full/one-does-not-simply.jpg b/apps/api/static/meme-templates/full/one-does-not-simply.jpg new file mode 100644 index 00000000..863953d0 Binary files /dev/null and b/apps/api/static/meme-templates/full/one-does-not-simply.jpg differ diff --git a/apps/api/static/meme-templates/full/oprah-you-get-a.jpg b/apps/api/static/meme-templates/full/oprah-you-get-a.jpg new file mode 100644 index 00000000..3e46a3db Binary files /dev/null and b/apps/api/static/meme-templates/full/oprah-you-get-a.jpg differ diff --git a/apps/api/static/meme-templates/full/panik-kalm-panik.jpg b/apps/api/static/meme-templates/full/panik-kalm-panik.jpg new file mode 100644 index 00000000..7dd74317 Binary files /dev/null and b/apps/api/static/meme-templates/full/panik-kalm-panik.jpg differ diff --git a/apps/api/static/meme-templates/full/patrick-to-do-list-actually-blank.jpg b/apps/api/static/meme-templates/full/patrick-to-do-list-actually-blank.jpg new file mode 100644 index 00000000..b6260ea0 Binary files /dev/null and b/apps/api/static/meme-templates/full/patrick-to-do-list-actually-blank.jpg differ diff --git a/apps/api/static/meme-templates/full/pawn-stars-best-i-can-do.jpg b/apps/api/static/meme-templates/full/pawn-stars-best-i-can-do.jpg new file mode 100644 index 00000000..eb583147 Binary files /dev/null and b/apps/api/static/meme-templates/full/pawn-stars-best-i-can-do.jpg differ diff --git a/apps/api/static/meme-templates/full/roll-safe-think-about-it.jpg b/apps/api/static/meme-templates/full/roll-safe-think-about-it.jpg new file mode 100644 index 00000000..319d1776 Binary files /dev/null and b/apps/api/static/meme-templates/full/roll-safe-think-about-it.jpg differ diff --git a/apps/api/static/meme-templates/full/running-away-balloon.jpg b/apps/api/static/meme-templates/full/running-away-balloon.jpg new file mode 100644 index 00000000..b8863818 Binary files /dev/null and b/apps/api/static/meme-templates/full/running-away-balloon.jpg differ diff --git a/apps/api/static/meme-templates/full/sad-pablo-escobar.jpg b/apps/api/static/meme-templates/full/sad-pablo-escobar.jpg new file mode 100644 index 00000000..fa5ad1f3 Binary files /dev/null and b/apps/api/static/meme-templates/full/sad-pablo-escobar.jpg differ diff --git a/apps/api/static/meme-templates/full/say-the-line-bart-simpsons.jpg b/apps/api/static/meme-templates/full/say-the-line-bart-simpsons.jpg new file mode 100644 index 00000000..c5a5ba72 Binary files /dev/null and b/apps/api/static/meme-templates/full/say-the-line-bart-simpsons.jpg differ diff --git a/apps/api/static/meme-templates/full/scooby-doo-mask-reveal.jpg b/apps/api/static/meme-templates/full/scooby-doo-mask-reveal.jpg new file mode 100644 index 00000000..c05da875 Binary files /dev/null and b/apps/api/static/meme-templates/full/scooby-doo-mask-reveal.jpg differ diff --git a/apps/api/static/meme-templates/full/sleeping-shaq.jpg b/apps/api/static/meme-templates/full/sleeping-shaq.jpg new file mode 100644 index 00000000..5b754e1d Binary files /dev/null and b/apps/api/static/meme-templates/full/sleeping-shaq.jpg differ diff --git a/apps/api/static/meme-templates/full/soldier-protecting-sleeping-child.jpg b/apps/api/static/meme-templates/full/soldier-protecting-sleeping-child.jpg new file mode 100644 index 00000000..9a3a7880 Binary files /dev/null and b/apps/api/static/meme-templates/full/soldier-protecting-sleeping-child.jpg differ diff --git a/apps/api/static/meme-templates/full/spider-man-triple.jpg b/apps/api/static/meme-templates/full/spider-man-triple.jpg new file mode 100644 index 00000000..a647aae6 Binary files /dev/null and b/apps/api/static/meme-templates/full/spider-man-triple.jpg differ diff --git a/apps/api/static/meme-templates/full/spiderman-pointing-at-spiderman.jpg b/apps/api/static/meme-templates/full/spiderman-pointing-at-spiderman.jpg new file mode 100644 index 00000000..87a58c87 Binary files /dev/null and b/apps/api/static/meme-templates/full/spiderman-pointing-at-spiderman.jpg differ diff --git a/apps/api/static/meme-templates/full/squidward-window.jpg b/apps/api/static/meme-templates/full/squidward-window.jpg new file mode 100644 index 00000000..e50f43a6 Binary files /dev/null and b/apps/api/static/meme-templates/full/squidward-window.jpg differ diff --git a/apps/api/static/meme-templates/full/star-wars-yoda.jpg b/apps/api/static/meme-templates/full/star-wars-yoda.jpg new file mode 100644 index 00000000..b64c9857 Binary files /dev/null and b/apps/api/static/meme-templates/full/star-wars-yoda.jpg differ diff --git a/apps/api/static/meme-templates/full/success-kid.jpg b/apps/api/static/meme-templates/full/success-kid.jpg new file mode 100644 index 00000000..d244fc88 Binary files /dev/null and b/apps/api/static/meme-templates/full/success-kid.jpg differ diff --git a/apps/api/static/meme-templates/full/surprised-pikachu.jpg b/apps/api/static/meme-templates/full/surprised-pikachu.jpg new file mode 100644 index 00000000..1bd741a0 Binary files /dev/null and b/apps/api/static/meme-templates/full/surprised-pikachu.jpg differ diff --git a/apps/api/static/meme-templates/full/the-rock-driving.jpg b/apps/api/static/meme-templates/full/the-rock-driving.jpg new file mode 100644 index 00000000..26b27693 Binary files /dev/null and b/apps/api/static/meme-templates/full/the-rock-driving.jpg differ diff --git a/apps/api/static/meme-templates/full/the-scroll-of-truth.jpg b/apps/api/static/meme-templates/full/the-scroll-of-truth.jpg new file mode 100644 index 00000000..a1a2b52a Binary files /dev/null and b/apps/api/static/meme-templates/full/the-scroll-of-truth.jpg differ diff --git a/apps/api/static/meme-templates/full/they-don-t-know.jpg b/apps/api/static/meme-templates/full/they-don-t-know.jpg new file mode 100644 index 00000000..dc83793a Binary files /dev/null and b/apps/api/static/meme-templates/full/they-don-t-know.jpg differ diff --git a/apps/api/static/meme-templates/full/they-re-the-same-picture.jpg b/apps/api/static/meme-templates/full/they-re-the-same-picture.jpg new file mode 100644 index 00000000..ecbc5744 Binary files /dev/null and b/apps/api/static/meme-templates/full/they-re-the-same-picture.jpg differ diff --git a/apps/api/static/meme-templates/full/third-world-skeptical-kid.jpg b/apps/api/static/meme-templates/full/third-world-skeptical-kid.jpg new file mode 100644 index 00000000..65335764 Binary files /dev/null and b/apps/api/static/meme-templates/full/third-world-skeptical-kid.jpg differ diff --git a/apps/api/static/meme-templates/full/this-is-fine.jpg b/apps/api/static/meme-templates/full/this-is-fine.jpg new file mode 100644 index 00000000..1ee9fd0a Binary files /dev/null and b/apps/api/static/meme-templates/full/this-is-fine.jpg differ diff --git a/apps/api/static/meme-templates/full/this-is-where-i-d-put-my-trophy-if-i-had-one.jpg b/apps/api/static/meme-templates/full/this-is-where-i-d-put-my-trophy-if-i-had-one.jpg new file mode 100644 index 00000000..7ae6fa73 Binary files /dev/null and b/apps/api/static/meme-templates/full/this-is-where-i-d-put-my-trophy-if-i-had-one.jpg differ diff --git a/apps/api/static/meme-templates/full/three-headed-dragon.jpg b/apps/api/static/meme-templates/full/three-headed-dragon.jpg new file mode 100644 index 00000000..bb32a90a Binary files /dev/null and b/apps/api/static/meme-templates/full/three-headed-dragon.jpg differ diff --git a/apps/api/static/meme-templates/full/trade-offer.jpg b/apps/api/static/meme-templates/full/trade-offer.jpg new file mode 100644 index 00000000..6e7edb16 Binary files /dev/null and b/apps/api/static/meme-templates/full/trade-offer.jpg differ diff --git a/apps/api/static/meme-templates/full/trump-bill-signing.jpg b/apps/api/static/meme-templates/full/trump-bill-signing.jpg new file mode 100644 index 00000000..725b5c07 Binary files /dev/null and b/apps/api/static/meme-templates/full/trump-bill-signing.jpg differ diff --git a/apps/api/static/meme-templates/full/tuxedo-winnie-the-pooh.jpg b/apps/api/static/meme-templates/full/tuxedo-winnie-the-pooh.jpg new file mode 100644 index 00000000..80a3ef86 Binary files /dev/null and b/apps/api/static/meme-templates/full/tuxedo-winnie-the-pooh.jpg differ diff --git a/apps/api/static/meme-templates/full/two-buttons.jpg b/apps/api/static/meme-templates/full/two-buttons.jpg new file mode 100644 index 00000000..f555b260 Binary files /dev/null and b/apps/api/static/meme-templates/full/two-buttons.jpg differ diff --git a/apps/api/static/meme-templates/full/two-guys-on-a-bus.jpg b/apps/api/static/meme-templates/full/two-guys-on-a-bus.jpg new file mode 100644 index 00000000..6b9593f4 Binary files /dev/null and b/apps/api/static/meme-templates/full/two-guys-on-a-bus.jpg differ diff --git a/apps/api/static/meme-templates/full/two-paths.jpg b/apps/api/static/meme-templates/full/two-paths.jpg new file mode 100644 index 00000000..5f643230 Binary files /dev/null and b/apps/api/static/meme-templates/full/two-paths.jpg differ diff --git a/apps/api/static/meme-templates/full/types-of-headaches-meme.jpg b/apps/api/static/meme-templates/full/types-of-headaches-meme.jpg new file mode 100644 index 00000000..353f9b12 Binary files /dev/null and b/apps/api/static/meme-templates/full/types-of-headaches-meme.jpg differ diff --git a/apps/api/static/meme-templates/full/uno-draw-25-cards.jpg b/apps/api/static/meme-templates/full/uno-draw-25-cards.jpg new file mode 100644 index 00000000..0ad21f30 Binary files /dev/null and b/apps/api/static/meme-templates/full/uno-draw-25-cards.jpg differ diff --git a/apps/api/static/meme-templates/full/waiting-skeleton.jpg b/apps/api/static/meme-templates/full/waiting-skeleton.jpg new file mode 100644 index 00000000..d9908484 Binary files /dev/null and b/apps/api/static/meme-templates/full/waiting-skeleton.jpg differ diff --git a/apps/api/static/meme-templates/full/whe-i-m-in-a-competition-and-my-opponent-is.jpg b/apps/api/static/meme-templates/full/whe-i-m-in-a-competition-and-my-opponent-is.jpg new file mode 100644 index 00000000..4341c441 Binary files /dev/null and b/apps/api/static/meme-templates/full/whe-i-m-in-a-competition-and-my-opponent-is.jpg differ diff --git a/apps/api/static/meme-templates/full/where-monkey.jpg b/apps/api/static/meme-templates/full/where-monkey.jpg new file mode 100644 index 00000000..ffbe8f40 Binary files /dev/null and b/apps/api/static/meme-templates/full/where-monkey.jpg differ diff --git a/apps/api/static/meme-templates/full/whisper-and-goosebumps.jpg b/apps/api/static/meme-templates/full/whisper-and-goosebumps.jpg new file mode 100644 index 00000000..b83d9fa6 Binary files /dev/null and b/apps/api/static/meme-templates/full/whisper-and-goosebumps.jpg differ diff --git a/apps/api/static/meme-templates/full/who-killed-hannibal.jpg b/apps/api/static/meme-templates/full/who-killed-hannibal.jpg new file mode 100644 index 00000000..561e9324 Binary files /dev/null and b/apps/api/static/meme-templates/full/who-killed-hannibal.jpg differ diff --git a/apps/api/static/meme-templates/full/woman-yelling-at-cat.jpg b/apps/api/static/meme-templates/full/woman-yelling-at-cat.jpg new file mode 100644 index 00000000..879ad2fc Binary files /dev/null and b/apps/api/static/meme-templates/full/woman-yelling-at-cat.jpg differ diff --git a/apps/api/static/meme-templates/full/x-x-everywhere.jpg b/apps/api/static/meme-templates/full/x-x-everywhere.jpg new file mode 100644 index 00000000..b15a8316 Binary files /dev/null and b/apps/api/static/meme-templates/full/x-x-everywhere.jpg differ diff --git a/apps/api/static/meme-templates/full/y-all-got-any-more-of-that.jpg b/apps/api/static/meme-templates/full/y-all-got-any-more-of-that.jpg new file mode 100644 index 00000000..a611e59a Binary files /dev/null and b/apps/api/static/meme-templates/full/y-all-got-any-more-of-that.jpg differ diff --git a/apps/api/static/meme-templates/full/yo-dawg-heard-you.jpg b/apps/api/static/meme-templates/full/yo-dawg-heard-you.jpg new file mode 100644 index 00000000..8510a8a2 Binary files /dev/null and b/apps/api/static/meme-templates/full/yo-dawg-heard-you.jpg differ diff --git a/apps/api/static/meme-templates/full/you-guys-are-getting-paid.jpg b/apps/api/static/meme-templates/full/you-guys-are-getting-paid.jpg new file mode 100644 index 00000000..04e7c8e0 Binary files /dev/null and b/apps/api/static/meme-templates/full/you-guys-are-getting-paid.jpg differ diff --git a/apps/api/static/meme-templates/meme-templates.json b/apps/api/static/meme-templates/meme-templates.json new file mode 100644 index 00000000..c997d797 --- /dev/null +++ b/apps/api/static/meme-templates/meme-templates.json @@ -0,0 +1,3062 @@ +{ + "version": 1, + "categories": ["reaction", "comparison", "opinion", "animals", "classic"], + "templates": [ + { + "id": "drake-hotline-bling", + "name": "Drake Hotline Bling", + "aliases": [], + "tags": ["drake", "hotline", "bling"], + "category": "reaction", + "filename": "drake-hotline-bling.jpg", + "width": 1200, + "height": 1200, + "popularity": 1, + "textBoxes": [ + { + "id": "reject", + "x": 52, + "y": 0, + "width": 48, + "height": 50, + "defaultText": "Thing I reject" + }, + { + "id": "approve", + "x": 52, + "y": 50, + "width": 48, + "height": 50, + "defaultText": "Thing I approve" + } + ] + }, + { + "id": "two-buttons", + "name": "Two Buttons", + "aliases": [], + "tags": ["two", "buttons"], + "category": "reaction", + "filename": "two-buttons.jpg", + "width": 600, + "height": 908, + "popularity": 2, + "textBoxes": [ + { + "id": "left-button", + "x": 5, + "y": 2, + "width": 38, + "height": 22, + "defaultText": "Option A" + }, + { + "id": "right-button", + "x": 45, + "y": 2, + "width": 38, + "height": 22, + "defaultText": "Option B" + } + ] + }, + { + "id": "distracted-boyfriend", + "name": "Distracted Boyfriend", + "aliases": [], + "tags": ["distracted", "boyfriend"], + "category": "comparison", + "filename": "distracted-boyfriend.jpg", + "width": 1200, + "height": 800, + "popularity": 3, + "textBoxes": [ + { + "id": "girl-behind", + "x": 58, + "y": 15, + "width": 30, + "height": 30, + "defaultText": "Current thing" + }, + { + "id": "guy", + "x": 32, + "y": 5, + "width": 25, + "height": 30, + "defaultText": "Me" + }, + { + "id": "girl-front", + "x": 2, + "y": 15, + "width": 28, + "height": 30, + "defaultText": "New shiny thing" + } + ] + }, + { + "id": "uno-draw-25-cards", + "name": "UNO Draw 25 Cards", + "aliases": [], + "tags": ["uno", "draw", "cards"], + "category": "comparison", + "filename": "uno-draw-25-cards.jpg", + "width": 500, + "height": 494, + "popularity": 4, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "bernie-i-am-once-again-asking-for-your-support", + "name": "Bernie I Am Once Again Asking For Your Support", + "aliases": [], + "tags": ["bernie", "once", "again", "asking", "for", "your", "support"], + "category": "opinion", + "filename": "bernie-i-am-once-again-asking-for-your-support.jpg", + "width": 750, + "height": 750, + "popularity": 5, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "left-exit-12-off-ramp", + "name": "Left Exit 12 Off Ramp", + "aliases": [], + "tags": ["left", "exit", "off", "ramp"], + "category": "comparison", + "filename": "left-exit-12-off-ramp.jpg", + "width": 804, + "height": 767, + "popularity": 6, + "textBoxes": [ + { + "id": "straight", + "x": 35, + "y": 2, + "width": 30, + "height": 15, + "defaultText": "Good choice" + }, + { + "id": "exit", + "x": 65, + "y": 2, + "width": 30, + "height": 15, + "defaultText": "Bad choice" + }, + { + "id": "car", + "x": 35, + "y": 65, + "width": 40, + "height": 20, + "defaultText": "Me" + } + ] + }, + { + "id": "always-has-been", + "name": "Always Has Been", + "aliases": [], + "tags": ["always", "has", "been"], + "category": "reaction", + "filename": "always-has-been.jpg", + "width": 960, + "height": 540, + "popularity": 7, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "running-away-balloon", + "name": "Running Away Balloon", + "aliases": [], + "tags": ["running", "away", "balloon"], + "category": "opinion", + "filename": "running-away-balloon.jpg", + "width": 761, + "height": 1024, + "popularity": 8, + "textBoxes": [ + { + "id": "person", + "x": 50, + "y": 55, + "width": 25, + "height": 15, + "defaultText": "Me" + }, + { + "id": "balloon", + "x": 55, + "y": 5, + "width": 30, + "height": 15, + "defaultText": "Responsibilities" + }, + { + "id": "distraction", + "x": 2, + "y": 55, + "width": 25, + "height": 15, + "defaultText": "Distraction" + } + ] + }, + { + "id": "anakin-padme-4-panel", + "name": "Anakin Padme 4 Panel", + "aliases": [], + "tags": ["anakin", "padme", "panel"], + "category": "reaction", + "filename": "anakin-padme-4-panel.jpg", + "width": 768, + "height": 768, + "popularity": 9, + "textBoxes": [ + { + "id": "anakin-1", + "x": 0, + "y": 0, + "width": 50, + "height": 15, + "defaultText": "Statement" + }, + { + "id": "padme-1", + "x": 50, + "y": 0, + "width": 50, + "height": 50, + "defaultText": "Right...?" + }, + { + "id": "anakin-2", + "x": 0, + "y": 50, + "width": 50, + "height": 50, + "defaultText": "..." + } + ] + }, + { + "id": "gru-s-plan", + "name": "Gru's Plan", + "aliases": [], + "tags": ["grus", "plan"], + "category": "comparison", + "filename": "gru-s-plan.jpg", + "width": 700, + "height": 449, + "popularity": 10, + "textBoxes": [ + { + "id": "step-1", + "x": 52, + "y": 0, + "width": 46, + "height": 25, + "defaultText": "Step 1" + }, + { + "id": "step-2", + "x": 52, + "y": 25, + "width": 46, + "height": 25, + "defaultText": "Step 2" + }, + { + "id": "step-3", + "x": 52, + "y": 50, + "width": 46, + "height": 25, + "defaultText": "Unexpected result" + }, + { + "id": "realization", + "x": 52, + "y": 75, + "width": 46, + "height": 25, + "defaultText": "Wait..." + } + ] + }, + { + "id": "epic-handshake", + "name": "Epic Handshake", + "aliases": [], + "tags": ["epic", "handshake"], + "category": "comparison", + "filename": "epic-handshake.jpg", + "width": 900, + "height": 645, + "popularity": 11, + "textBoxes": [ + { + "id": "left", + "x": 2, + "y": 2, + "width": 30, + "height": 20, + "defaultText": "Group A" + }, + { + "id": "center", + "x": 25, + "y": 70, + "width": 50, + "height": 25, + "defaultText": "Shared thing" + }, + { + "id": "right", + "x": 68, + "y": 2, + "width": 30, + "height": 20, + "defaultText": "Group B" + } + ] + }, + { + "id": "disaster-girl", + "name": "Disaster Girl", + "aliases": [], + "tags": ["disaster", "girl"], + "category": "reaction", + "filename": "disaster-girl.jpg", + "width": 500, + "height": 375, + "popularity": 12, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "sad-pablo-escobar", + "name": "Sad Pablo Escobar", + "aliases": [], + "tags": ["sad", "pablo", "escobar"], + "category": "reaction", + "filename": "sad-pablo-escobar.jpg", + "width": 720, + "height": 709, + "popularity": 13, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "When you..." + }, + { + "id": "middle", + "x": 5, + "y": 40, + "width": 90, + "height": 20 + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "...sad" + } + ] + }, + { + "id": "waiting-skeleton", + "name": "Waiting Skeleton", + "aliases": [], + "tags": ["waiting", "skeleton"], + "category": "animals", + "filename": "waiting-skeleton.jpg", + "width": 298, + "height": 403, + "popularity": 14, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "x-x-everywhere", + "name": "X, X Everywhere", + "aliases": [], + "tags": ["everywhere"], + "category": "opinion", + "filename": "x-x-everywhere.jpg", + "width": 2118, + "height": 1440, + "popularity": 15, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "woman-yelling-at-cat", + "name": "Woman Yelling At Cat", + "aliases": [], + "tags": ["woman", "yelling", "cat"], + "category": "animals", + "filename": "woman-yelling-at-cat.jpg", + "width": 680, + "height": 438, + "popularity": 16, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "change-my-mind", + "name": "Change My Mind", + "aliases": [], + "tags": ["change", "mind"], + "category": "opinion", + "filename": "change-my-mind.jpg", + "width": 482, + "height": 361, + "popularity": 17, + "textBoxes": [ + { + "id": "sign", + "x": 24, + "y": 55, + "width": 50, + "height": 30, + "defaultText": "Your hot take here" + } + ] + }, + { + "id": "buff-doge-vs-cheems", + "name": "Buff Doge vs. Cheems", + "aliases": [], + "tags": ["buff", "doge", "cheems"], + "category": "comparison", + "filename": "buff-doge-vs-cheems.jpg", + "width": 937, + "height": 720, + "popularity": 18, + "textBoxes": [ + { + "id": "buff-label", + "x": 2, + "y": 2, + "width": 45, + "height": 15, + "defaultText": "Strong version" + }, + { + "id": "buff-text", + "x": 2, + "y": 75, + "width": 45, + "height": 23, + "defaultText": "Chad description" + }, + { + "id": "cheems-label", + "x": 52, + "y": 2, + "width": 45, + "height": 15, + "defaultText": "Weak version" + }, + { + "id": "cheems-text", + "x": 52, + "y": 75, + "width": 45, + "height": 23, + "defaultText": "Sad description" + } + ] + }, + { + "id": "batman-slapping-robin", + "name": "Batman Slapping Robin", + "aliases": [], + "tags": ["batman", "slapping", "robin"], + "category": "classic", + "filename": "batman-slapping-robin.jpg", + "width": 400, + "height": 387, + "popularity": 19, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "mocking-spongebob", + "name": "Mocking Spongebob", + "aliases": [], + "tags": ["mocking", "spongebob"], + "category": "animals", + "filename": "mocking-spongebob.jpg", + "width": 502, + "height": 353, + "popularity": 20, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "expanding-brain", + "name": "Expanding Brain", + "aliases": [], + "tags": ["expanding", "brain"], + "category": "comparison", + "filename": "expanding-brain.jpg", + "width": 857, + "height": 1202, + "popularity": 21, + "textBoxes": [ + { + "id": "panel-1", + "x": 0, + "y": 0, + "width": 50, + "height": 25, + "defaultText": "Normal idea" + }, + { + "id": "panel-2", + "x": 0, + "y": 25, + "width": 50, + "height": 25, + "defaultText": "Smarter idea" + }, + { + "id": "panel-3", + "x": 0, + "y": 50, + "width": 50, + "height": 25, + "defaultText": "Big brain idea" + }, + { + "id": "panel-4", + "x": 0, + "y": 75, + "width": 50, + "height": 25, + "defaultText": "Galaxy brain idea" + } + ] + }, + { + "id": "trade-offer", + "name": "Trade Offer", + "aliases": [], + "tags": ["trade", "offer"], + "category": "reaction", + "filename": "trade-offer.jpg", + "width": 607, + "height": 794, + "popularity": 22, + "textBoxes": [ + { + "id": "i-receive", + "x": 5, + "y": 32, + "width": 42, + "height": 32, + "defaultText": "I receive" + }, + { + "id": "you-receive", + "x": 53, + "y": 32, + "width": 42, + "height": 32, + "defaultText": "You receive" + } + ] + }, + { + "id": "y-all-got-any-more-of-that", + "name": "Y'all Got Any More Of That", + "aliases": [], + "tags": ["yall", "got", "any", "more", "that"], + "category": "reaction", + "filename": "y-all-got-any-more-of-that.jpg", + "width": 600, + "height": 471, + "popularity": 23, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "tuxedo-winnie-the-pooh", + "name": "Tuxedo Winnie The Pooh", + "aliases": [], + "tags": ["tuxedo", "winnie", "the", "pooh"], + "category": "comparison", + "filename": "tuxedo-winnie-the-pooh.jpg", + "width": 800, + "height": 582, + "popularity": 24, + "textBoxes": [ + { + "id": "regular", + "x": 52, + "y": 2, + "width": 46, + "height": 48, + "defaultText": "Regular way" + }, + { + "id": "fancy", + "x": 52, + "y": 52, + "width": 46, + "height": 46, + "defaultText": "Fancy way" + } + ] + }, + { + "id": "this-is-fine", + "name": "This Is Fine", + "aliases": [], + "tags": ["this", "fine"], + "category": "reaction", + "filename": "this-is-fine.jpg", + "width": 580, + "height": 282, + "popularity": 25, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "ancient-aliens", + "name": "Ancient Aliens", + "aliases": [], + "tags": ["ancient", "aliens"], + "category": "classic", + "filename": "ancient-aliens.jpg", + "width": 500, + "height": 437, + "popularity": 26, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "bike-fall", + "name": "Bike Fall", + "aliases": [], + "tags": ["bike", "fall"], + "category": "comparison", + "filename": "bike-fall.jpg", + "width": 500, + "height": 680, + "popularity": 27, + "textBoxes": [ + { + "id": "stick", + "x": 50, + "y": 0, + "width": 48, + "height": 33, + "defaultText": "My plan" + }, + { + "id": "wheel", + "x": 5, + "y": 33, + "width": 48, + "height": 33, + "defaultText": "What went wrong" + }, + { + "id": "ground", + "x": 50, + "y": 66, + "width": 48, + "height": 33, + "defaultText": "The consequence" + } + ] + }, + { + "id": "they-re-the-same-picture", + "name": "They're The Same Picture", + "aliases": [], + "tags": ["theyre", "the", "same", "picture"], + "category": "comparison", + "filename": "they-re-the-same-picture.jpg", + "width": 1363, + "height": 1524, + "popularity": 28, + "textBoxes": [ + { + "id": "left-image", + "x": 10, + "y": 8, + "width": 35, + "height": 25, + "defaultText": "Thing A" + }, + { + "id": "right-image", + "x": 55, + "y": 8, + "width": 35, + "height": 25, + "defaultText": "Thing B" + }, + { + "id": "caption", + "x": 10, + "y": 72, + "width": 80, + "height": 15, + "defaultText": "They're the same picture" + } + ] + }, + { + "id": "one-does-not-simply", + "name": "One Does Not Simply", + "aliases": [], + "tags": ["one", "does", "not", "simply"], + "category": "classic", + "filename": "one-does-not-simply.jpg", + "width": 568, + "height": 335, + "popularity": 29, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "is-this-a-pigeon", + "name": "Is This A Pigeon", + "aliases": [], + "tags": ["this", "pigeon"], + "category": "animals", + "filename": "is-this-a-pigeon.jpg", + "width": 1587, + "height": 1425, + "popularity": 30, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "this-is-where-i-d-put-my-trophy-if-i-had-one", + "name": "This Is Where I'd Put My Trophy If I Had One", + "aliases": [], + "tags": ["this", "where", "put", "trophy", "had", "one"], + "category": "reaction", + "filename": "this-is-where-i-d-put-my-trophy-if-i-had-one.jpg", + "width": 300, + "height": 418, + "popularity": 31, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "monkey-puppet", + "name": "Monkey Puppet", + "aliases": [], + "tags": ["monkey", "puppet"], + "category": "animals", + "filename": "monkey-puppet.jpg", + "width": 923, + "height": 768, + "popularity": 32, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "0-days-without-lenny-simpsons", + "name": "0 days without (Lenny, Simpsons)", + "aliases": [], + "tags": ["days", "without", "lenny", "simpsons"], + "category": "reaction", + "filename": "0-days-without-lenny-simpsons.jpg", + "width": 619, + "height": 403, + "popularity": 33, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "absolute-cinema", + "name": "Absolute Cinema", + "aliases": [], + "tags": ["absolute", "cinema"], + "category": "reaction", + "filename": "absolute-cinema.jpg", + "width": 936, + "height": 725, + "popularity": 34, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "mother-ignoring-kid-drowning-in-a-pool", + "name": "Mother Ignoring Kid Drowning In A Pool", + "aliases": [], + "tags": ["mother", "ignoring", "kid", "drowning", "pool"], + "category": "reaction", + "filename": "mother-ignoring-kid-drowning-in-a-pool.jpg", + "width": 782, + "height": 1032, + "popularity": 35, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "marked-safe-from", + "name": "Marked Safe From", + "aliases": [], + "tags": ["marked", "safe", "from"], + "category": "reaction", + "filename": "marked-safe-from.jpg", + "width": 618, + "height": 499, + "popularity": 36, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "bernie-sanders-once-again-asking", + "name": "Bernie Sanders Once Again Asking", + "aliases": [], + "tags": ["bernie", "sanders", "once", "again", "asking"], + "category": "opinion", + "filename": "bernie-sanders-once-again-asking.jpg", + "width": 926, + "height": 688, + "popularity": 37, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "you-guys-are-getting-paid", + "name": "You Guys are Getting Paid", + "aliases": [], + "tags": ["you", "guys", "are", "getting", "paid"], + "category": "opinion", + "filename": "you-guys-are-getting-paid.jpg", + "width": 520, + "height": 358, + "popularity": 38, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "i-bet-he-s-thinking-about-other-women", + "name": "I Bet He's Thinking About Other Women", + "aliases": [], + "tags": ["bet", "hes", "thinking", "about", "other", "women"], + "category": "reaction", + "filename": "i-bet-he-s-thinking-about-other-women.jpg", + "width": 1654, + "height": 930, + "popularity": 39, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "clown-applying-makeup", + "name": "Clown Applying Makeup", + "aliases": [], + "tags": ["clown", "applying", "makeup"], + "category": "comparison", + "filename": "clown-applying-makeup.jpg", + "width": 750, + "height": 798, + "popularity": 40, + "textBoxes": [ + { + "id": "panel-1", + "x": 52, + "y": 0, + "width": 46, + "height": 25, + "defaultText": "Step 1" + }, + { + "id": "panel-2", + "x": 52, + "y": 25, + "width": 46, + "height": 25, + "defaultText": "Step 2" + }, + { + "id": "panel-3", + "x": 52, + "y": 50, + "width": 46, + "height": 25, + "defaultText": "Step 3" + }, + { + "id": "panel-4", + "x": 52, + "y": 75, + "width": 46, + "height": 25, + "defaultText": "Full clown" + } + ] + }, + { + "id": "oprah-you-get-a", + "name": "Oprah You Get A", + "aliases": [], + "tags": ["oprah", "you", "get"], + "category": "reaction", + "filename": "oprah-you-get-a.jpg", + "width": 620, + "height": 465, + "popularity": 41, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "megamind-peeking", + "name": "Megamind peeking", + "aliases": [], + "tags": ["megamind", "peeking"], + "category": "opinion", + "filename": "megamind-peeking.jpg", + "width": 540, + "height": 540, + "popularity": 42, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "squidward-window", + "name": "Squidward window", + "aliases": [], + "tags": ["squidward", "window"], + "category": "reaction", + "filename": "squidward-window.jpg", + "width": 598, + "height": 420, + "popularity": 43, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "hide-the-pain-harold", + "name": "Hide the Pain Harold", + "aliases": [], + "tags": ["hide", "the", "pain", "harold"], + "category": "reaction", + "filename": "hide-the-pain-harold.jpg", + "width": 480, + "height": 601, + "popularity": 44, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "evil-kermit", + "name": "Evil Kermit", + "aliases": [], + "tags": ["evil", "kermit"], + "category": "animals", + "filename": "evil-kermit.jpg", + "width": 700, + "height": 325, + "popularity": 45, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "laughing-leo", + "name": "Laughing Leo", + "aliases": [], + "tags": ["laughing", "leo"], + "category": "reaction", + "filename": "laughing-leo.jpg", + "width": 470, + "height": 470, + "popularity": 46, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "boardroom-meeting-suggestion", + "name": "Boardroom Meeting Suggestion", + "aliases": [], + "tags": ["boardroom", "meeting", "suggestion"], + "category": "opinion", + "filename": "boardroom-meeting-suggestion.jpg", + "width": 500, + "height": 649, + "popularity": 47, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "pawn-stars-best-i-can-do", + "name": "Pawn Stars Best I Can Do", + "aliases": [], + "tags": ["pawn", "stars", "best", "can"], + "category": "reaction", + "filename": "pawn-stars-best-i-can-do.jpg", + "width": 624, + "height": 352, + "popularity": 48, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "roll-safe-think-about-it", + "name": "Roll Safe Think About It", + "aliases": [], + "tags": ["roll", "safe", "think", "about"], + "category": "reaction", + "filename": "roll-safe-think-about-it.jpg", + "width": 702, + "height": 395, + "popularity": 49, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "spider-man-triple", + "name": "Spider Man Triple", + "aliases": [], + "tags": ["spider", "man", "triple"], + "category": "reaction", + "filename": "spider-man-triple.jpg", + "width": 600, + "height": 551, + "popularity": 50, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "soldier-protecting-sleeping-child", + "name": "Soldier protecting sleeping child", + "aliases": [], + "tags": ["soldier", "protecting", "sleeping", "child"], + "category": "reaction", + "filename": "soldier-protecting-sleeping-child.jpg", + "width": 540, + "height": 440, + "popularity": 51, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "blank-nut-button", + "name": "Blank Nut Button", + "aliases": [], + "tags": ["blank", "nut", "button"], + "category": "reaction", + "filename": "blank-nut-button.jpg", + "width": 600, + "height": 446, + "popularity": 52, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "bell-curve", + "name": "Bell Curve", + "aliases": [], + "tags": ["bell", "curve"], + "category": "comparison", + "filename": "bell-curve.jpg", + "width": 675, + "height": 499, + "popularity": 53, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "futurama-fry", + "name": "Futurama Fry", + "aliases": [], + "tags": ["futurama", "fry"], + "category": "classic", + "filename": "futurama-fry.jpg", + "width": 552, + "height": 414, + "popularity": 54, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "panik-kalm-panik", + "name": "Panik Kalm Panik", + "aliases": [], + "tags": ["panik", "kalm"], + "category": "reaction", + "filename": "panik-kalm-panik.jpg", + "width": 640, + "height": 881, + "popularity": 55, + "textBoxes": [ + { + "id": "panik-1", + "x": 0, + "y": 0, + "width": 50, + "height": 33, + "defaultText": "Scary thing" + }, + { + "id": "kalm", + "x": 0, + "y": 33, + "width": 50, + "height": 33, + "defaultText": "Resolution" + }, + { + "id": "panik-2", + "x": 0, + "y": 66, + "width": 50, + "height": 33, + "defaultText": "Even scarier" + } + ] + }, + { + "id": "surprised-pikachu", + "name": "Surprised Pikachu", + "aliases": [], + "tags": ["surprised", "pikachu"], + "category": "reaction", + "filename": "surprised-pikachu.jpg", + "width": 1893, + "height": 1893, + "popularity": 56, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "sleeping-shaq", + "name": "Sleeping Shaq", + "aliases": [], + "tags": ["sleeping", "shaq"], + "category": "reaction", + "filename": "sleeping-shaq.jpg", + "width": 640, + "height": 631, + "popularity": 57, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "leonardo-dicaprio-cheers", + "name": "Leonardo Dicaprio Cheers", + "aliases": [], + "tags": ["leonardo", "dicaprio", "cheers"], + "category": "reaction", + "filename": "leonardo-dicaprio-cheers.jpg", + "width": 600, + "height": 400, + "popularity": 58, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "they-don-t-know", + "name": "They don't know", + "aliases": [], + "tags": ["they", "dont", "know"], + "category": "reaction", + "filename": "they-don-t-know.jpg", + "width": 671, + "height": 673, + "popularity": 59, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "flex-tape", + "name": "Flex Tape", + "aliases": [], + "tags": ["flex", "tape"], + "category": "classic", + "filename": "flex-tape.jpg", + "width": 510, + "height": 572, + "popularity": 60, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "where-monkey", + "name": "where monkey", + "aliases": [], + "tags": ["where", "monkey"], + "category": "animals", + "filename": "where-monkey.jpg", + "width": 1113, + "height": 629, + "popularity": 61, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "imagination-spongebob", + "name": "Imagination Spongebob", + "aliases": [], + "tags": ["imagination", "spongebob"], + "category": "animals", + "filename": "imagination-spongebob.jpg", + "width": 500, + "height": 366, + "popularity": 62, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "two-guys-on-a-bus", + "name": "Two guys on a bus", + "aliases": [], + "tags": ["two", "guys", "bus"], + "category": "reaction", + "filename": "two-guys-on-a-bus.jpg", + "width": 762, + "height": 675, + "popularity": 63, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "george-bush-9-11", + "name": "George Bush 9/11", + "aliases": [], + "tags": ["george", "bush", "911"], + "category": "reaction", + "filename": "george-bush-9-11.jpg", + "width": 300, + "height": 180, + "popularity": 64, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "inhaling-seagull", + "name": "Inhaling Seagull", + "aliases": [], + "tags": ["inhaling", "seagull"], + "category": "reaction", + "filename": "inhaling-seagull.jpg", + "width": 1269, + "height": 2825, + "popularity": 65, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "friendship-ended", + "name": "Friendship ended", + "aliases": [], + "tags": ["friendship", "ended"], + "category": "reaction", + "filename": "friendship-ended.jpg", + "width": 800, + "height": 600, + "popularity": 66, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "third-world-skeptical-kid", + "name": "Third World Skeptical Kid", + "aliases": [], + "tags": ["third", "world", "skeptical", "kid"], + "category": "reaction", + "filename": "third-world-skeptical-kid.jpg", + "width": 426, + "height": 426, + "popularity": 67, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "aj-styles-undertaker", + "name": "AJ Styles & Undertaker", + "aliases": [], + "tags": ["styles", "undertaker"], + "category": "reaction", + "filename": "aj-styles-undertaker.jpg", + "width": 933, + "height": 525, + "popularity": 68, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "types-of-headaches-meme", + "name": "Types of Headaches meme", + "aliases": [], + "tags": ["types", "headaches", "meme"], + "category": "reaction", + "filename": "types-of-headaches-meme.jpg", + "width": 483, + "height": 497, + "popularity": 69, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "no-yes", + "name": "No - Yes", + "aliases": [], + "tags": ["yes"], + "category": "reaction", + "filename": "no-yes.jpg", + "width": 429, + "height": 343, + "popularity": 70, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "grandma-finds-the-internet", + "name": "Grandma Finds The Internet", + "aliases": [], + "tags": ["grandma", "finds", "the", "internet"], + "category": "reaction", + "filename": "grandma-finds-the-internet.jpg", + "width": 640, + "height": 480, + "popularity": 71, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "a-train-hitting-a-school-bus", + "name": "A train hitting a school bus", + "aliases": [], + "tags": ["train", "hitting", "school", "bus"], + "category": "reaction", + "filename": "a-train-hitting-a-school-bus.jpg", + "width": 920, + "height": 1086, + "popularity": 72, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "grant-gustin-over-grave", + "name": "Grant Gustin over grave", + "aliases": [], + "tags": ["grant", "gustin", "over", "grave"], + "category": "reaction", + "filename": "grant-gustin-over-grave.jpg", + "width": 500, + "height": 475, + "popularity": 73, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "whisper-and-goosebumps", + "name": "Whisper and Goosebumps", + "aliases": [], + "tags": ["whisper", "and", "goosebumps"], + "category": "reaction", + "filename": "whisper-and-goosebumps.jpg", + "width": 600, + "height": 600, + "popularity": 74, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "three-headed-dragon", + "name": "Three-headed Dragon", + "aliases": [], + "tags": ["threeheaded", "dragon"], + "category": "reaction", + "filename": "three-headed-dragon.jpg", + "width": 680, + "height": 544, + "popularity": 75, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "anime-girl-hiding-from-terminator", + "name": "Anime Girl Hiding from Terminator", + "aliases": [], + "tags": ["anime", "girl", "hiding", "from", "terminator"], + "category": "reaction", + "filename": "anime-girl-hiding-from-terminator.jpg", + "width": 581, + "height": 633, + "popularity": 76, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "two-paths", + "name": "Two Paths", + "aliases": [], + "tags": ["two", "paths"], + "category": "reaction", + "filename": "two-paths.jpg", + "width": 416, + "height": 416, + "popularity": 77, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "the-rock-driving", + "name": "The Rock Driving", + "aliases": [], + "tags": ["the", "rock", "driving"], + "category": "reaction", + "filename": "the-rock-driving.jpg", + "width": 568, + "height": 700, + "popularity": 78, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "all-my-homies-hate", + "name": "All My Homies Hate", + "aliases": [], + "tags": ["all", "homies", "hate"], + "category": "reaction", + "filename": "all-my-homies-hate.jpg", + "width": 680, + "height": 615, + "popularity": 79, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "trump-bill-signing", + "name": "Trump Bill Signing", + "aliases": [], + "tags": ["trump", "bill", "signing"], + "category": "reaction", + "filename": "trump-bill-signing.jpg", + "width": 1866, + "height": 1529, + "popularity": 80, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "spiderman-pointing-at-spiderman", + "name": "spiderman pointing at spiderman", + "aliases": [], + "tags": ["spiderman", "pointing"], + "category": "reaction", + "filename": "spiderman-pointing-at-spiderman.jpg", + "width": 800, + "height": 450, + "popularity": 81, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "star-wars-yoda", + "name": "Star Wars Yoda", + "aliases": [], + "tags": ["star", "wars", "yoda"], + "category": "reaction", + "filename": "star-wars-yoda.jpg", + "width": 620, + "height": 714, + "popularity": 82, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "look-at-me", + "name": "Look At Me", + "aliases": [], + "tags": ["look"], + "category": "reaction", + "filename": "look-at-me.jpg", + "width": 300, + "height": 300, + "popularity": 83, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "the-scroll-of-truth", + "name": "The Scroll Of Truth", + "aliases": [], + "tags": ["the", "scroll", "truth"], + "category": "comparison", + "filename": "the-scroll-of-truth.jpg", + "width": 1280, + "height": 1236, + "popularity": 84, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "who-killed-hannibal", + "name": "Who Killed Hannibal", + "aliases": [], + "tags": ["who", "killed", "hannibal"], + "category": "opinion", + "filename": "who-killed-hannibal.jpg", + "width": 1280, + "height": 1440, + "popularity": 85, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "megamind-no-bitches", + "name": "Megamind no bitches", + "aliases": [], + "tags": ["megamind", "bitches"], + "category": "opinion", + "filename": "megamind-no-bitches.jpg", + "width": 674, + "height": 734, + "popularity": 86, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "success-kid", + "name": "Success Kid", + "aliases": [], + "tags": ["success", "kid"], + "category": "classic", + "filename": "success-kid.jpg", + "width": 500, + "height": 500, + "popularity": 87, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "yo-dawg-heard-you", + "name": "Yo Dawg Heard You", + "aliases": [], + "tags": ["dawg", "heard", "you"], + "category": "reaction", + "filename": "yo-dawg-heard-you.jpg", + "width": 500, + "height": 323, + "popularity": 88, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "is-this-butterfly", + "name": "is this butterfly", + "aliases": [], + "tags": ["this", "butterfly"], + "category": "reaction", + "filename": "is-this-butterfly.jpg", + "width": 1587, + "height": 1425, + "popularity": 89, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "american-chopper-argument", + "name": "American Chopper Argument", + "aliases": [], + "tags": ["american", "chopper", "argument"], + "category": "reaction", + "filename": "american-chopper-argument.jpg", + "width": 640, + "height": 1800, + "popularity": 90, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "scooby-doo-mask-reveal", + "name": "Scooby doo mask reveal", + "aliases": [], + "tags": ["scooby", "doo", "mask", "reveal"], + "category": "reaction", + "filename": "scooby-doo-mask-reveal.jpg", + "width": 720, + "height": 960, + "popularity": 91, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "whe-i-m-in-a-competition-and-my-opponent-is", + "name": "whe i'm in a competition and my opponent is", + "aliases": [], + "tags": ["whe", "competition", "and", "opponent"], + "category": "reaction", + "filename": "whe-i-m-in-a-competition-and-my-opponent-is.jpg", + "width": 916, + "height": 900, + "popularity": 92, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "disappointed-black-guy", + "name": "Disappointed Black Guy", + "aliases": [], + "tags": ["disappointed", "black", "guy"], + "category": "reaction", + "filename": "disappointed-black-guy.jpg", + "width": 1172, + "height": 756, + "popularity": 93, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "patrick-to-do-list-actually-blank", + "name": "patrick to do list actually blank", + "aliases": [], + "tags": ["patrick", "list", "actually", "blank"], + "category": "reaction", + "filename": "patrick-to-do-list-actually-blank.jpg", + "width": 1000, + "height": 625, + "popularity": 94, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "say-the-line-bart-simpsons", + "name": "say the line bart! simpsons", + "aliases": [], + "tags": ["say", "the", "line", "bart", "simpsons"], + "category": "reaction", + "filename": "say-the-line-bart-simpsons.jpg", + "width": 395, + "height": 650, + "popularity": 95, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "domino-effect", + "name": "Domino Effect", + "aliases": [], + "tags": ["domino", "effect"], + "category": "reaction", + "filename": "domino-effect.jpg", + "width": 820, + "height": 565, + "popularity": 96, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "charlie-conspiracy-always-sunny-in-philidelphia", + "name": "Charlie Conspiracy (Always Sunny in Philidelphia)", + "aliases": [], + "tags": ["charlie", "conspiracy", "always", "sunny", "philidelphia"], + "category": "reaction", + "filename": "charlie-conspiracy-always-sunny-in-philidelphia.jpg", + "width": 1024, + "height": 768, + "popularity": 97, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "finding-neverland", + "name": "Finding Neverland", + "aliases": [], + "tags": ["finding", "neverland"], + "category": "reaction", + "filename": "finding-neverland.jpg", + "width": 423, + "height": 600, + "popularity": 98, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "i-m-the-captain-now", + "name": "I'm The Captain Now", + "aliases": [], + "tags": ["the", "captain", "now"], + "category": "reaction", + "filename": "i-m-the-captain-now.jpg", + "width": 478, + "height": 350, + "popularity": 99, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "grim-reaper-knocking-door", + "name": "Grim Reaper Knocking Door", + "aliases": [], + "tags": ["grim", "reaper", "knocking", "door"], + "category": "reaction", + "filename": "grim-reaper-knocking-door.jpg", + "width": 500, + "height": 312, + "popularity": 100, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + }, + { + "id": "grumpy-cat", + "name": "Grumpy Cat", + "aliases": [], + "tags": ["grumpy", "cat"], + "category": "animals", + "filename": "grumpy-cat.jpg", + "width": 800, + "height": 600, + "popularity": 101, + "textBoxes": [ + { + "id": "top", + "x": 5, + "y": 2, + "width": 90, + "height": 20, + "defaultText": "Top text" + }, + { + "id": "bottom", + "x": 5, + "y": 78, + "width": 90, + "height": 20, + "defaultText": "Bottom text" + } + ] + } + ] +} diff --git a/apps/api/static/meme-templates/thumbs/0-days-without-lenny-simpsons.webp b/apps/api/static/meme-templates/thumbs/0-days-without-lenny-simpsons.webp new file mode 100644 index 00000000..564a88b6 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/0-days-without-lenny-simpsons.webp differ diff --git a/apps/api/static/meme-templates/thumbs/a-train-hitting-a-school-bus.webp b/apps/api/static/meme-templates/thumbs/a-train-hitting-a-school-bus.webp new file mode 100644 index 00000000..d6d3e16a Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/a-train-hitting-a-school-bus.webp differ diff --git a/apps/api/static/meme-templates/thumbs/absolute-cinema.webp b/apps/api/static/meme-templates/thumbs/absolute-cinema.webp new file mode 100644 index 00000000..f30e03f3 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/absolute-cinema.webp differ diff --git a/apps/api/static/meme-templates/thumbs/aj-styles-undertaker.webp b/apps/api/static/meme-templates/thumbs/aj-styles-undertaker.webp new file mode 100644 index 00000000..e00ee8f9 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/aj-styles-undertaker.webp differ diff --git a/apps/api/static/meme-templates/thumbs/all-my-homies-hate.webp b/apps/api/static/meme-templates/thumbs/all-my-homies-hate.webp new file mode 100644 index 00000000..1473717c Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/all-my-homies-hate.webp differ diff --git a/apps/api/static/meme-templates/thumbs/always-has-been.webp b/apps/api/static/meme-templates/thumbs/always-has-been.webp new file mode 100644 index 00000000..f6c49cc2 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/always-has-been.webp differ diff --git a/apps/api/static/meme-templates/thumbs/american-chopper-argument.webp b/apps/api/static/meme-templates/thumbs/american-chopper-argument.webp new file mode 100644 index 00000000..0e40c82a Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/american-chopper-argument.webp differ diff --git a/apps/api/static/meme-templates/thumbs/anakin-padme-4-panel.webp b/apps/api/static/meme-templates/thumbs/anakin-padme-4-panel.webp new file mode 100644 index 00000000..e5805986 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/anakin-padme-4-panel.webp differ diff --git a/apps/api/static/meme-templates/thumbs/ancient-aliens.webp b/apps/api/static/meme-templates/thumbs/ancient-aliens.webp new file mode 100644 index 00000000..9136b8c8 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/ancient-aliens.webp differ diff --git a/apps/api/static/meme-templates/thumbs/anime-girl-hiding-from-terminator.webp b/apps/api/static/meme-templates/thumbs/anime-girl-hiding-from-terminator.webp new file mode 100644 index 00000000..a7a8d36b Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/anime-girl-hiding-from-terminator.webp differ diff --git a/apps/api/static/meme-templates/thumbs/batman-slapping-robin.webp b/apps/api/static/meme-templates/thumbs/batman-slapping-robin.webp new file mode 100644 index 00000000..ecb74b73 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/batman-slapping-robin.webp differ diff --git a/apps/api/static/meme-templates/thumbs/bell-curve.webp b/apps/api/static/meme-templates/thumbs/bell-curve.webp new file mode 100644 index 00000000..11f0ded1 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/bell-curve.webp differ diff --git a/apps/api/static/meme-templates/thumbs/bernie-i-am-once-again-asking-for-your-support.webp b/apps/api/static/meme-templates/thumbs/bernie-i-am-once-again-asking-for-your-support.webp new file mode 100644 index 00000000..1d4754c3 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/bernie-i-am-once-again-asking-for-your-support.webp differ diff --git a/apps/api/static/meme-templates/thumbs/bernie-sanders-once-again-asking.webp b/apps/api/static/meme-templates/thumbs/bernie-sanders-once-again-asking.webp new file mode 100644 index 00000000..4e539f9b Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/bernie-sanders-once-again-asking.webp differ diff --git a/apps/api/static/meme-templates/thumbs/bike-fall.webp b/apps/api/static/meme-templates/thumbs/bike-fall.webp new file mode 100644 index 00000000..57270c58 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/bike-fall.webp differ diff --git a/apps/api/static/meme-templates/thumbs/blank-nut-button.webp b/apps/api/static/meme-templates/thumbs/blank-nut-button.webp new file mode 100644 index 00000000..e1cffb1b Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/blank-nut-button.webp differ diff --git a/apps/api/static/meme-templates/thumbs/boardroom-meeting-suggestion.webp b/apps/api/static/meme-templates/thumbs/boardroom-meeting-suggestion.webp new file mode 100644 index 00000000..7a929346 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/boardroom-meeting-suggestion.webp differ diff --git a/apps/api/static/meme-templates/thumbs/buff-doge-vs-cheems.webp b/apps/api/static/meme-templates/thumbs/buff-doge-vs-cheems.webp new file mode 100644 index 00000000..098e4921 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/buff-doge-vs-cheems.webp differ diff --git a/apps/api/static/meme-templates/thumbs/change-my-mind.webp b/apps/api/static/meme-templates/thumbs/change-my-mind.webp new file mode 100644 index 00000000..ae29d264 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/change-my-mind.webp differ diff --git a/apps/api/static/meme-templates/thumbs/charlie-conspiracy-always-sunny-in-philidelphia.webp b/apps/api/static/meme-templates/thumbs/charlie-conspiracy-always-sunny-in-philidelphia.webp new file mode 100644 index 00000000..5df4557e Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/charlie-conspiracy-always-sunny-in-philidelphia.webp differ diff --git a/apps/api/static/meme-templates/thumbs/clown-applying-makeup.webp b/apps/api/static/meme-templates/thumbs/clown-applying-makeup.webp new file mode 100644 index 00000000..659290ea Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/clown-applying-makeup.webp differ diff --git a/apps/api/static/meme-templates/thumbs/disappointed-black-guy.webp b/apps/api/static/meme-templates/thumbs/disappointed-black-guy.webp new file mode 100644 index 00000000..46383464 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/disappointed-black-guy.webp differ diff --git a/apps/api/static/meme-templates/thumbs/disaster-girl.webp b/apps/api/static/meme-templates/thumbs/disaster-girl.webp new file mode 100644 index 00000000..fdcdff77 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/disaster-girl.webp differ diff --git a/apps/api/static/meme-templates/thumbs/distracted-boyfriend.webp b/apps/api/static/meme-templates/thumbs/distracted-boyfriend.webp new file mode 100644 index 00000000..4f76dd8e Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/distracted-boyfriend.webp differ diff --git a/apps/api/static/meme-templates/thumbs/domino-effect.webp b/apps/api/static/meme-templates/thumbs/domino-effect.webp new file mode 100644 index 00000000..a9b7fc1f Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/domino-effect.webp differ diff --git a/apps/api/static/meme-templates/thumbs/drake-hotline-bling.webp b/apps/api/static/meme-templates/thumbs/drake-hotline-bling.webp new file mode 100644 index 00000000..49026033 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/drake-hotline-bling.webp differ diff --git a/apps/api/static/meme-templates/thumbs/epic-handshake.webp b/apps/api/static/meme-templates/thumbs/epic-handshake.webp new file mode 100644 index 00000000..aa3fa929 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/epic-handshake.webp differ diff --git a/apps/api/static/meme-templates/thumbs/evil-kermit.webp b/apps/api/static/meme-templates/thumbs/evil-kermit.webp new file mode 100644 index 00000000..c69b791c Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/evil-kermit.webp differ diff --git a/apps/api/static/meme-templates/thumbs/expanding-brain.webp b/apps/api/static/meme-templates/thumbs/expanding-brain.webp new file mode 100644 index 00000000..7614c14c Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/expanding-brain.webp differ diff --git a/apps/api/static/meme-templates/thumbs/finding-neverland.webp b/apps/api/static/meme-templates/thumbs/finding-neverland.webp new file mode 100644 index 00000000..875f6ee8 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/finding-neverland.webp differ diff --git a/apps/api/static/meme-templates/thumbs/flex-tape.webp b/apps/api/static/meme-templates/thumbs/flex-tape.webp new file mode 100644 index 00000000..ece33dda Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/flex-tape.webp differ diff --git a/apps/api/static/meme-templates/thumbs/friendship-ended.webp b/apps/api/static/meme-templates/thumbs/friendship-ended.webp new file mode 100644 index 00000000..590039f7 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/friendship-ended.webp differ diff --git a/apps/api/static/meme-templates/thumbs/futurama-fry.webp b/apps/api/static/meme-templates/thumbs/futurama-fry.webp new file mode 100644 index 00000000..997524ba Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/futurama-fry.webp differ diff --git a/apps/api/static/meme-templates/thumbs/george-bush-9-11.webp b/apps/api/static/meme-templates/thumbs/george-bush-9-11.webp new file mode 100644 index 00000000..a0566ebb Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/george-bush-9-11.webp differ diff --git a/apps/api/static/meme-templates/thumbs/grandma-finds-the-internet.webp b/apps/api/static/meme-templates/thumbs/grandma-finds-the-internet.webp new file mode 100644 index 00000000..4751bf9d Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/grandma-finds-the-internet.webp differ diff --git a/apps/api/static/meme-templates/thumbs/grant-gustin-over-grave.webp b/apps/api/static/meme-templates/thumbs/grant-gustin-over-grave.webp new file mode 100644 index 00000000..e5553efd Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/grant-gustin-over-grave.webp differ diff --git a/apps/api/static/meme-templates/thumbs/grim-reaper-knocking-door.webp b/apps/api/static/meme-templates/thumbs/grim-reaper-knocking-door.webp new file mode 100644 index 00000000..e7506441 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/grim-reaper-knocking-door.webp differ diff --git a/apps/api/static/meme-templates/thumbs/gru-s-plan.webp b/apps/api/static/meme-templates/thumbs/gru-s-plan.webp new file mode 100644 index 00000000..6e1e3843 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/gru-s-plan.webp differ diff --git a/apps/api/static/meme-templates/thumbs/grumpy-cat.webp b/apps/api/static/meme-templates/thumbs/grumpy-cat.webp new file mode 100644 index 00000000..24af6b62 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/grumpy-cat.webp differ diff --git a/apps/api/static/meme-templates/thumbs/hide-the-pain-harold.webp b/apps/api/static/meme-templates/thumbs/hide-the-pain-harold.webp new file mode 100644 index 00000000..239a5a22 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/hide-the-pain-harold.webp differ diff --git a/apps/api/static/meme-templates/thumbs/i-bet-he-s-thinking-about-other-women.webp b/apps/api/static/meme-templates/thumbs/i-bet-he-s-thinking-about-other-women.webp new file mode 100644 index 00000000..94979afd Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/i-bet-he-s-thinking-about-other-women.webp differ diff --git a/apps/api/static/meme-templates/thumbs/i-m-the-captain-now.webp b/apps/api/static/meme-templates/thumbs/i-m-the-captain-now.webp new file mode 100644 index 00000000..41996193 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/i-m-the-captain-now.webp differ diff --git a/apps/api/static/meme-templates/thumbs/imagination-spongebob.webp b/apps/api/static/meme-templates/thumbs/imagination-spongebob.webp new file mode 100644 index 00000000..a5dcce13 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/imagination-spongebob.webp differ diff --git a/apps/api/static/meme-templates/thumbs/inhaling-seagull.webp b/apps/api/static/meme-templates/thumbs/inhaling-seagull.webp new file mode 100644 index 00000000..18f01237 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/inhaling-seagull.webp differ diff --git a/apps/api/static/meme-templates/thumbs/is-this-a-pigeon.webp b/apps/api/static/meme-templates/thumbs/is-this-a-pigeon.webp new file mode 100644 index 00000000..dd02b873 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/is-this-a-pigeon.webp differ diff --git a/apps/api/static/meme-templates/thumbs/is-this-butterfly.webp b/apps/api/static/meme-templates/thumbs/is-this-butterfly.webp new file mode 100644 index 00000000..b9d1db1b Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/is-this-butterfly.webp differ diff --git a/apps/api/static/meme-templates/thumbs/laughing-leo.webp b/apps/api/static/meme-templates/thumbs/laughing-leo.webp new file mode 100644 index 00000000..66e7e227 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/laughing-leo.webp differ diff --git a/apps/api/static/meme-templates/thumbs/left-exit-12-off-ramp.webp b/apps/api/static/meme-templates/thumbs/left-exit-12-off-ramp.webp new file mode 100644 index 00000000..91cfeca1 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/left-exit-12-off-ramp.webp differ diff --git a/apps/api/static/meme-templates/thumbs/leonardo-dicaprio-cheers.webp b/apps/api/static/meme-templates/thumbs/leonardo-dicaprio-cheers.webp new file mode 100644 index 00000000..fb457793 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/leonardo-dicaprio-cheers.webp differ diff --git a/apps/api/static/meme-templates/thumbs/look-at-me.webp b/apps/api/static/meme-templates/thumbs/look-at-me.webp new file mode 100644 index 00000000..93dada21 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/look-at-me.webp differ diff --git a/apps/api/static/meme-templates/thumbs/marked-safe-from.webp b/apps/api/static/meme-templates/thumbs/marked-safe-from.webp new file mode 100644 index 00000000..799ed1cd Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/marked-safe-from.webp differ diff --git a/apps/api/static/meme-templates/thumbs/megamind-no-bitches.webp b/apps/api/static/meme-templates/thumbs/megamind-no-bitches.webp new file mode 100644 index 00000000..df139117 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/megamind-no-bitches.webp differ diff --git a/apps/api/static/meme-templates/thumbs/megamind-peeking.webp b/apps/api/static/meme-templates/thumbs/megamind-peeking.webp new file mode 100644 index 00000000..c57b4a39 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/megamind-peeking.webp differ diff --git a/apps/api/static/meme-templates/thumbs/mocking-spongebob.webp b/apps/api/static/meme-templates/thumbs/mocking-spongebob.webp new file mode 100644 index 00000000..3e6896a5 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/mocking-spongebob.webp differ diff --git a/apps/api/static/meme-templates/thumbs/monkey-puppet.webp b/apps/api/static/meme-templates/thumbs/monkey-puppet.webp new file mode 100644 index 00000000..5356d935 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/monkey-puppet.webp differ diff --git a/apps/api/static/meme-templates/thumbs/mother-ignoring-kid-drowning-in-a-pool.webp b/apps/api/static/meme-templates/thumbs/mother-ignoring-kid-drowning-in-a-pool.webp new file mode 100644 index 00000000..683be433 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/mother-ignoring-kid-drowning-in-a-pool.webp differ diff --git a/apps/api/static/meme-templates/thumbs/no-yes.webp b/apps/api/static/meme-templates/thumbs/no-yes.webp new file mode 100644 index 00000000..f23abee7 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/no-yes.webp differ diff --git a/apps/api/static/meme-templates/thumbs/one-does-not-simply.webp b/apps/api/static/meme-templates/thumbs/one-does-not-simply.webp new file mode 100644 index 00000000..327c3ce0 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/one-does-not-simply.webp differ diff --git a/apps/api/static/meme-templates/thumbs/oprah-you-get-a.webp b/apps/api/static/meme-templates/thumbs/oprah-you-get-a.webp new file mode 100644 index 00000000..6fc2c6a6 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/oprah-you-get-a.webp differ diff --git a/apps/api/static/meme-templates/thumbs/panik-kalm-panik.webp b/apps/api/static/meme-templates/thumbs/panik-kalm-panik.webp new file mode 100644 index 00000000..d2e7b82b Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/panik-kalm-panik.webp differ diff --git a/apps/api/static/meme-templates/thumbs/patrick-to-do-list-actually-blank.webp b/apps/api/static/meme-templates/thumbs/patrick-to-do-list-actually-blank.webp new file mode 100644 index 00000000..d2aa8307 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/patrick-to-do-list-actually-blank.webp differ diff --git a/apps/api/static/meme-templates/thumbs/pawn-stars-best-i-can-do.webp b/apps/api/static/meme-templates/thumbs/pawn-stars-best-i-can-do.webp new file mode 100644 index 00000000..89e04131 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/pawn-stars-best-i-can-do.webp differ diff --git a/apps/api/static/meme-templates/thumbs/roll-safe-think-about-it.webp b/apps/api/static/meme-templates/thumbs/roll-safe-think-about-it.webp new file mode 100644 index 00000000..197951d3 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/roll-safe-think-about-it.webp differ diff --git a/apps/api/static/meme-templates/thumbs/running-away-balloon.webp b/apps/api/static/meme-templates/thumbs/running-away-balloon.webp new file mode 100644 index 00000000..d1b0a040 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/running-away-balloon.webp differ diff --git a/apps/api/static/meme-templates/thumbs/sad-pablo-escobar.webp b/apps/api/static/meme-templates/thumbs/sad-pablo-escobar.webp new file mode 100644 index 00000000..847876e3 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/sad-pablo-escobar.webp differ diff --git a/apps/api/static/meme-templates/thumbs/say-the-line-bart-simpsons.webp b/apps/api/static/meme-templates/thumbs/say-the-line-bart-simpsons.webp new file mode 100644 index 00000000..0a337157 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/say-the-line-bart-simpsons.webp differ diff --git a/apps/api/static/meme-templates/thumbs/scooby-doo-mask-reveal.webp b/apps/api/static/meme-templates/thumbs/scooby-doo-mask-reveal.webp new file mode 100644 index 00000000..a0f96301 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/scooby-doo-mask-reveal.webp differ diff --git a/apps/api/static/meme-templates/thumbs/sleeping-shaq.webp b/apps/api/static/meme-templates/thumbs/sleeping-shaq.webp new file mode 100644 index 00000000..93e80804 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/sleeping-shaq.webp differ diff --git a/apps/api/static/meme-templates/thumbs/soldier-protecting-sleeping-child.webp b/apps/api/static/meme-templates/thumbs/soldier-protecting-sleeping-child.webp new file mode 100644 index 00000000..fa818ec7 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/soldier-protecting-sleeping-child.webp differ diff --git a/apps/api/static/meme-templates/thumbs/spider-man-triple.webp b/apps/api/static/meme-templates/thumbs/spider-man-triple.webp new file mode 100644 index 00000000..b6773e40 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/spider-man-triple.webp differ diff --git a/apps/api/static/meme-templates/thumbs/spiderman-pointing-at-spiderman.webp b/apps/api/static/meme-templates/thumbs/spiderman-pointing-at-spiderman.webp new file mode 100644 index 00000000..764474df Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/spiderman-pointing-at-spiderman.webp differ diff --git a/apps/api/static/meme-templates/thumbs/squidward-window.webp b/apps/api/static/meme-templates/thumbs/squidward-window.webp new file mode 100644 index 00000000..ccb23fbb Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/squidward-window.webp differ diff --git a/apps/api/static/meme-templates/thumbs/star-wars-yoda.webp b/apps/api/static/meme-templates/thumbs/star-wars-yoda.webp new file mode 100644 index 00000000..ac7ddfe6 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/star-wars-yoda.webp differ diff --git a/apps/api/static/meme-templates/thumbs/success-kid.webp b/apps/api/static/meme-templates/thumbs/success-kid.webp new file mode 100644 index 00000000..cb3ea53b Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/success-kid.webp differ diff --git a/apps/api/static/meme-templates/thumbs/surprised-pikachu.webp b/apps/api/static/meme-templates/thumbs/surprised-pikachu.webp new file mode 100644 index 00000000..1441a784 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/surprised-pikachu.webp differ diff --git a/apps/api/static/meme-templates/thumbs/the-rock-driving.webp b/apps/api/static/meme-templates/thumbs/the-rock-driving.webp new file mode 100644 index 00000000..c6838b22 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/the-rock-driving.webp differ diff --git a/apps/api/static/meme-templates/thumbs/the-scroll-of-truth.webp b/apps/api/static/meme-templates/thumbs/the-scroll-of-truth.webp new file mode 100644 index 00000000..1042df48 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/the-scroll-of-truth.webp differ diff --git a/apps/api/static/meme-templates/thumbs/they-don-t-know.webp b/apps/api/static/meme-templates/thumbs/they-don-t-know.webp new file mode 100644 index 00000000..acee8632 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/they-don-t-know.webp differ diff --git a/apps/api/static/meme-templates/thumbs/they-re-the-same-picture.webp b/apps/api/static/meme-templates/thumbs/they-re-the-same-picture.webp new file mode 100644 index 00000000..4ff93850 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/they-re-the-same-picture.webp differ diff --git a/apps/api/static/meme-templates/thumbs/third-world-skeptical-kid.webp b/apps/api/static/meme-templates/thumbs/third-world-skeptical-kid.webp new file mode 100644 index 00000000..35f40957 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/third-world-skeptical-kid.webp differ diff --git a/apps/api/static/meme-templates/thumbs/this-is-fine.webp b/apps/api/static/meme-templates/thumbs/this-is-fine.webp new file mode 100644 index 00000000..fdf70b17 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/this-is-fine.webp differ diff --git a/apps/api/static/meme-templates/thumbs/this-is-where-i-d-put-my-trophy-if-i-had-one.webp b/apps/api/static/meme-templates/thumbs/this-is-where-i-d-put-my-trophy-if-i-had-one.webp new file mode 100644 index 00000000..574cbad8 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/this-is-where-i-d-put-my-trophy-if-i-had-one.webp differ diff --git a/apps/api/static/meme-templates/thumbs/three-headed-dragon.webp b/apps/api/static/meme-templates/thumbs/three-headed-dragon.webp new file mode 100644 index 00000000..cf6ef5f0 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/three-headed-dragon.webp differ diff --git a/apps/api/static/meme-templates/thumbs/trade-offer.webp b/apps/api/static/meme-templates/thumbs/trade-offer.webp new file mode 100644 index 00000000..f378416b Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/trade-offer.webp differ diff --git a/apps/api/static/meme-templates/thumbs/trump-bill-signing.webp b/apps/api/static/meme-templates/thumbs/trump-bill-signing.webp new file mode 100644 index 00000000..b29cb8b3 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/trump-bill-signing.webp differ diff --git a/apps/api/static/meme-templates/thumbs/tuxedo-winnie-the-pooh.webp b/apps/api/static/meme-templates/thumbs/tuxedo-winnie-the-pooh.webp new file mode 100644 index 00000000..da87427c Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/tuxedo-winnie-the-pooh.webp differ diff --git a/apps/api/static/meme-templates/thumbs/two-buttons.webp b/apps/api/static/meme-templates/thumbs/two-buttons.webp new file mode 100644 index 00000000..1c82c165 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/two-buttons.webp differ diff --git a/apps/api/static/meme-templates/thumbs/two-guys-on-a-bus.webp b/apps/api/static/meme-templates/thumbs/two-guys-on-a-bus.webp new file mode 100644 index 00000000..9cbf6272 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/two-guys-on-a-bus.webp differ diff --git a/apps/api/static/meme-templates/thumbs/two-paths.webp b/apps/api/static/meme-templates/thumbs/two-paths.webp new file mode 100644 index 00000000..a9867a59 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/two-paths.webp differ diff --git a/apps/api/static/meme-templates/thumbs/types-of-headaches-meme.webp b/apps/api/static/meme-templates/thumbs/types-of-headaches-meme.webp new file mode 100644 index 00000000..7f840d04 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/types-of-headaches-meme.webp differ diff --git a/apps/api/static/meme-templates/thumbs/uno-draw-25-cards.webp b/apps/api/static/meme-templates/thumbs/uno-draw-25-cards.webp new file mode 100644 index 00000000..ee6706f9 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/uno-draw-25-cards.webp differ diff --git a/apps/api/static/meme-templates/thumbs/waiting-skeleton.webp b/apps/api/static/meme-templates/thumbs/waiting-skeleton.webp new file mode 100644 index 00000000..fb30e0f7 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/waiting-skeleton.webp differ diff --git a/apps/api/static/meme-templates/thumbs/whe-i-m-in-a-competition-and-my-opponent-is.webp b/apps/api/static/meme-templates/thumbs/whe-i-m-in-a-competition-and-my-opponent-is.webp new file mode 100644 index 00000000..921ff964 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/whe-i-m-in-a-competition-and-my-opponent-is.webp differ diff --git a/apps/api/static/meme-templates/thumbs/where-monkey.webp b/apps/api/static/meme-templates/thumbs/where-monkey.webp new file mode 100644 index 00000000..bd73e10b Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/where-monkey.webp differ diff --git a/apps/api/static/meme-templates/thumbs/whisper-and-goosebumps.webp b/apps/api/static/meme-templates/thumbs/whisper-and-goosebumps.webp new file mode 100644 index 00000000..8a126c8f Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/whisper-and-goosebumps.webp differ diff --git a/apps/api/static/meme-templates/thumbs/who-killed-hannibal.webp b/apps/api/static/meme-templates/thumbs/who-killed-hannibal.webp new file mode 100644 index 00000000..6b3125cf Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/who-killed-hannibal.webp differ diff --git a/apps/api/static/meme-templates/thumbs/woman-yelling-at-cat.webp b/apps/api/static/meme-templates/thumbs/woman-yelling-at-cat.webp new file mode 100644 index 00000000..f53876c9 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/woman-yelling-at-cat.webp differ diff --git a/apps/api/static/meme-templates/thumbs/x-x-everywhere.webp b/apps/api/static/meme-templates/thumbs/x-x-everywhere.webp new file mode 100644 index 00000000..543cf3a3 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/x-x-everywhere.webp differ diff --git a/apps/api/static/meme-templates/thumbs/y-all-got-any-more-of-that.webp b/apps/api/static/meme-templates/thumbs/y-all-got-any-more-of-that.webp new file mode 100644 index 00000000..6aa48ee5 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/y-all-got-any-more-of-that.webp differ diff --git a/apps/api/static/meme-templates/thumbs/yo-dawg-heard-you.webp b/apps/api/static/meme-templates/thumbs/yo-dawg-heard-you.webp new file mode 100644 index 00000000..10f1ec11 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/yo-dawg-heard-you.webp differ diff --git a/apps/api/static/meme-templates/thumbs/you-guys-are-getting-paid.webp b/apps/api/static/meme-templates/thumbs/you-guys-are-getting-paid.webp new file mode 100644 index 00000000..894b8742 Binary files /dev/null and b/apps/api/static/meme-templates/thumbs/you-guys-are-getting-paid.webp differ diff --git a/apps/web/src/components/tools/meme-generator-preview.tsx b/apps/web/src/components/tools/meme-generator-preview.tsx new file mode 100644 index 00000000..5902e509 --- /dev/null +++ b/apps/web/src/components/tools/meme-generator-preview.tsx @@ -0,0 +1,510 @@ +import { + ArrowLeft, + Download, + ImagePlus, + Laugh, + Loader2, + RotateCcw, + Search, + Sparkles, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { + CATEGORIES, + FONT_FAMILY_MAP, + injectMemeFonts, + PRESET_LAYOUTS, + type TemplateTextBox, + type TextBoxValue, + type TextLayout, + useMemeStore, +} from "@/stores/meme-store"; + +const INPUT_CLASS = + "w-full px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"; + +// ── Text Preview Overlay ──────────────────────────────────────────── + +function TextPreviewOverlay({ + boxes, + textValues, + fontFamily, + fontSize, + textColor, + strokeColor, + textAlign, + allCaps, + containerWidth, +}: { + boxes: TemplateTextBox[]; + textValues: TextBoxValue[]; + fontFamily: string; + fontSize: number; + textColor: string; + strokeColor: string; + textAlign: string; + allCaps: boolean; + containerWidth: number; +}) { + const cssFontFamily = FONT_FAMILY_MAP[fontFamily] ?? FONT_FAMILY_MAP.anton; + + return ( + <> + {boxes.map((box) => { + const value = textValues.find((v) => v.id === box.id); + const text = value?.text || box.defaultText || ""; + const displayText = allCaps ? text.toUpperCase() : text; + const boxPxW = (box.width / 100) * containerWidth; + const boxPxH = (box.height / 100) * containerWidth; + const autoSize = Math.max(10, Math.min(Math.floor(boxPxW / 8), Math.floor(boxPxH / 2), 48)); + const appliedSize = fontSize > 0 ? fontSize : autoSize; + const stroke = Math.max(1, Math.round(appliedSize * 0.04)); + + return ( +
+ + {displayText} + +
+ ); + })} + + ); +} + +// ── Gallery Phase ─────────────────────────────────────────────────── + +function TemplateGallery() { + const templates = useMemeStore((s) => s.templates); + const searchQuery = useMemeStore((s) => s.searchQuery); + const activeCategory = useMemeStore((s) => s.activeCategory); + const selectTemplate = useMemeStore((s) => s.selectTemplate); + const setSearchQuery = useMemeStore((s) => s.setSearchQuery); + const setActiveCategory = useMemeStore((s) => s.setActiveCategory); + const setCustomImage = useMemeStore((s) => s.setCustomImage); + + const fileInputRef = useRef(null); + + const filtered = useMemo(() => { + let result = templates; + + if (activeCategory !== "all") { + result = result.filter((t) => t.category === activeCategory); + } + + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase().trim(); + result = result.filter( + (t) => + t.name.toLowerCase().includes(q) || + t.aliases.some((a) => a.toLowerCase().includes(q)) || + t.tags.some((tag) => tag.toLowerCase().includes(q)), + ); + } + + return result; + }, [templates, searchQuery, activeCategory]); + + const categoryCounts = useMemo(() => { + const counts: Record = { all: templates.length }; + for (const t of templates) { + counts[t.category] = (counts[t.category] ?? 0) + 1; + } + return counts; + }, [templates]); + + const handleUploadCustom = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setCustomImage(file); + e.target.value = ""; + }, + [setCustomImage], + ); + + return ( +
+
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search templates..." + className={cn(INPUT_CLASS, "pl-8")} + /> +
+ + {/* Categories */} +
+ {CATEGORIES.map((cat) => ( + + ))} +
+ + {/* Upload custom */} + + + + {/* Template grid */} +
+ {filtered.map((t) => ( + + ))} +
+ + {filtered.length === 0 && ( +
+ + No templates match your search +
+ )} +
+
+ ); +} + +// ── Layout Picker Phase (custom image) ────────────────────────────── + +function LayoutPicker() { + const customImageUrl = useMemeStore((s) => s.customImageUrl); + const customLayout = useMemeStore((s) => s.customLayout); + const setCustomLayout = useMemeStore((s) => s.setCustomLayout); + const backToGallery = useMemeStore((s) => s.backToGallery); + + const selected = customLayout ?? "top-bottom"; + + if (!customImageUrl) return null; + + return ( +
+
+ + +

Choose a text layout

+ +
+ {( + Object.entries(PRESET_LAYOUTS) as [TextLayout, (typeof PRESET_LAYOUTS)[TextLayout]][] + ).map(([key, layout]) => ( + + ))} +
+
+
+ ); +} + +// ── Editor Phase ──────────────────────────────────────────────────── + +function EditorPreview() { + const selectedTemplate = useMemeStore((s) => s.selectedTemplate); + const customImageUrl = useMemeStore((s) => s.customImageUrl); + const customLayout = useMemeStore((s) => s.customLayout); + const textBoxValues = useMemeStore((s) => s.textBoxValues); + const fontFamily = useMemeStore((s) => s.fontFamily); + const fontSize = useMemeStore((s) => s.fontSize); + const textColor = useMemeStore((s) => s.textColor); + const strokeColor = useMemeStore((s) => s.strokeColor); + const textAlign = useMemeStore((s) => s.textAlign); + const allCaps = useMemeStore((s) => s.allCaps); + + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(600); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const imageSrc = selectedTemplate + ? `/api/v1/meme-templates/full/${selectedTemplate.filename}` + : (customImageUrl ?? ""); + + const textBoxes = selectedTemplate + ? selectedTemplate.textBoxes + : ((customLayout && PRESET_LAYOUTS[customLayout]?.boxes) ?? PRESET_LAYOUTS["top-bottom"].boxes); + + return ( +
+
+
+ Template preview + +
+
+
+ ); +} + +// ── Result Phase ──────────────────────────────────────────────────── + +function ResultView() { + const resultUrl = useMemeStore((s) => s.resultUrl); + const backToEditor = useMemeStore((s) => s.backToEditor); + const backToGallery = useMemeStore((s) => s.backToGallery); + + if (!resultUrl) return null; + + return ( +
+
+ Generated meme +
+
+ + + + Download + + +
+
+ ); +} + +// ── Loading / Error States ────────────────────────────────────────── + +function LoadingView() { + return ( +
+ + Loading templates... +
+ ); +} + +function ErrorView() { + const error = useMemeStore((s) => s.error); + + return ( +
+
+

{error}

+ +
+
+ ); +} + +// ── Main Preview Component (ResultsPanel) ─────────────────────────── + +export function MemeGeneratorPreview() { + const phase = useMemeStore((s) => s.phase); + const loading = useMemeStore((s) => s.loading); + const error = useMemeStore((s) => s.error); + const templates = useMemeStore((s) => s.templates); + const fetchTemplates = useMemeStore((s) => s.fetchTemplates); + + // Inject fonts on mount + useEffect(() => { + injectMemeFonts(); + }, []); + + // Fetch templates on mount + useEffect(() => { + if (templates.length === 0) { + fetchTemplates(); + } + }, [templates.length, fetchTemplates]); + + if (loading && templates.length === 0) { + return ; + } + + if (error && phase === "gallery") { + return ; + } + + if (phase === "gallery") { + return ; + } + + if (phase === "layout-picker") { + return ; + } + + if (phase === "editor") { + return ; + } + + if (phase === "result") { + return ; + } + + return null; +} diff --git a/apps/web/src/components/tools/meme-generator-settings.tsx b/apps/web/src/components/tools/meme-generator-settings.tsx new file mode 100644 index 00000000..bf9c1a57 --- /dev/null +++ b/apps/web/src/components/tools/meme-generator-settings.tsx @@ -0,0 +1,338 @@ +import { + AlignCenter, + AlignLeft, + AlignRight, + ArrowLeft, + Download, + Loader2, + Sparkles, +} from "lucide-react"; +import { useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { + FONT_OPTIONS, + PRESET_LAYOUTS, + type TemplateTextBox, + useMemeStore, +} from "@/stores/meme-store"; + +const INPUT_CLASS = + "w-full px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"; + +// ── Gallery Phase Settings ────────────────────────────────────────── + +function GallerySettings() { + return ( +
+
+

+ Select a template from the gallery or upload your own image to get started. +

+
+
+ ); +} + +// ── Layout Picker Phase Settings ──────────────────────────────────── + +function LayoutPickerSettings() { + return ( +
+
+

Choose a text layout for your custom image.

+
+
+ ); +} + +// ── Editor Phase Settings ─────────────────────────────────────────── + +function EditorSettings() { + const selectedTemplate = useMemeStore((s) => s.selectedTemplate); + const customLayout = useMemeStore((s) => s.customLayout); + const textBoxValues = useMemeStore((s) => s.textBoxValues); + const fontFamily = useMemeStore((s) => s.fontFamily); + const fontSize = useMemeStore((s) => s.fontSize); + const textColor = useMemeStore((s) => s.textColor); + const strokeColor = useMemeStore((s) => s.strokeColor); + const textAlign = useMemeStore((s) => s.textAlign); + const allCaps = useMemeStore((s) => s.allCaps); + const generating = useMemeStore((s) => s.generating); + const error = useMemeStore((s) => s.error); + const updateTextValue = useMemeStore((s) => s.updateTextValue); + const setFontFamily = useMemeStore((s) => s.setFontFamily); + const setFontSize = useMemeStore((s) => s.setFontSize); + const setTextColor = useMemeStore((s) => s.setTextColor); + const setStrokeColor = useMemeStore((s) => s.setStrokeColor); + const setTextAlign = useMemeStore((s) => s.setTextAlign); + const setAllCaps = useMemeStore((s) => s.setAllCaps); + const generateMeme = useMemeStore((s) => s.generateMeme); + const backToGallery = useMemeStore((s) => s.backToGallery); + + const textBoxes: TemplateTextBox[] = selectedTemplate + ? selectedTemplate.textBoxes + : ((customLayout && PRESET_LAYOUTS[customLayout]?.boxes) ?? PRESET_LAYOUTS["top-bottom"].boxes); + + const handleGenerate = useCallback(() => { + generateMeme(); + }, [generateMeme]); + + return ( +
+ {/* Back button */} + + + {/* Template name */} + {selectedTemplate && ( +

{selectedTemplate.name}

+ )} + + {/* Text inputs */} + {textBoxes.map((box) => { + const val = textBoxValues.find((v) => v.id === box.id); + return ( +
+ + updateTextValue(box.id, e.target.value)} + placeholder={box.defaultText || box.id} + className={INPUT_CLASS} + /> +
+ ); + })} + + {/* Font picker */} +
+ + +
+ + {/* Font size */} +
+
+ + + {fontSize === 0 ? "Auto" : `${fontSize}px`} + +
+ setFontSize(Number(e.target.value))} + className="w-full" + /> +
+ + {/* Colors */} +
+
+ +
+ setTextColor(e.target.value)} + className="w-7 h-7 rounded border border-border shrink-0 cursor-pointer" + /> + setTextColor(e.target.value)} + className="flex-1 min-w-0 px-1 py-1 rounded border border-border bg-background text-[11px] text-foreground font-mono" + /> +
+
+
+ +
+ setStrokeColor(e.target.value)} + className="w-7 h-7 rounded border border-border shrink-0 cursor-pointer" + /> + setStrokeColor(e.target.value)} + className="flex-1 min-w-0 px-1 py-1 rounded border border-border bg-background text-[11px] text-foreground font-mono" + /> +
+
+
+ + {/* Alignment */} +
+ Alignment +
+ {(["left", "center", "right"] as const).map((align) => { + const Icon = + align === "left" ? AlignLeft : align === "right" ? AlignRight : AlignCenter; + return ( + + ); + })} +
+
+ + {/* All caps */} + + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Generate */} + +
+ ); +} + +// ── Result Phase Settings ─────────────────────────────────────────── + +function ResultSettings() { + const resultUrl = useMemeStore((s) => s.resultUrl); + const backToEditor = useMemeStore((s) => s.backToEditor); + const backToGallery = useMemeStore((s) => s.backToGallery); + + return ( +
+

Your meme is ready.

+ + {resultUrl && ( + + + Download Meme + + )} + + + + +
+ ); +} + +// ── Main Settings Component ───────────────────────────────────────── + +export function MemeGeneratorSettings() { + const phase = useMemeStore((s) => s.phase); + + if (phase === "gallery") return ; + if (phase === "layout-picker") return ; + if (phase === "editor") return ; + if (phase === "result") return ; + + return null; +} diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index 168c32d8..7ab686ca 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -311,6 +311,16 @@ const TransparencyFixerSettings = lazy(() => default: m.TransparencyFixerSettings, })), ); +const MemeGeneratorSettings = lazy(() => + import("@/components/tools/meme-generator-settings").then((m) => ({ + default: m.MemeGeneratorSettings, + })), +); +const MemeGeneratorPreview = lazy(() => + import("@/components/tools/meme-generator-preview").then((m) => ({ + default: m.MemeGeneratorPreview, + })), +); const ColorBlindnessSettings = lazy(() => import("@/components/tools/color-blindness-settings").then((m) => ({ default: m.ColorBlindnessSettings, @@ -378,6 +388,14 @@ export const toolRegistry = new Map([ ["watermark-image", { displayMode: "before-after", Settings: WatermarkImageSettings }], ["text-overlay", { displayMode: "before-after", Settings: TextOverlaySettings }], ["compose", { displayMode: "before-after", Settings: ComposeSettings }], + [ + "meme-generator", + { + displayMode: "no-dropzone", + Settings: MemeGeneratorSettings, + ResultsPanel: MemeGeneratorPreview, + }, + ], // Utilities ["info", { displayMode: "before-after", Settings: InfoSettings }], diff --git a/apps/web/src/stores/meme-store.ts b/apps/web/src/stores/meme-store.ts new file mode 100644 index 00000000..2ed24e1d --- /dev/null +++ b/apps/web/src/stores/meme-store.ts @@ -0,0 +1,401 @@ +import { create } from "zustand"; +import { formatHeaders } from "@/lib/api"; + +// ── Types ──────────────────────────────────────────────────────────── + +export interface TemplateTextBox { + id: string; + x: number; + y: number; + width: number; + height: number; + defaultText?: string; +} + +export interface MemeTemplate { + id: string; + name: string; + aliases: string[]; + tags: string[]; + category: string; + filename: string; + width: number; + height: number; + popularity: number; + textBoxes: TemplateTextBox[]; +} + +interface TemplateManifest { + version: number; + categories: string[]; + templates: MemeTemplate[]; +} + +export type Phase = "gallery" | "layout-picker" | "editor" | "result"; +export type TextLayout = "top-bottom" | "top-only" | "bottom-only" | "center" | "side-by-side"; + +export interface TextBoxValue { + id: string; + text: string; +} + +// ── Constants ──────────────────────────────────────────────────────── + +export const FONT_OPTIONS = [ + { value: "anton", label: "Anton" }, + { value: "arial-black", label: "Arial Black" }, + { value: "comic-sans", label: "Comic Sans" }, + { value: "montserrat", label: "Montserrat" }, + { value: "bebas-neue", label: "Bebas Neue" }, + { value: "permanent-marker", label: "Permanent Marker" }, + { value: "roboto", label: "Roboto Black" }, +] as const; + +export const FONT_FAMILY_MAP: Record = { + anton: "'Anton', 'Impact', sans-serif", + "arial-black": "'Arial Black', 'Anton', sans-serif", + "comic-sans": "'Comic Sans MS', cursive", + montserrat: "'Montserrat Black', 'Anton', sans-serif", + "bebas-neue": "'Bebas Neue', 'Anton', sans-serif", + "permanent-marker": "'Permanent Marker', cursive", + roboto: "'Roboto Black', 'Anton', sans-serif", +}; + +export const CATEGORIES = [ + { id: "all", label: "All" }, + { id: "reaction", label: "Reaction" }, + { id: "comparison", label: "Comparison" }, + { id: "opinion", label: "Opinion" }, + { id: "animals", label: "Animals" }, + { id: "classic", label: "Classic" }, +]; + +export const PRESET_LAYOUTS: Record< + TextLayout, + { label: string; description: string; boxes: TemplateTextBox[] } +> = { + "top-bottom": { + label: "Top + Bottom", + description: "Classic meme layout", + boxes: [ + { id: "top", x: 5, y: 2, width: 90, height: 20, defaultText: "Top text" }, + { id: "bottom", x: 5, y: 78, width: 90, height: 20, defaultText: "Bottom text" }, + ], + }, + "top-only": { + label: "Top Only", + description: "Text at the top", + boxes: [{ id: "top", x: 5, y: 2, width: 90, height: 25, defaultText: "Top text" }], + }, + "bottom-only": { + label: "Bottom Only", + description: "Text at the bottom", + boxes: [{ id: "bottom", x: 5, y: 75, width: 90, height: 23, defaultText: "Bottom text" }], + }, + center: { + label: "Center", + description: "Text in the middle", + boxes: [{ id: "center", x: 10, y: 35, width: 80, height: 30, defaultText: "Center text" }], + }, + "side-by-side": { + label: "Side by Side", + description: "Left and right text", + boxes: [ + { id: "left", x: 2, y: 35, width: 46, height: 30, defaultText: "Left text" }, + { id: "right", x: 52, y: 35, width: 46, height: 30, defaultText: "Right text" }, + ], + }, +}; + +// ── Font loading ───────────────────────────────────────────────────── + +const FONT_FACES = [ + { family: "Anton", file: "Anton-Regular.ttf" }, + { family: "Bebas Neue", file: "BebasNeue-Regular.ttf" }, + { family: "Permanent Marker", file: "PermanentMarker-Regular.ttf" }, + { family: "Montserrat Black", file: "Montserrat-Black.ttf" }, + { family: "Roboto Black", file: "Roboto-Black.ttf" }, +]; + +let fontsInjected = false; + +export function injectMemeFonts() { + if (fontsInjected) return; + const id = "meme-generator-fonts"; + if (document.getElementById(id)) { + fontsInjected = true; + return; + } + + const css = FONT_FACES.map( + (f) => + `@font-face { font-family: '${f.family}'; src: url('/api/v1/meme-templates/fonts/${f.file}') format('truetype'); font-display: swap; }`, + ).join("\n"); + + const style = document.createElement("style"); + style.id = id; + style.textContent = css; + document.head.appendChild(style); + fontsInjected = true; +} + +// ── Store ──────────────────────────────────────────────────────────── + +interface MemeState { + // Phase + phase: Phase; + + // Templates + templates: MemeTemplate[]; + loading: boolean; + searchQuery: string; + activeCategory: string; + + // Selected template + selectedTemplate: MemeTemplate | null; + + // Custom image + customFile: File | null; + customImageUrl: string | null; + customLayout: TextLayout | null; + + // Editor settings + textBoxValues: TextBoxValue[]; + fontFamily: string; + fontSize: number; // 0 = auto + textColor: string; + strokeColor: string; + textAlign: string; + allCaps: boolean; + + // Processing / result + generating: boolean; + resultUrl: string | null; + downloadUrl: string | null; + error: string | null; + + // Actions + setPhase: (phase: Phase) => void; + setSearchQuery: (q: string) => void; + setActiveCategory: (c: string) => void; + selectTemplate: (t: MemeTemplate) => void; + setCustomImage: (file: File) => void; + setCustomLayout: (layout: TextLayout) => void; + updateTextValue: (id: string, text: string) => void; + setFontFamily: (f: string) => void; + setFontSize: (s: number) => void; + setTextColor: (c: string) => void; + setStrokeColor: (c: string) => void; + setTextAlign: (a: string) => void; + setAllCaps: (v: boolean) => void; + fetchTemplates: () => Promise; + generateMeme: () => Promise; + backToGallery: () => void; + backToEditor: () => void; + reset: () => void; +} + +export const useMemeStore = create((set, get) => ({ + phase: "gallery", + templates: [], + loading: true, + searchQuery: "", + activeCategory: "all", + selectedTemplate: null, + customFile: null, + customImageUrl: null, + customLayout: null, + textBoxValues: [], + fontFamily: "anton", + fontSize: 0, + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: true, + generating: false, + resultUrl: null, + downloadUrl: null, + error: null, + + setPhase: (phase) => set({ phase }), + setSearchQuery: (q) => set({ searchQuery: q }), + setActiveCategory: (c) => set({ activeCategory: c }), + + selectTemplate: (t) => { + const oldUrl = get().customImageUrl; + if (oldUrl) URL.revokeObjectURL(oldUrl); + set({ + selectedTemplate: t, + customFile: null, + customImageUrl: null, + customLayout: null, + textBoxValues: t.textBoxes.map((b) => ({ id: b.id, text: "" })), + resultUrl: null, + downloadUrl: null, + error: null, + generating: false, + phase: "editor", + }); + }, + + setCustomImage: (file) => { + const oldUrl = get().customImageUrl; + if (oldUrl) URL.revokeObjectURL(oldUrl); + set({ + customFile: file, + customImageUrl: URL.createObjectURL(file), + selectedTemplate: null, + resultUrl: null, + downloadUrl: null, + error: null, + generating: false, + phase: "layout-picker", + }); + }, + + setCustomLayout: (layout) => { + const boxes = PRESET_LAYOUTS[layout]?.boxes ?? PRESET_LAYOUTS["top-bottom"].boxes; + set({ + customLayout: layout, + textBoxValues: boxes.map((b) => ({ id: b.id, text: "" })), + phase: "editor", + }); + }, + + updateTextValue: (id, text) => { + const values = get().textBoxValues.map((v) => (v.id === id ? { ...v, text } : v)); + set({ textBoxValues: values }); + }, + + setFontFamily: (f) => set({ fontFamily: f }), + setFontSize: (s) => set({ fontSize: s }), + setTextColor: (c) => set({ textColor: c }), + setStrokeColor: (c) => set({ strokeColor: c }), + setTextAlign: (a) => set({ textAlign: a }), + setAllCaps: (v) => set({ allCaps: v }), + + fetchTemplates: async () => { + set({ loading: true, error: null }); + try { + const res = await fetch("/api/v1/meme-templates", { headers: formatHeaders() }); + if (!res.ok) throw new Error(`Failed to load templates: ${res.status}`); + const data: TemplateManifest = await res.json(); + set({ templates: data.templates, loading: false }); + } catch (err) { + set({ + error: err instanceof Error ? err.message : "Failed to load templates", + loading: false, + }); + } + }, + + generateMeme: async () => { + const state = get(); + set({ generating: true, error: null }); + + try { + const apiSettings = { + templateId: state.selectedTemplate?.id, + textLayout: state.customLayout ?? "top-bottom", + textBoxes: state.textBoxValues, + fontFamily: state.fontFamily, + fontSize: state.fontSize > 0 ? state.fontSize : undefined, + textColor: state.textColor, + strokeColor: state.strokeColor, + textAlign: state.textAlign, + allCaps: state.allCaps, + }; + + let response: Response; + + if (state.customFile) { + const formData = new FormData(); + formData.append("file", state.customFile); + formData.append("settings", JSON.stringify(apiSettings)); + response = await fetch("/api/v1/tools/meme-generator", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); + } else { + response = await fetch("/api/v1/tools/meme-generator", { + method: "POST", + headers: formatHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(apiSettings), + }); + } + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error( + (body as Record).error || `Generation failed: ${response.status}`, + ); + } + + const result = (await response.json()) as { + jobId: string; + downloadUrl: string; + originalSize: number; + processedSize: number; + }; + + set({ + resultUrl: result.downloadUrl, + downloadUrl: result.downloadUrl, + phase: "result", + generating: false, + }); + } catch (err) { + set({ + error: err instanceof Error ? err.message : "Meme generation failed", + generating: false, + }); + } + }, + + backToGallery: () => { + const oldUrl = get().customImageUrl; + if (oldUrl) URL.revokeObjectURL(oldUrl); + set({ + phase: "gallery", + selectedTemplate: null, + customFile: null, + customImageUrl: null, + customLayout: null, + textBoxValues: [], + resultUrl: null, + downloadUrl: null, + error: null, + generating: false, + }); + }, + + backToEditor: () => set({ phase: "editor", resultUrl: null, downloadUrl: null }), + + reset: () => { + const oldUrl = get().customImageUrl; + if (oldUrl) URL.revokeObjectURL(oldUrl); + set({ + phase: "gallery", + templates: [], + loading: true, + searchQuery: "", + activeCategory: "all", + selectedTemplate: null, + customFile: null, + customImageUrl: null, + customLayout: null, + textBoxValues: [], + fontFamily: "anton", + fontSize: 0, + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: true, + generating: false, + resultUrl: null, + downloadUrl: null, + error: null, + }); + }, +})); diff --git a/docker/Dockerfile b/docker/Dockerfile index be17257e..3f94bb28 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -225,6 +225,7 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store/v3 \ # Copy source code for API (tsx runs TS directly - no build step needed) COPY apps/api/src ./apps/api/src COPY apps/api/drizzle ./apps/api/drizzle +COPY apps/api/static ./apps/api/static # Copy workspace packages source (referenced by API at runtime) COPY packages/shared/src ./packages/shared/src diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 889ec56f..cf8f5065 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -290,6 +290,14 @@ export const TOOLS: Tool[] = [ icon: "Layers", route: "/compose", }, + { + id: "meme-generator", + name: "Meme Generator", + description: "Create memes with templates and custom text", + category: "watermark", + icon: "Laugh", + route: "/meme-generator", + }, // Utilities { id: "info", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 54667f13..05e65c42 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -136,6 +136,11 @@ export const en = { "watermark-image": { name: "Image Watermark", description: "Overlay a logo as watermark" }, "text-overlay": { name: "Text Overlay", description: "Add styled text to images" }, compose: { name: "Image Composition", description: "Layer images with position and opacity" }, + "meme-generator": { + name: "Meme Generator", + description: + "Create memes with popular templates or your own images. Classic Impact-style text with customizable fonts, colors, and positioning.", + }, info: { name: "Image Info", description: "View all metadata and image properties" }, compare: { name: "Image Compare", description: "Side-by-side comparison of two images" }, "find-duplicates": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b91e259e..b124da24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: mupdf: specifier: ^1.27.0 version: 1.27.0 + opentype.js: + specifier: ^2.0.0 + version: 2.0.0 p-queue: specifier: ^9.1.0 version: 9.1.0 @@ -187,6 +190,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.15 + '@types/opentype.js': + specifier: ^1.3.9 + version: 1.3.9 '@types/pdfkit': specifier: ^0.17.5 version: 0.17.5 @@ -3398,6 +3404,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/opentype.js@1.3.9': + resolution: {integrity: sha512-KOGywvDPncA4/tTWV5xKNhjpsoSSAHIx3mHOhL5l3XX+c6Xu2dQnHvGs7mRNQsQRte1EqmQ0cPQQ8Z14lkv+yw==} + '@types/pdfkit@0.17.5': resolution: {integrity: sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==} @@ -5561,6 +5570,10 @@ packages: oniguruma-to-es@3.1.1: resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + opentype.js@2.0.0: + resolution: {integrity: sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==} + hasBin: true + p-each-series@3.0.0: resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==} engines: {node: '>=12'} @@ -9814,6 +9827,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/opentype.js@1.3.9': {} + '@types/pdfkit@0.17.5': dependencies: '@types/node': 22.19.15 @@ -12068,6 +12083,8 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + opentype.js@2.0.0: {} + p-each-series@3.0.0: {} p-event@6.0.1: diff --git a/scripts/build-templates.mjs b/scripts/build-templates.mjs new file mode 100644 index 00000000..058a8f97 --- /dev/null +++ b/scripts/build-templates.mjs @@ -0,0 +1,189 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const sharp = require("sharp"); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FULL_DIR = join(__dirname, "../apps/api/static/meme-templates/full"); +const THUMBS_DIR = join(__dirname, "../apps/api/static/meme-templates/thumbs"); +const MANIFEST_PATH = join(__dirname, "../apps/api/static/meme-templates/meme-templates.json"); + +mkdirSync(FULL_DIR, { recursive: true }); +mkdirSync(THUMBS_DIR, { recursive: true }); + +const CURATED_BOXES = { + "drake-hotline-bling": [ + { id: "reject", x: 52, y: 0, width: 48, height: 50, defaultText: "Thing I reject" }, + { id: "approve", x: 52, y: 50, width: 48, height: 50, defaultText: "Thing I approve" }, + ], + "distracted-boyfriend": [ + { id: "girl-behind", x: 58, y: 15, width: 30, height: 30, defaultText: "Current thing" }, + { id: "guy", x: 32, y: 5, width: 25, height: 30, defaultText: "Me" }, + { id: "girl-front", x: 2, y: 15, width: 28, height: 30, defaultText: "New shiny thing" }, + ], + "expanding-brain": [ + { id: "panel-1", x: 0, y: 0, width: 50, height: 25, defaultText: "Normal idea" }, + { id: "panel-2", x: 0, y: 25, width: 50, height: 25, defaultText: "Smarter idea" }, + { id: "panel-3", x: 0, y: 50, width: 50, height: 25, defaultText: "Big brain idea" }, + { id: "panel-4", x: 0, y: 75, width: 50, height: 25, defaultText: "Galaxy brain idea" }, + ], + "change-my-mind": [ + { id: "sign", x: 24, y: 55, width: 50, height: 30, defaultText: "Your hot take here" }, + ], + "two-buttons": [ + { id: "left-button", x: 5, y: 2, width: 38, height: 22, defaultText: "Option A" }, + { id: "right-button", x: 45, y: 2, width: 38, height: 22, defaultText: "Option B" }, + ], + "gru-s-plan": [ + { id: "step-1", x: 52, y: 0, width: 46, height: 25, defaultText: "Step 1" }, + { id: "step-2", x: 52, y: 25, width: 46, height: 25, defaultText: "Step 2" }, + { id: "step-3", x: 52, y: 50, width: 46, height: 25, defaultText: "Unexpected result" }, + { id: "realization", x: 52, y: 75, width: 46, height: 25, defaultText: "Wait..." }, + ], + "buff-doge-vs-cheems": [ + { id: "buff-label", x: 2, y: 2, width: 45, height: 15, defaultText: "Strong version" }, + { id: "buff-text", x: 2, y: 75, width: 45, height: 23, defaultText: "Chad description" }, + { id: "cheems-label", x: 52, y: 2, width: 45, height: 15, defaultText: "Weak version" }, + { id: "cheems-text", x: 52, y: 75, width: 45, height: 23, defaultText: "Sad description" }, + ], + "trade-offer": [ + { id: "i-receive", x: 5, y: 32, width: 42, height: 32, defaultText: "I receive" }, + { id: "you-receive", x: 53, y: 32, width: 42, height: 32, defaultText: "You receive" }, + ], + "tuxedo-winnie-the-pooh": [ + { id: "regular", x: 52, y: 2, width: 46, height: 48, defaultText: "Regular way" }, + { id: "fancy", x: 52, y: 52, width: 46, height: 46, defaultText: "Fancy way" }, + ], + "epic-handshake": [ + { id: "left", x: 2, y: 2, width: 30, height: 20, defaultText: "Group A" }, + { id: "center", x: 25, y: 70, width: 50, height: 25, defaultText: "Shared thing" }, + { id: "right", x: 68, y: 2, width: 30, height: 20, defaultText: "Group B" }, + ], + "sad-pablo-escobar": [ + { id: "top", x: 5, y: 2, width: 90, height: 20, defaultText: "When you..." }, + { id: "middle", x: 5, y: 40, width: 90, height: 20 }, + { id: "bottom", x: 5, y: 78, width: 90, height: 20, defaultText: "...sad" }, + ], + "bike-fall": [ + { id: "stick", x: 50, y: 0, width: 48, height: 33, defaultText: "My plan" }, + { id: "wheel", x: 5, y: 33, width: 48, height: 33, defaultText: "What went wrong" }, + { id: "ground", x: 50, y: 66, width: 48, height: 33, defaultText: "The consequence" }, + ], + "they-re-the-same-picture": [ + { id: "left-image", x: 10, y: 8, width: 35, height: 25, defaultText: "Thing A" }, + { id: "right-image", x: 55, y: 8, width: 35, height: 25, defaultText: "Thing B" }, + { id: "caption", x: 10, y: 72, width: 80, height: 15, defaultText: "They're the same picture" }, + ], + "running-away-balloon": [ + { id: "person", x: 50, y: 55, width: 25, height: 15, defaultText: "Me" }, + { id: "balloon", x: 55, y: 5, width: 30, height: 15, defaultText: "Responsibilities" }, + { id: "distraction", x: 2, y: 55, width: 25, height: 15, defaultText: "Distraction" }, + ], + "anakin-padme-4-panel": [ + { id: "anakin-1", x: 0, y: 0, width: 50, height: 15, defaultText: "Statement" }, + { id: "padme-1", x: 50, y: 0, width: 50, height: 50, defaultText: "Right...?" }, + { id: "anakin-2", x: 0, y: 50, width: 50, height: 50, defaultText: "..." }, + ], + "left-exit-12-off-ramp": [ + { id: "straight", x: 35, y: 2, width: 30, height: 15, defaultText: "Good choice" }, + { id: "exit", x: 65, y: 2, width: 30, height: 15, defaultText: "Bad choice" }, + { id: "car", x: 35, y: 65, width: 40, height: 20, defaultText: "Me" }, + ], + "clown-applying-makeup": [ + { id: "panel-1", x: 52, y: 0, width: 46, height: 25, defaultText: "Step 1" }, + { id: "panel-2", x: 52, y: 25, width: 46, height: 25, defaultText: "Step 2" }, + { id: "panel-3", x: 52, y: 50, width: 46, height: 25, defaultText: "Step 3" }, + { id: "panel-4", x: 52, y: 75, width: 46, height: 25, defaultText: "Full clown" }, + ], + "panik-kalm-panik": [ + { id: "panik-1", x: 0, y: 0, width: 50, height: 33, defaultText: "Scary thing" }, + { id: "kalm", x: 0, y: 33, width: 50, height: 33, defaultText: "Resolution" }, + { id: "panik-2", x: 0, y: 66, width: 50, height: 33, defaultText: "Even scarier" }, + ], +}; + +function classifyCategory(name) { + const n = name.toLowerCase(); + if (/brain|boyfriend|buff|vs|tuxedo|pooh|handshake|same picture|gru|clown|bike|exit|draw 25|horse|scroll|virgin|bell curve/i.test(n)) return "comparison"; + if (/change my mind|pills|lisa|hannibal|boardroom|balloon|salesman|getting paid|everywhere|megamind|presentation|bernie|roof/i.test(n)) return "opinion"; + if (/doge|cat|dog|skeleton|kermit|monkey|penguin|cheems|pigeon|spongebob|bird|frog|seal|bear/i.test(n)) return "animals"; + if (/simply|y u no|bad luck|success|aliens|batman|first time|well yes|flex|x everywhere|ancient|futurama|matrix|chuck/i.test(n)) return "classic"; + return "reaction"; +} + +function generateTags(name) { + return [...new Set(name.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w.length > 2))]; +} + +async function downloadImage(url, dest) { + const res = await fetch(url); + if (!res.ok) throw new Error("HTTP " + res.status); + const buf = Buffer.from(await res.arrayBuffer()); + writeFileSync(dest, buf); +} + +async function main() { + const res = await fetch("https://api.imgflip.com/get_memes"); + const data = await res.json(); + const memes = data.data.memes; + console.log(`Fetching ${memes.length} Imgflip templates...`); + + const templates = []; + let downloaded = 0; + + for (const meme of memes) { + const slug = meme.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + const filename = slug + ".jpg"; + const destPath = join(FULL_DIR, filename); + + if (!existsSync(destPath)) { + try { + await downloadImage(meme.url, destPath); + downloaded++; + process.stdout.write("."); + } catch (e) { + console.log(`\n FAIL ${slug}: ${e.message}`); + continue; + } + } + + const textBoxes = CURATED_BOXES[slug] || [ + { id: "top", x: 5, y: 2, width: 90, height: 20, defaultText: "Top text" }, + { id: "bottom", x: 5, y: 78, width: 90, height: 20, defaultText: "Bottom text" }, + ]; + + templates.push({ + id: slug, + name: meme.name, + aliases: [], + tags: generateTags(meme.name), + category: classifyCategory(meme.name), + filename, + width: meme.width, + height: meme.height, + popularity: templates.length + 1, + textBoxes, + }); + } + + console.log(`\nDownloaded ${downloaded} new images`); + + const manifest = { version: 1, categories: ["reaction", "comparison", "opinion", "animals", "classic"], templates }; + writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2)); + console.log(`Manifest: ${templates.length} templates`); + + console.log("Generating thumbnails..."); + const files = readdirSync(FULL_DIR).filter((f) => f.endsWith(".jpg")); + for (const file of files) { + const thumbPath = join(THUMBS_DIR, file.replace(".jpg", ".webp")); + if (!existsSync(thumbPath)) { + await sharp(join(FULL_DIR, file)).resize({ width: 200 }).webp({ quality: 80 }).toFile(thumbPath); + } + } + console.log(`Thumbnails: ${readdirSync(THUMBS_DIR).length}`); +} + +main().catch(console.error); diff --git a/scripts/fetch-meme-templates.ts b/scripts/fetch-meme-templates.ts new file mode 100644 index 00000000..4a364822 --- /dev/null +++ b/scripts/fetch-meme-templates.ts @@ -0,0 +1,80 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEMPLATES_DIR = join(__dirname, "../apps/api/static/meme-templates"); +const MANIFEST_PATH = join(TEMPLATES_DIR, "meme-templates.json"); +const FULL_DIR = join(TEMPLATES_DIR, "full"); + +interface ImgflipMeme { + id: string; + name: string; + url: string; + width: number; + height: number; + box_count: number; +} + +async function fetchImgflipMemes(): Promise { + const res = await fetch("https://api.imgflip.com/get_memes"); + const data = (await res.json()) as { + success: boolean; + data: { memes: ImgflipMeme[] }; + }; + if (!data.success) throw new Error("Imgflip API failed"); + return data.data.memes; +} + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +async function downloadImage(url: string, dest: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to download ${url}: ${res.status}`); + const buffer = Buffer.from(await res.arrayBuffer()); + const resized = await sharp(buffer) + .resize({ width: 1200, withoutEnlargement: true }) + .jpeg({ quality: 90 }) + .toBuffer(); + writeFileSync(dest, resized); +} + +async function main() { + mkdirSync(FULL_DIR, { recursive: true }); + + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")); + const existingIds = new Set(manifest.templates.map((t: { id: string }) => t.id)); + + const imgflipMemes = await fetchImgflipMemes(); + console.log(`Imgflip returned ${imgflipMemes.length} memes`); + + let downloaded = 0; + for (const meme of imgflipMemes) { + const slug = slugify(meme.name); + const filename = `${slug}.jpg`; + const destPath = join(FULL_DIR, filename); + + if (existsSync(destPath)) { + console.log(` SKIP ${slug} (already exists)`); + continue; + } + + if (existingIds.has(slug)) { + console.log(` DOWNLOAD ${slug}`); + await downloadImage(meme.url, destPath); + downloaded++; + } else { + console.log(` SKIP ${slug} (not in manifest)`); + } + } + + console.log(`Downloaded ${downloaded} template images`); +} + +main().catch(console.error); diff --git a/scripts/generate-meme-thumbs.ts b/scripts/generate-meme-thumbs.ts new file mode 100644 index 00000000..ebb23bf7 --- /dev/null +++ b/scripts/generate-meme-thumbs.ts @@ -0,0 +1,33 @@ +import { mkdirSync, readdirSync } from "node:fs"; +import { dirname, join, parse } from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEMPLATES_DIR = join(__dirname, "../apps/api/static/meme-templates"); +const FULL_DIR = join(TEMPLATES_DIR, "full"); +const THUMBS_DIR = join(TEMPLATES_DIR, "thumbs"); + +async function main() { + mkdirSync(THUMBS_DIR, { recursive: true }); + + const files = readdirSync(FULL_DIR).filter( + (f) => f.endsWith(".jpg") || f.endsWith(".jpeg") || f.endsWith(".png"), + ); + + console.log(`Generating thumbnails for ${files.length} templates...`); + + for (const file of files) { + const { name } = parse(file); + const inputPath = join(FULL_DIR, file); + const outputPath = join(THUMBS_DIR, `${name}.webp`); + + await sharp(inputPath).resize({ width: 200 }).webp({ quality: 80 }).toFile(outputPath); + + console.log(` ${name}.webp`); + } + + console.log("Done."); +} + +main().catch(console.error); 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); diff --git a/tests/unit/api/meme-text-renderer.test.ts b/tests/unit/api/meme-text-renderer.test.ts new file mode 100644 index 00000000..e7c374f0 --- /dev/null +++ b/tests/unit/api/meme-text-renderer.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "vitest"; +import { + autoSizeFontToFit, + loadFont, + measureText, + renderMemeTextSvg, + wrapText, +} from "../../../apps/api/src/lib/meme-text-renderer.js"; + +// ========================================================================== +// loadFont +// ========================================================================== + +describe("loadFont", () => { + it("loads anton font", () => { + const font = loadFont("anton"); + expect(font).toBeDefined(); + expect(font.getAdvanceWidth).toBeTypeOf("function"); + }); + + it("falls back to anton for unknown fonts", () => { + const font = loadFont("totally-unknown-font"); + const anton = loadFont("anton"); + // Both should return a usable font object + expect(font).toBeDefined(); + expect(font.getAdvanceWidth("Hello", 48)).toBe(anton.getAdvanceWidth("Hello", 48)); + }); + + it("caches across calls", () => { + const first = loadFont("anton"); + const second = loadFont("anton"); + expect(first).toBe(second); + }); +}); + +// ========================================================================== +// measureText +// ========================================================================== + +describe("measureText", () => { + it("returns positive width for non-empty text", () => { + const width = measureText("Hello", "anton", 48); + expect(width).toBeGreaterThan(0); + }); + + it("returns 0 for empty text", () => { + const width = measureText("", "anton", 48); + expect(width).toBe(0); + }); + + it("scales with font size", () => { + const small = measureText("Hello", "anton", 24); + const large = measureText("Hello", "anton", 48); + expect(large).toBeGreaterThan(small); + }); +}); + +// ========================================================================== +// wrapText +// ========================================================================== + +describe("wrapText", () => { + it("returns single line when text fits", () => { + const lines = wrapText("Hi", "anton", 48, 500); + expect(lines).toHaveLength(1); + expect(lines[0]).toBe("Hi"); + }); + + it("wraps into multiple lines", () => { + const lines = wrapText("This is a longer sentence that should wrap", "anton", 48, 200); + expect(lines.length).toBeGreaterThan(1); + }); + + it("handles single word exceeding width", () => { + const lines = wrapText("Supercalifragilisticexpialidocious", "anton", 48, 50); + expect(lines.length).toBeGreaterThanOrEqual(1); + // The word must appear somewhere in the output + expect(lines.join("")).toBe("Supercalifragilisticexpialidocious"); + }); + + it('returns [""] for empty text', () => { + const lines = wrapText("", "anton", 48, 500); + expect(lines).toEqual([""]); + }); + + it("handles multiple spaces", () => { + const lines = wrapText("Hello World", "anton", 48, 500); + // Should still produce valid output (no empty-string tokens) + for (const line of lines) { + expect(line.trim()).not.toBe(""); + } + }); +}); + +// ========================================================================== +// autoSizeFontToFit +// ========================================================================== + +describe("autoSizeFontToFit", () => { + it("returns size within bounds", () => { + const size = autoSizeFontToFit("Hello World", "anton", 400, 200); + expect(size).toBeGreaterThanOrEqual(8); + expect(size).toBeLessThanOrEqual(200); + }); + + it("returns smaller size for longer text", () => { + const shortSize = autoSizeFontToFit("Hi", "anton", 400, 200); + const longSize = autoSizeFontToFit( + "This is a much longer piece of text that needs more space", + "anton", + 400, + 200, + ); + expect(longSize).toBeLessThanOrEqual(shortSize); + }); + + it("returns min size for tiny box", () => { + const size = autoSizeFontToFit("Hello World", "anton", 10, 10); + expect(size).toBe(8); + }); + + it("respects maxFontSize", () => { + const size = autoSizeFontToFit("Hi", "anton", 2000, 2000, 36); + expect(size).toBeLessThanOrEqual(36); + }); +}); + +// ========================================================================== +// renderMemeTextSvg +// ========================================================================== + +describe("renderMemeTextSvg", () => { + it("returns valid SVG with elements", () => { + const svgBuf = renderMemeTextSvg({ + imageWidth: 800, + imageHeight: 600, + textBoxes: [{ text: "TOP TEXT", x: 5, y: 2, width: 90, height: 20 }], + fontFamily: "anton", + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: false, + }); + const svg = svgBuf.toString("utf-8"); + expect(svg).toContain(""); + }); + + it("has correct fill/stroke attributes", () => { + const svgBuf = renderMemeTextSvg({ + imageWidth: 800, + imageHeight: 600, + textBoxes: [{ text: "Hello", x: 5, y: 2, width: 90, height: 20 }], + fontFamily: "anton", + textColor: "#ff0000", + strokeColor: "#00ff00", + textAlign: "center", + allCaps: false, + }); + const svg = svgBuf.toString("utf-8"); + expect(svg).toContain('fill="#ff0000"'); + expect(svg).toContain('stroke="#00ff00"'); + }); + + it('has paint-order="stroke fill"', () => { + const svgBuf = renderMemeTextSvg({ + imageWidth: 800, + imageHeight: 600, + textBoxes: [{ text: "Hello", x: 5, y: 2, width: 90, height: 20 }], + fontFamily: "anton", + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: false, + }); + const svg = svgBuf.toString("utf-8"); + expect(svg).toContain('paint-order="stroke fill"'); + }); + + it("skips empty text boxes", () => { + const svgBuf = renderMemeTextSvg({ + imageWidth: 800, + imageHeight: 600, + textBoxes: [ + { text: "", x: 5, y: 2, width: 90, height: 20 }, + { text: " ", x: 5, y: 50, width: 90, height: 20 }, + ], + fontFamily: "anton", + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: false, + }); + const svg = svgBuf.toString("utf-8"); + // SVG is returned but should have no elements + expect(svg).not.toContain(" { + const lowerBuf = renderMemeTextSvg({ + imageWidth: 800, + imageHeight: 600, + textBoxes: [{ text: "hello", x: 5, y: 2, width: 90, height: 20 }], + fontFamily: "anton", + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: false, + }); + const upperBuf = renderMemeTextSvg({ + imageWidth: 800, + imageHeight: 600, + textBoxes: [{ text: "hello", x: 5, y: 2, width: 90, height: 20 }], + fontFamily: "anton", + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: true, + }); + // Different path data when allCaps transforms the text + const lowerSvg = lowerBuf.toString("utf-8"); + const upperSvg = upperBuf.toString("utf-8"); + expect(lowerSvg).not.toBe(upperSvg); + }); + + it("uses custom fontSize", () => { + const svgBuf = renderMemeTextSvg({ + imageWidth: 800, + imageHeight: 600, + textBoxes: [{ text: "Hello", x: 5, y: 2, width: 90, height: 20 }], + fontFamily: "anton", + fontSize: 32, + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: false, + }); + const svg = svgBuf.toString("utf-8"); + expect(svg).toContain(" { + const antonBuf = renderMemeTextSvg({ + imageWidth: 800, + imageHeight: 600, + textBoxes: [{ text: "Hello", x: 5, y: 2, width: 90, height: 20 }], + fontFamily: "anton", + fontSize: 48, + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: false, + }); + const bebasBuf = renderMemeTextSvg({ + imageWidth: 800, + imageHeight: 600, + textBoxes: [{ text: "Hello", x: 5, y: 2, width: 90, height: 20 }], + fontFamily: "bebas-neue", + fontSize: 48, + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: false, + }); + expect(antonBuf.toString("utf-8")).not.toBe(bebasBuf.toString("utf-8")); + }); +}); diff --git a/tests/unit/meme-templates.test.ts b/tests/unit/meme-templates.test.ts new file mode 100644 index 00000000..1d2571ed --- /dev/null +++ b/tests/unit/meme-templates.test.ts @@ -0,0 +1,188 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const TEMPLATES_DIR = join(__dirname, "../../apps/api/static/meme-templates"); +const MANIFEST_PATH = join(TEMPLATES_DIR, "meme-templates.json"); +const VALID_CATEGORIES = ["reaction", "comparison", "opinion", "animals", "classic"]; + +interface TextBox { + id: string; + x: number; + y: number; + width: number; + height: number; + defaultText: string; +} + +interface Template { + id: string; + name: string; + aliases: string[]; + tags: string[]; + category: string; + filename: string; + width: number; + height: number; + popularity: number; + textBoxes: TextBox[]; +} + +interface Manifest { + version: number; + categories: string[]; + templates: Template[]; +} + +function loadManifest(): Manifest { + const raw = readFileSync(MANIFEST_PATH, "utf-8"); + return JSON.parse(raw); +} + +describe("meme template manifest validation", () => { + it("manifest file exists and is valid JSON with version 1 and non-empty templates array", () => { + expect(existsSync(MANIFEST_PATH)).toBe(true); + + const raw = readFileSync(MANIFEST_PATH, "utf-8"); + let manifest: Manifest; + expect(() => { + manifest = JSON.parse(raw); + }).not.toThrow(); + + manifest = JSON.parse(raw); + expect(manifest.version).toBe(1); + expect(Array.isArray(manifest.templates)).toBe(true); + expect(manifest.templates.length).toBeGreaterThan(0); + }); + + it("has no duplicate template IDs", () => { + const manifest = loadManifest(); + const ids = manifest.templates.map((t) => t.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + + // Identify duplicates for a useful error message + const seen = new Set(); + const duplicates: string[] = []; + for (const id of ids) { + if (seen.has(id)) { + duplicates.push(id); + } + seen.add(id); + } + expect(duplicates, `Duplicate template IDs: ${duplicates.join(", ")}`).toHaveLength(0); + }); + + it("every template has required fields with correct types", () => { + const manifest = loadManifest(); + + for (const template of manifest.templates) { + const label = `template "${template.id || template.name || "unknown"}"`; + + // Required string fields + expect(typeof template.id, `${label}: id must be a string`).toBe("string"); + expect(template.id.length, `${label}: id must not be empty`).toBeGreaterThan(0); + expect(typeof template.name, `${label}: name must be a string`).toBe("string"); + expect(template.name.length, `${label}: name must not be empty`).toBeGreaterThan(0); + + // aliases must be an array + expect(Array.isArray(template.aliases), `${label}: aliases must be an array`).toBe(true); + + // tags must be an array + expect(Array.isArray(template.tags), `${label}: tags must be an array`).toBe(true); + + // category must be valid + expect( + VALID_CATEGORIES.includes(template.category), + `${label}: category "${template.category}" is not one of ${VALID_CATEGORIES.join(", ")}`, + ).toBe(true); + + // filename + expect(typeof template.filename, `${label}: filename must be a string`).toBe("string"); + expect(template.filename.length, `${label}: filename must not be empty`).toBeGreaterThan(0); + + // width and height must be positive numbers + expect(typeof template.width, `${label}: width must be a number`).toBe("number"); + expect(template.width, `${label}: width must be positive`).toBeGreaterThan(0); + expect(typeof template.height, `${label}: height must be a number`).toBe("number"); + expect(template.height, `${label}: height must be positive`).toBeGreaterThan(0); + + // popularity must be non-negative + expect(typeof template.popularity, `${label}: popularity must be a number`).toBe("number"); + expect( + template.popularity, + `${label}: popularity must be non-negative`, + ).toBeGreaterThanOrEqual(0); + + // textBoxes must be a non-empty array + expect(Array.isArray(template.textBoxes), `${label}: textBoxes must be an array`).toBe(true); + expect( + template.textBoxes.length, + `${label}: must have at least one textBox`, + ).toBeGreaterThanOrEqual(1); + } + }); + + it("text box coordinates are in valid percentage range (0-100)", () => { + const manifest = loadManifest(); + + for (const template of manifest.templates) { + for (const box of template.textBoxes) { + const label = `template "${template.id}" textBox "${box.id}"`; + + expect(box.x, `${label}: x must be >= 0`).toBeGreaterThanOrEqual(0); + expect(box.x, `${label}: x must be <= 100`).toBeLessThanOrEqual(100); + + expect(box.y, `${label}: y must be >= 0`).toBeGreaterThanOrEqual(0); + expect(box.y, `${label}: y must be <= 100`).toBeLessThanOrEqual(100); + + expect(box.width, `${label}: width must be >= 0`).toBeGreaterThanOrEqual(0); + expect(box.width, `${label}: width must be <= 100`).toBeLessThanOrEqual(100); + + expect(box.height, `${label}: height must be >= 0`).toBeGreaterThanOrEqual(0); + expect(box.height, `${label}: height must be <= 100`).toBeLessThanOrEqual(100); + } + } + }); + + it("no duplicate text box IDs within a template", () => { + const manifest = loadManifest(); + + for (const template of manifest.templates) { + const boxIds = template.textBoxes.map((b) => b.id); + const uniqueBoxIds = new Set(boxIds); + expect(uniqueBoxIds.size, `template "${template.id}" has duplicate textBox IDs`).toBe( + boxIds.length, + ); + } + }); + + it("every template has a corresponding full-size image in full/ directory", () => { + const manifest = loadManifest(); + const fullDir = join(TEMPLATES_DIR, "full"); + + for (const template of manifest.templates) { + const imagePath = join(fullDir, template.filename); + expect( + existsSync(imagePath), + `template "${template.id}": missing full image at full/${template.filename}`, + ).toBe(true); + } + }); + + it("every template has a corresponding thumbnail in thumbs/ directory", () => { + const manifest = loadManifest(); + const thumbsDir = join(TEMPLATES_DIR, "thumbs"); + + for (const template of manifest.templates) { + // Thumbnail uses the same base name but with .webp extension + const baseName = template.filename.replace(/\.[^.]+$/, ""); + const thumbFilename = `${baseName}.webp`; + const thumbPath = join(thumbsDir, thumbFilename); + expect( + existsSync(thumbPath), + `template "${template.id}": missing thumbnail at thumbs/${thumbFilename}`, + ).toBe(true); + } + }); +}); diff --git a/tests/unit/web/tool-registry.test.ts b/tests/unit/web/tool-registry.test.ts index f9c2060c..9f6091bc 100644 --- a/tests/unit/web/tool-registry.test.ts +++ b/tests/unit/web/tool-registry.test.ts @@ -296,8 +296,9 @@ describe("toolRegistry", () => { }); it("tools with no-dropzone display mode have a ResultsPanel", () => { + const selfContainedTools = new Set(["meme-generator"]); for (const [toolId, entry] of toolRegistry) { - if (entry.displayMode === "no-dropzone") { + if (entry.displayMode === "no-dropzone" && !selfContainedTools.has(toolId)) { expect(entry.ResultsPanel, `missing ResultsPanel for ${toolId}`).toBeDefined(); } } diff --git a/vitest.config.ts b/vitest.config.ts index 5850b43e..5c88c66c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -107,6 +107,7 @@ export default defineConfig({ jsqr: path.join(apiNodeModules, "jsqr"), pdfkit: path.join(apiNodeModules, "pdfkit"), sharp: path.join(apiNodeModules, "sharp"), + "opentype.js": path.join(apiNodeModules, "opentype.js"), react: path.join(webNodeModules, "react"), "react-dom": path.join(webNodeModules, "react-dom"), "react-router-dom": path.join(webNodeModules, "react-router-dom"),