scripts/generate_meme.py
scripts/generate_meme.pyBrowse 5 files
4,441 tokens
16,629 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""Generate a meme image by overlaying text on a template.3 4Usage:5 python generate_meme.py <template_id_or_name> <output_path> <text1> [text2] [text3] [text4]6 7Example:8 python generate_meme.py drake /tmp/meme.png "Writing tests" "Shipping to prod and hoping"9 python generate_meme.py "Disaster Girl" /tmp/meme.png "Top text" "Bottom text"10 python generate_meme.py --list # show curated templates11 python generate_meme.py --search "distracted" # search all imgflip templates12 13Templates with custom text positioning are in templates.json (10 curated).14Any of the ~100 popular imgflip templates can also be used by name or ID —15unknown templates get smart default text positioning based on their box_count.16"""17 18import json19import os20import sys21from io import BytesIO22from pathlib import Path23 24try:25 import requests as _requests26except ImportError:27 _requests = None28 29from PIL import Image, ImageDraw, ImageFont30 31SCRIPT_DIR = Path(__file__).parent32TEMPLATES_FILE = SCRIPT_DIR / "templates.json"33CACHE_DIR = SCRIPT_DIR / ".cache"34IMGFLIP_API = "https://api.imgflip.com/get_memes"35IMGFLIP_CACHE_FILE = CACHE_DIR / "imgflip_memes.json"36IMGFLIP_CACHE_MAX_AGE = 86400 # 24 hours37 38 39def _fetch_url(url: str, timeout: int = 15) -> bytes:40 """Fetch URL content, using requests if available, else urllib."""41 if _requests is not None:42 resp = _requests.get(url, timeout=timeout)43 resp.raise_for_status()44 return resp.content45 import urllib.request46 return urllib.request.urlopen(url, timeout=timeout).read()47 48 49def load_curated_templates() -> dict:50 """Load templates with hand-tuned text field positions."""51 with open(TEMPLATES_FILE) as f:52 return json.load(f)53 54 55def _default_fields(box_count: int) -> list:56 """Generate sensible default text field positions for unknown templates."""57 if box_count <= 0:58 box_count = 259 if box_count == 1:60 return [{"name": "text", "x_pct": 0.5, "y_pct": 0.5, "w_pct": 0.90, "align": "center"}]61 if box_count == 2:62 return [63 {"name": "top", "x_pct": 0.5, "y_pct": 0.08, "w_pct": 0.95, "align": "center"},64 {"name": "bottom", "x_pct": 0.5, "y_pct": 0.92, "w_pct": 0.95, "align": "center"},65 ]66 # 3+: evenly space vertically67 fields = []68 for i in range(box_count):69 y = 0.08 + (0.84 * i / (box_count - 1)) if box_count > 1 else 0.570 fields.append({71 "name": f"text{i+1}",72 "x_pct": 0.5,73 "y_pct": round(y, 2),74 "w_pct": 0.90,75 "align": "center",76 })77 return fields78 79 80def fetch_imgflip_templates() -> list:81 """Fetch popular meme templates from imgflip API. Cached for 24h."""82 import time83 84 CACHE_DIR.mkdir(exist_ok=True)85 # Check cache86 if IMGFLIP_CACHE_FILE.exists():87 age = time.time() - IMGFLIP_CACHE_FILE.stat().st_mtime88 if age < IMGFLIP_CACHE_MAX_AGE:89 with open(IMGFLIP_CACHE_FILE) as f:90 return json.load(f)91 92 try:93 data = json.loads(_fetch_url(IMGFLIP_API))94 memes = data.get("data", {}).get("memes", [])95 with open(IMGFLIP_CACHE_FILE, "w") as f:96 json.dump(memes, f)97 return memes98 except Exception as e:99 # If fetch fails and we have stale cache, use it100 if IMGFLIP_CACHE_FILE.exists():101 with open(IMGFLIP_CACHE_FILE) as f:102 return json.load(f)103 print(f"Warning: could not fetch imgflip templates: {e}", file=sys.stderr)104 return []105 106 107def _slugify(name: str) -> str:108 """Convert a template name to a slug for matching."""109 return name.lower().replace(" ", "-").replace("'", "").replace("\"", "")110 111 112def resolve_template(identifier: str) -> dict:113 """Resolve a template by curated ID, imgflip name, or imgflip ID.114 115 Returns dict with: name, url, fields, source.116 """117 curated = load_curated_templates()118 119 # 1. Exact curated ID match120 if identifier in curated:121 tmpl = curated[identifier]122 return {**tmpl, "source": "curated"}123 124 # 2. Slugified curated match125 slug = _slugify(identifier)126 for tid, tmpl in curated.items():127 if _slugify(tmpl["name"]) == slug or tid == slug:128 return {**tmpl, "source": "curated"}129 130 # 3. Search imgflip templates131 imgflip_memes = fetch_imgflip_templates()132 slug_lower = slug.lower()133 id_lower = identifier.strip()134 135 for meme in imgflip_memes:136 meme_slug = _slugify(meme["name"])137 # Check curated first for this imgflip template (custom positioning)138 for tid, ctmpl in curated.items():139 if _slugify(ctmpl["name"]) == meme_slug:140 if meme_slug == slug_lower or meme["id"] == id_lower:141 return {**ctmpl, "source": "curated"}142 143 if meme_slug == slug_lower or meme["id"] == id_lower or slug_lower in meme_slug:144 return {145 "name": meme["name"],146 "url": meme["url"],147 "fields": _default_fields(meme.get("box_count", 2)),148 "source": "imgflip",149 }150 151 return None152 153 154def get_template_image(url: str) -> Image.Image:155 """Download a template image, caching it locally."""156 CACHE_DIR.mkdir(exist_ok=True)157 # Use URL hash as cache key158 cache_name = url.split("/")[-1]159 cache_path = CACHE_DIR / cache_name160 161 # Always cache as PNG to avoid JPEG/RGBA conflicts162 cache_path = cache_path.with_suffix(".png")163 164 if cache_path.exists():165 return Image.open(cache_path).convert("RGBA")166 167 data = _fetch_url(url)168 img = Image.open(BytesIO(data)).convert("RGBA")169 img.save(cache_path, "PNG")170 return img171 172 173def find_font(size: int) -> ImageFont.FreeTypeFont:174 """Find a bold font for meme text. Tries Impact, then falls back."""175 candidates = [176 "/usr/share/fonts/truetype/msttcorefonts/Impact.ttf",177 "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",178 "/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf",179 "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",180 "/usr/share/fonts/dejavu-sans/DejaVuSans-Bold.ttf",181 "/System/Library/Fonts/Helvetica.ttc",182 "/System/Library/Fonts/SFCompact.ttf",183 ]184 for path in candidates:185 if os.path.exists(path):186 try:187 return ImageFont.truetype(path, size)188 except (OSError, IOError):189 continue190 # Last resort: Pillow default191 try:192 return ImageFont.truetype("DejaVuSans-Bold", size)193 except (OSError, IOError):194 return ImageFont.load_default()195 196 197def _wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> str:198 """Word-wrap text to fit within max_width pixels. Never breaks mid-word."""199 words = text.split()200 if not words:201 return text202 lines = []203 current_line = words[0]204 for word in words[1:]:205 test_line = current_line + " " + word206 if font.getlength(test_line) <= max_width:207 current_line = test_line208 else:209 lines.append(current_line)210 current_line = word211 lines.append(current_line)212 return "\n".join(lines)213 214 215def draw_outlined_text(216 draw: ImageDraw.ImageDraw,217 text: str,218 x: int,219 y: int,220 font_size: int,221 max_width: int,222 align: str = "center",223):224 """Draw white text with black outline, auto-scaled to fit max_width."""225 # Auto-scale: reduce font size until text fits reasonably226 size = font_size227 while size > 12:228 font = find_font(size)229 wrapped = _wrap_text(text, font, max_width)230 bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, align=align)231 text_w = bbox[2] - bbox[0]232 line_count = wrapped.count("\n") + 1233 # Accept if width fits and not too many lines234 if text_w <= max_width * 1.05 and line_count <= 4:235 break236 size -= 2237 else:238 font = find_font(size)239 wrapped = _wrap_text(text, font, max_width)240 241 # Measure total text block242 bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, align=align)243 text_w = bbox[2] - bbox[0]244 text_h = bbox[3] - bbox[1]245 246 # Center horizontally at x, vertically at y247 tx = x - text_w // 2248 ty = y - text_h // 2249 250 # Draw outline (black border)251 outline_range = max(2, font.size // 18)252 for dx in range(-outline_range, outline_range + 1):253 for dy in range(-outline_range, outline_range + 1):254 if dx == 0 and dy == 0:255 continue256 draw.multiline_text(257 (tx + dx, ty + dy), wrapped, font=font, fill="black", align=align258 )259 # Draw main text (white)260 draw.multiline_text((tx, ty), wrapped, font=font, fill="white", align=align)261 262 263def _overlay_on_image(img: Image.Image, texts: list, fields: list) -> Image.Image:264 """Overlay meme text directly on an image using field positions."""265 draw = ImageDraw.Draw(img)266 w, h = img.size267 base_font_size = max(16, min(w, h) // 12)268 269 for i, field in enumerate(fields):270 if i >= len(texts):271 break272 text = texts[i].strip()273 if not text:274 continue275 fx = int(field["x_pct"] * w)276 fy = int(field["y_pct"] * h)277 fw = int(field["w_pct"] * w)278 draw_outlined_text(draw, text, fx, fy, base_font_size, fw, field.get("align", "center"))279 return img280 281 282def _add_bars(img: Image.Image, texts: list) -> Image.Image:283 """Add black bars with white text above/below the image.284 285 Distributes texts across bars: first text on top bar, last text on286 bottom bar, any middle texts overlaid on the image center.287 """288 w, h = img.size289 bar_font_size = max(20, w // 16)290 font = find_font(bar_font_size)291 padding = bar_font_size // 2292 293 top_text = texts[0].strip() if texts else ""294 bottom_text = texts[-1].strip() if len(texts) > 1 else ""295 middle_texts = [t.strip() for t in texts[1:-1]] if len(texts) > 2 else []296 297 def _measure_bar(text: str) -> int:298 if not text:299 return 0300 wrapped = _wrap_text(text, font, int(w * 0.92))301 bbox = ImageDraw.Draw(Image.new("RGB", (1, 1))).multiline_textbbox(302 (0, 0), wrapped, font=font, align="center"303 )304 return (bbox[3] - bbox[1]) + padding * 2305 306 top_h = _measure_bar(top_text)307 bottom_h = _measure_bar(bottom_text)308 new_h = h + top_h + bottom_h309 310 canvas = Image.new("RGB", (w, new_h), (0, 0, 0))311 canvas.paste(img.convert("RGB"), (0, top_h))312 draw = ImageDraw.Draw(canvas)313 314 if top_text:315 wrapped = _wrap_text(top_text, font, int(w * 0.92))316 bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, align="center")317 tw = bbox[2] - bbox[0]318 th = bbox[3] - bbox[1]319 tx = (w - tw) // 2320 ty = (top_h - th) // 2321 draw.multiline_text((tx, ty), wrapped, font=font, fill="white", align="center")322 323 if bottom_text:324 wrapped = _wrap_text(bottom_text, font, int(w * 0.92))325 bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, align="center")326 tw = bbox[2] - bbox[0]327 th = bbox[3] - bbox[1]328 tx = (w - tw) // 2329 ty = top_h + h + (bottom_h - th) // 2330 draw.multiline_text((tx, ty), wrapped, font=font, fill="white", align="center")331 332 # Overlay any middle texts centered on the image333 if middle_texts:334 mid_fields = _default_fields(len(middle_texts))335 # Shift y positions to account for top bar offset336 for field in mid_fields:337 field["y_pct"] = (top_h + field["y_pct"] * h) / new_h338 field["w_pct"] = 0.90339 _overlay_on_image(canvas, middle_texts, mid_fields)340 341 return canvas342 343 344def generate_meme(template_id: str, texts: list[str], output_path: str) -> str:345 """Generate a meme from a template and save it. Returns the path."""346 tmpl = resolve_template(template_id)347 348 if tmpl is None:349 print(f"Unknown template: {template_id}", file=sys.stderr)350 print("Use --list to see curated templates or --search to find imgflip templates.", file=sys.stderr)351 sys.exit(1)352 353 fields = tmpl["fields"]354 print(f"Using template: {tmpl['name']} ({tmpl['source']}, {len(fields)} fields)", file=sys.stderr)355 356 img = get_template_image(tmpl["url"])357 img = _overlay_on_image(img, texts, fields)358 359 output = Path(output_path)360 if output.suffix.lower() in {".jpg", ".jpeg"}:361 img = img.convert("RGB")362 img.save(str(output), quality=95)363 return str(output)364 365 366def generate_from_image(367 image_path: str, texts: list[str], output_path: str, use_bars: bool = False368) -> str:369 """Generate a meme from a custom image (e.g. AI-generated). Returns the path."""370 img = Image.open(image_path).convert("RGBA")371 print(f"Custom image: {img.size[0]}x{img.size[1]}, {len(texts)} text(s), mode={'bars' if use_bars else 'overlay'}", file=sys.stderr)372 373 if use_bars:374 result = _add_bars(img, texts)375 else:376 fields = _default_fields(len(texts))377 result = _overlay_on_image(img, texts, fields)378 379 output = Path(output_path)380 if output.suffix.lower() in {".jpg", ".jpeg"}:381 result = result.convert("RGB")382 result.save(str(output), quality=95)383 return str(output)384 385 386def list_templates():387 """Print curated templates with custom positioning."""388 templates = load_curated_templates()389 print(f"{'ID':<25} {'Name':<30} {'Fields':<8} Best for")390 print("-" * 90)391 for tid, tmpl in sorted(templates.items()):392 fields = len(tmpl["fields"])393 print(f"{tid:<25} {tmpl['name']:<30} {fields:<8} {tmpl['best_for']}")394 print(f"\n{len(templates)} curated templates with custom text positioning.")395 print("Use --search to find any of the ~100 popular imgflip templates.")396 397 398def search_templates(query: str):399 """Search imgflip templates by name."""400 imgflip_memes = fetch_imgflip_templates()401 curated = load_curated_templates()402 curated_slugs = {_slugify(t["name"]) for t in curated.values()}403 query_lower = query.lower()404 405 matches = []406 for meme in imgflip_memes:407 if query_lower in meme["name"].lower():408 slug = _slugify(meme["name"])409 has_custom = "curated" if slug in curated_slugs else "default"410 matches.append((meme["name"], meme["id"], meme.get("box_count", 2), has_custom))411 412 if not matches:413 print(f"No templates found matching '{query}'")414 return415 416 print(f"{'Name':<40} {'ID':<12} {'Fields':<8} Positioning")417 print("-" * 75)418 for name, mid, boxes, positioning in matches:419 print(f"{name:<40} {mid:<12} {boxes:<8} {positioning}")420 print(f"\n{len(matches)} template(s) found. Use the name or ID as the first argument.")421 422 423if __name__ == "__main__":424 if len(sys.argv) < 2:425 print("Usage: generate_meme.py <template_id_or_name> <output_path> <text1> [text2] ...")426 print(" generate_meme.py --image <path> [--bars] <output_path> <text1> [text2] ...")427 print(" generate_meme.py --list # curated templates")428 print(" generate_meme.py --search <query> # search all imgflip templates")429 sys.exit(1)430 431 if sys.argv[1] == "--list":432 list_templates()433 sys.exit(0)434 435 if sys.argv[1] == "--search":436 if len(sys.argv) < 3:437 print("Usage: generate_meme.py --search <query>")438 sys.exit(1)439 search_templates(sys.argv[2])440 sys.exit(0)441 442 if sys.argv[1] == "--image":443 # Custom image mode: --image <path> [--bars] <output> <text1> ...444 args = sys.argv[2:]445 if len(args) < 3:446 print("Usage: generate_meme.py --image <image_path> [--bars] <output_path> <text1> ...")447 sys.exit(1)448 image_path = args.pop(0)449 use_bars = False450 if args and args[0] == "--bars":451 use_bars = True452 args.pop(0)453 if len(args) < 2:454 print("Need at least: output_path and one text argument")455 sys.exit(1)456 output_path = args.pop(0)457 result = generate_from_image(image_path, args, output_path, use_bars=use_bars)458 print(f"Meme saved to: {result}")459 sys.exit(0)460 461 if len(sys.argv) < 4:462 print("Need at least: template_id_or_name, output_path, and one text argument")463 sys.exit(1)464 465 template_id = sys.argv[1]466 output_path = sys.argv[2]467 texts = sys.argv[3:]468 469 result = generate_meme(template_id, texts, output_path)470 print(f"Meme saved to: {result}")471