mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(meme-generator): add 100 meme templates with manifest and asset pipeline
Includes full-size images, WebP thumbnails, curated text box positions for 18 popular templates, and default top/bottom layout for the rest.
This commit is contained in:
@@ -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);
|
||||
@@ -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<ImgflipMeme[]> {
|
||||
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<void> {
|
||||
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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user