feat(skill): meme-generation — real image generator with Pillow (#2344)

* feat: add meme-generation skill

* Reduce meme skill prompt cost with tighter selection rules

* feat(skill): overhaul meme-generation into real image generator

Move from skills/creative/ to optional-skills/creative/ (niche skill,
not needed by default). Replace prompt-only meme concept brainstormer
with actual meme image generation:

- Python script using Pillow to overlay text on template images
- 10 curated templates with hand-tuned text positioning
- Dynamic access to ~100 popular imgflip templates via public API
- Custom image mode (--image): use AI-generated or any image as base
- Two text modes: overlay (white+outline on image) or bars (black bars)
- Vision verification workflow: use vision_analyze to QA the result
- Auto-scaling font with pixel-accurate word wrapping
- Template search via --search
- No API keys required

Original skill concept by adanaleycio (PR #1771), overhauled with
image generation and custom image support.

---------

Co-authored-by: adanaleycio <atillababa767@gmail.com>
This commit is contained in:
Teknium
2026-03-21 12:48:57 -07:00
committed by GitHub
parent 2988334fe5
commit 1f1fa71d0c
5 changed files with 744 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
# Meme Generation Examples
## Example 1: Debugging at 2 AM
**Topic:** debugging production at 2 AM
**Template:** this-is-fine
```bash
python generate_meme.py this-is-fine /tmp/meme.png "PRODUCTION IS DOWN" "This is fine"
```
## Example 2: Developer Priorities
**Topic:** choosing between writing tests and shipping features
**Template:** drake
```bash
python generate_meme.py drake /tmp/meme.png "Writing unit tests" "Shipping straight to prod"
```
## Example 3: Exam Stress
**Topic:** final exam preparation
**Template:** two-buttons
```bash
python generate_meme.py two-buttons /tmp/meme.png "Study everything" "Sleep" "Me at midnight"
```
## Example 4: Escalating Solutions
**Topic:** fixing a CSS bug
**Template:** expanding-brain
```bash
python generate_meme.py expanding-brain /tmp/meme.png "Reading the docs" "Stack Overflow" "!important on everything" "Deleting the stylesheet"
```
## Example 5: Hot Take
**Topic:** tabs vs spaces
**Template:** change-my-mind
```bash
python generate_meme.py change-my-mind /tmp/meme.png "Tabs are just thicc spaces"
```

View File

@@ -0,0 +1,129 @@
---
name: meme-generation
description: Generate real meme images by picking a template and overlaying text with Pillow. Produces actual .png meme files.
version: 2.0.0
author: adanaleycio
license: MIT
metadata:
hermes:
tags: [creative, memes, humor, images]
related_skills: [ascii-art, generative-widgets]
category: creative
---
# Meme Generation
Generate actual meme images from a topic. Picks a template, writes captions, and renders a real .png file with text overlay.
## When to Use
- User asks you to make or generate a meme
- User wants a meme about a specific topic, situation, or frustration
- User says "meme this" or similar
## Available Templates
The script supports **any of the ~100 popular imgflip templates** by name or ID, plus 10 curated templates with hand-tuned text positioning.
### Curated Templates (custom text placement)
| ID | Name | Fields | Best for |
|----|------|--------|----------|
| `this-is-fine` | This is Fine | top, bottom | chaos, denial |
| `drake` | Drake Hotline Bling | reject, approve | rejecting/preferring |
| `distracted-boyfriend` | Distracted Boyfriend | distraction, current, person | temptation, shifting priorities |
| `two-buttons` | Two Buttons | left, right, person | impossible choice |
| `expanding-brain` | Expanding Brain | 4 levels | escalating irony |
| `change-my-mind` | Change My Mind | statement | hot takes |
| `woman-yelling-at-cat` | Woman Yelling at Cat | woman, cat | arguments |
| `one-does-not-simply` | One Does Not Simply | top, bottom | deceptively hard things |
| `grus-plan` | Gru's Plan | step1-3, realization | plans that backfire |
| `batman-slapping-robin` | Batman Slapping Robin | robin, batman | shutting down bad ideas |
### Dynamic Templates (from imgflip API)
Any template not in the curated list can be used by name or imgflip ID. These get smart default text positioning (top/bottom for 2-field, evenly spaced for 3+). Search with:
```bash
python "$SKILL_DIR/scripts/generate_meme.py" --search "disaster"
```
## Procedure
### Mode 1: Classic Template (default)
1. Read the user's topic and identify the core dynamic (chaos, dilemma, preference, irony, etc.)
2. Pick the template that best matches. Use the "Best for" column, or search with `--search`.
3. Write short captions for each field (8-12 words max per field, shorter is better).
4. Find the skill's script directory:
```
SKILL_DIR=$(dirname "$(find ~/.hermes/skills -path '*/meme-generation/SKILL.md' 2>/dev/null | head -1)")
```
5. Run the generator:
```bash
python "$SKILL_DIR/scripts/generate_meme.py" <template_id> /tmp/meme.png "caption 1" "caption 2" ...
```
6. Return the image with `MEDIA:/tmp/meme.png`
### Mode 2: Custom AI Image (when image_generate is available)
Use this when no classic template fits, or when the user wants something original.
1. Write the captions first.
2. Use `image_generate` to create a scene that matches the meme concept. Do NOT include any text in the image prompt — text will be added by the script. Describe only the visual scene.
3. Find the generated image path from the image_generate result URL. Download it to a local path if needed.
4. Run the script with `--image` to overlay text, choosing a mode:
- **Overlay** (text directly on image, white with black outline):
```bash
python "$SKILL_DIR/scripts/generate_meme.py" --image /path/to/scene.png /tmp/meme.png "top text" "bottom text"
```
- **Bars** (black bars above/below with white text — cleaner, always readable):
```bash
python "$SKILL_DIR/scripts/generate_meme.py" --image /path/to/scene.png --bars /tmp/meme.png "top text" "bottom text"
```
Use `--bars` when the image is busy/detailed and text would be hard to read on top of it.
5. **Verify with vision** (if `vision_analyze` is available): Check the result looks good:
```
vision_analyze(image_url="/tmp/meme.png", question="Is the text legible and well-positioned? Does the meme work visually?")
```
If the vision model flags issues (text hard to read, bad placement, etc.), try the other mode (switch between overlay and bars) or regenerate the scene.
6. Return the image with `MEDIA:/tmp/meme.png`
## Examples
**"debugging production at 2 AM":**
```bash
python generate_meme.py this-is-fine /tmp/meme.png "SERVERS ARE ON FIRE" "This is fine"
```
**"choosing between sleep and one more episode":**
```bash
python generate_meme.py drake /tmp/meme.png "Getting 8 hours of sleep" "One more episode at 3 AM"
```
**"the stages of a Monday morning":**
```bash
python generate_meme.py expanding-brain /tmp/meme.png "Setting an alarm" "Setting 5 alarms" "Sleeping through all alarms" "Working from bed"
```
## Listing Templates
To see all available templates:
```bash
python generate_meme.py --list
```
## Pitfalls
- Keep captions SHORT. Memes with long text look terrible.
- Match the number of text arguments to the template's field count.
- Pick the template that fits the joke structure, not just the topic.
- Do not generate hateful, abusive, or personally targeted content.
- The script caches template images in `scripts/.cache/` after first download.
## Verification
The output is correct if:
- A .png file was created at the output path
- Text is legible (white with black outline) on the template
- The joke lands — caption matches the template's intended structure
- File can be delivered via MEDIA: path

View File

@@ -0,0 +1 @@
.cache/

View File

@@ -0,0 +1,471 @@
#!/usr/bin/env python3
"""Generate a meme image by overlaying text on a template.
Usage:
python generate_meme.py <template_id_or_name> <output_path> <text1> [text2] [text3] [text4]
Example:
python generate_meme.py drake /tmp/meme.png "Writing tests" "Shipping to prod and hoping"
python generate_meme.py "Disaster Girl" /tmp/meme.png "Top text" "Bottom text"
python generate_meme.py --list # show curated templates
python generate_meme.py --search "distracted" # search all imgflip templates
Templates with custom text positioning are in templates.json (10 curated).
Any of the ~100 popular imgflip templates can also be used by name or ID —
unknown templates get smart default text positioning based on their box_count.
"""
import json
import os
import sys
import textwrap
from io import BytesIO
from pathlib import Path
try:
import requests as _requests
except ImportError:
_requests = None
from PIL import Image, ImageDraw, ImageFont
SCRIPT_DIR = Path(__file__).parent
TEMPLATES_FILE = SCRIPT_DIR / "templates.json"
CACHE_DIR = SCRIPT_DIR / ".cache"
IMGFLIP_API = "https://api.imgflip.com/get_memes"
IMGFLIP_CACHE_FILE = CACHE_DIR / "imgflip_memes.json"
IMGFLIP_CACHE_MAX_AGE = 86400 # 24 hours
def _fetch_url(url: str, timeout: int = 15) -> bytes:
"""Fetch URL content, using requests if available, else urllib."""
if _requests is not None:
resp = _requests.get(url, timeout=timeout)
resp.raise_for_status()
return resp.content
import urllib.request
return urllib.request.urlopen(url, timeout=timeout).read()
def load_curated_templates() -> dict:
"""Load templates with hand-tuned text field positions."""
with open(TEMPLATES_FILE) as f:
return json.load(f)
def _default_fields(box_count: int) -> list:
"""Generate sensible default text field positions for unknown templates."""
if box_count <= 0:
box_count = 2
if box_count == 1:
return [{"name": "text", "x_pct": 0.5, "y_pct": 0.5, "w_pct": 0.90, "align": "center"}]
if box_count == 2:
return [
{"name": "top", "x_pct": 0.5, "y_pct": 0.08, "w_pct": 0.95, "align": "center"},
{"name": "bottom", "x_pct": 0.5, "y_pct": 0.92, "w_pct": 0.95, "align": "center"},
]
# 3+: evenly space vertically
fields = []
for i in range(box_count):
y = 0.08 + (0.84 * i / (box_count - 1)) if box_count > 1 else 0.5
fields.append({
"name": f"text{i+1}",
"x_pct": 0.5,
"y_pct": round(y, 2),
"w_pct": 0.90,
"align": "center",
})
return fields
def fetch_imgflip_templates() -> list:
"""Fetch popular meme templates from imgflip API. Cached for 24h."""
import time
CACHE_DIR.mkdir(exist_ok=True)
# Check cache
if IMGFLIP_CACHE_FILE.exists():
age = time.time() - IMGFLIP_CACHE_FILE.stat().st_mtime
if age < IMGFLIP_CACHE_MAX_AGE:
with open(IMGFLIP_CACHE_FILE) as f:
return json.load(f)
try:
data = json.loads(_fetch_url(IMGFLIP_API))
memes = data.get("data", {}).get("memes", [])
with open(IMGFLIP_CACHE_FILE, "w") as f:
json.dump(memes, f)
return memes
except Exception as e:
# If fetch fails and we have stale cache, use it
if IMGFLIP_CACHE_FILE.exists():
with open(IMGFLIP_CACHE_FILE) as f:
return json.load(f)
print(f"Warning: could not fetch imgflip templates: {e}", file=sys.stderr)
return []
def _slugify(name: str) -> str:
"""Convert a template name to a slug for matching."""
return name.lower().replace(" ", "-").replace("'", "").replace("\"", "")
def resolve_template(identifier: str) -> dict:
"""Resolve a template by curated ID, imgflip name, or imgflip ID.
Returns dict with: name, url, fields, source.
"""
curated = load_curated_templates()
# 1. Exact curated ID match
if identifier in curated:
tmpl = curated[identifier]
return {**tmpl, "source": "curated"}
# 2. Slugified curated match
slug = _slugify(identifier)
for tid, tmpl in curated.items():
if _slugify(tmpl["name"]) == slug or tid == slug:
return {**tmpl, "source": "curated"}
# 3. Search imgflip templates
imgflip_memes = fetch_imgflip_templates()
slug_lower = slug.lower()
id_lower = identifier.strip()
for meme in imgflip_memes:
meme_slug = _slugify(meme["name"])
# Check curated first for this imgflip template (custom positioning)
for tid, ctmpl in curated.items():
if _slugify(ctmpl["name"]) == meme_slug:
if meme_slug == slug_lower or meme["id"] == id_lower:
return {**ctmpl, "source": "curated"}
if meme_slug == slug_lower or meme["id"] == id_lower or slug_lower in meme_slug:
return {
"name": meme["name"],
"url": meme["url"],
"fields": _default_fields(meme.get("box_count", 2)),
"source": "imgflip",
}
return None
def get_template_image(url: str) -> Image.Image:
"""Download a template image, caching it locally."""
CACHE_DIR.mkdir(exist_ok=True)
# Use URL hash as cache key
cache_name = url.split("/")[-1]
cache_path = CACHE_DIR / cache_name
# Always cache as PNG to avoid JPEG/RGBA conflicts
cache_path = cache_path.with_suffix(".png")
if cache_path.exists():
return Image.open(cache_path).convert("RGBA")
data = _fetch_url(url)
img = Image.open(BytesIO(data)).convert("RGBA")
img.save(cache_path, "PNG")
return img
def find_font(size: int) -> ImageFont.FreeTypeFont:
"""Find a bold font for meme text. Tries Impact, then falls back."""
candidates = [
"/usr/share/fonts/truetype/msttcorefonts/Impact.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu-sans/DejaVuSans-Bold.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/SFCompact.ttf",
]
for path in candidates:
if os.path.exists(path):
try:
return ImageFont.truetype(path, size)
except (OSError, IOError):
continue
# Last resort: Pillow default
try:
return ImageFont.truetype("DejaVuSans-Bold", size)
except (OSError, IOError):
return ImageFont.load_default()
def _wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> str:
"""Word-wrap text to fit within max_width pixels. Never breaks mid-word."""
words = text.split()
if not words:
return text
lines = []
current_line = words[0]
for word in words[1:]:
test_line = current_line + " " + word
if font.getlength(test_line) <= max_width:
current_line = test_line
else:
lines.append(current_line)
current_line = word
lines.append(current_line)
return "\n".join(lines)
def draw_outlined_text(
draw: ImageDraw.ImageDraw,
text: str,
x: int,
y: int,
font_size: int,
max_width: int,
align: str = "center",
):
"""Draw white text with black outline, auto-scaled to fit max_width."""
# Auto-scale: reduce font size until text fits reasonably
size = font_size
while size > 12:
font = find_font(size)
wrapped = _wrap_text(text, font, max_width)
bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, align=align)
text_w = bbox[2] - bbox[0]
line_count = wrapped.count("\n") + 1
# Accept if width fits and not too many lines
if text_w <= max_width * 1.05 and line_count <= 4:
break
size -= 2
else:
font = find_font(size)
wrapped = _wrap_text(text, font, max_width)
# Measure total text block
bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, align=align)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
# Center horizontally at x, vertically at y
tx = x - text_w // 2
ty = y - text_h // 2
# Draw outline (black border)
outline_range = max(2, font.size // 18)
for dx in range(-outline_range, outline_range + 1):
for dy in range(-outline_range, outline_range + 1):
if dx == 0 and dy == 0:
continue
draw.multiline_text(
(tx + dx, ty + dy), wrapped, font=font, fill="black", align=align
)
# Draw main text (white)
draw.multiline_text((tx, ty), wrapped, font=font, fill="white", align=align)
def _overlay_on_image(img: Image.Image, texts: list, fields: list) -> Image.Image:
"""Overlay meme text directly on an image using field positions."""
draw = ImageDraw.Draw(img)
w, h = img.size
base_font_size = max(16, min(w, h) // 12)
for i, field in enumerate(fields):
if i >= len(texts):
break
text = texts[i].strip()
if not text:
continue
fx = int(field["x_pct"] * w)
fy = int(field["y_pct"] * h)
fw = int(field["w_pct"] * w)
draw_outlined_text(draw, text, fx, fy, base_font_size, fw, field.get("align", "center"))
return img
def _add_bars(img: Image.Image, texts: list) -> Image.Image:
"""Add black bars with white text above/below the image.
Distributes texts across bars: first text on top bar, last text on
bottom bar, any middle texts overlaid on the image center.
"""
w, h = img.size
bar_font_size = max(20, w // 16)
font = find_font(bar_font_size)
padding = bar_font_size // 2
top_text = texts[0].strip() if texts else ""
bottom_text = texts[-1].strip() if len(texts) > 1 else ""
middle_texts = [t.strip() for t in texts[1:-1]] if len(texts) > 2 else []
def _measure_bar(text: str) -> int:
if not text:
return 0
wrapped = _wrap_text(text, font, int(w * 0.92))
bbox = ImageDraw.Draw(Image.new("RGB", (1, 1))).multiline_textbbox(
(0, 0), wrapped, font=font, align="center"
)
return (bbox[3] - bbox[1]) + padding * 2
top_h = _measure_bar(top_text)
bottom_h = _measure_bar(bottom_text)
new_h = h + top_h + bottom_h
canvas = Image.new("RGB", (w, new_h), (0, 0, 0))
canvas.paste(img.convert("RGB"), (0, top_h))
draw = ImageDraw.Draw(canvas)
if top_text:
wrapped = _wrap_text(top_text, font, int(w * 0.92))
bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, align="center")
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
tx = (w - tw) // 2
ty = (top_h - th) // 2
draw.multiline_text((tx, ty), wrapped, font=font, fill="white", align="center")
if bottom_text:
wrapped = _wrap_text(bottom_text, font, int(w * 0.92))
bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, align="center")
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
tx = (w - tw) // 2
ty = top_h + h + (bottom_h - th) // 2
draw.multiline_text((tx, ty), wrapped, font=font, fill="white", align="center")
# Overlay any middle texts centered on the image
if middle_texts:
mid_fields = _default_fields(len(middle_texts))
# Shift y positions to account for top bar offset
for field in mid_fields:
field["y_pct"] = (top_h + field["y_pct"] * h) / new_h
field["w_pct"] = 0.90
_overlay_on_image(canvas, middle_texts, mid_fields)
return canvas
def generate_meme(template_id: str, texts: list[str], output_path: str) -> str:
"""Generate a meme from a template and save it. Returns the path."""
tmpl = resolve_template(template_id)
if tmpl is None:
print(f"Unknown template: {template_id}", file=sys.stderr)
print("Use --list to see curated templates or --search to find imgflip templates.", file=sys.stderr)
sys.exit(1)
fields = tmpl["fields"]
print(f"Using template: {tmpl['name']} ({tmpl['source']}, {len(fields)} fields)", file=sys.stderr)
img = get_template_image(tmpl["url"])
img = _overlay_on_image(img, texts, fields)
output = Path(output_path)
if output.suffix.lower() in (".jpg", ".jpeg"):
img = img.convert("RGB")
img.save(str(output), quality=95)
return str(output)
def generate_from_image(
image_path: str, texts: list[str], output_path: str, use_bars: bool = False
) -> str:
"""Generate a meme from a custom image (e.g. AI-generated). Returns the path."""
img = Image.open(image_path).convert("RGBA")
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)
if use_bars:
result = _add_bars(img, texts)
else:
fields = _default_fields(len(texts))
result = _overlay_on_image(img, texts, fields)
output = Path(output_path)
if output.suffix.lower() in (".jpg", ".jpeg"):
result = result.convert("RGB")
result.save(str(output), quality=95)
return str(output)
def list_templates():
"""Print curated templates with custom positioning."""
templates = load_curated_templates()
print(f"{'ID':<25} {'Name':<30} {'Fields':<8} Best for")
print("-" * 90)
for tid, tmpl in sorted(templates.items()):
fields = len(tmpl["fields"])
print(f"{tid:<25} {tmpl['name']:<30} {fields:<8} {tmpl['best_for']}")
print(f"\n{len(templates)} curated templates with custom text positioning.")
print("Use --search to find any of the ~100 popular imgflip templates.")
def search_templates(query: str):
"""Search imgflip templates by name."""
imgflip_memes = fetch_imgflip_templates()
curated = load_curated_templates()
curated_slugs = {_slugify(t["name"]) for t in curated.values()}
query_lower = query.lower()
matches = []
for meme in imgflip_memes:
if query_lower in meme["name"].lower():
slug = _slugify(meme["name"])
has_custom = "curated" if slug in curated_slugs else "default"
matches.append((meme["name"], meme["id"], meme.get("box_count", 2), has_custom))
if not matches:
print(f"No templates found matching '{query}'")
return
print(f"{'Name':<40} {'ID':<12} {'Fields':<8} Positioning")
print("-" * 75)
for name, mid, boxes, positioning in matches:
print(f"{name:<40} {mid:<12} {boxes:<8} {positioning}")
print(f"\n{len(matches)} template(s) found. Use the name or ID as the first argument.")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: generate_meme.py <template_id_or_name> <output_path> <text1> [text2] ...")
print(" generate_meme.py --image <path> [--bars] <output_path> <text1> [text2] ...")
print(" generate_meme.py --list # curated templates")
print(" generate_meme.py --search <query> # search all imgflip templates")
sys.exit(1)
if sys.argv[1] == "--list":
list_templates()
sys.exit(0)
if sys.argv[1] == "--search":
if len(sys.argv) < 3:
print("Usage: generate_meme.py --search <query>")
sys.exit(1)
search_templates(sys.argv[2])
sys.exit(0)
if sys.argv[1] == "--image":
# Custom image mode: --image <path> [--bars] <output> <text1> ...
args = sys.argv[2:]
if len(args) < 3:
print("Usage: generate_meme.py --image <image_path> [--bars] <output_path> <text1> ...")
sys.exit(1)
image_path = args.pop(0)
use_bars = False
if args and args[0] == "--bars":
use_bars = True
args.pop(0)
if len(args) < 2:
print("Need at least: output_path and one text argument")
sys.exit(1)
output_path = args.pop(0)
result = generate_from_image(image_path, args, output_path, use_bars=use_bars)
print(f"Meme saved to: {result}")
sys.exit(0)
if len(sys.argv) < 4:
print("Need at least: template_id_or_name, output_path, and one text argument")
sys.exit(1)
template_id = sys.argv[1]
output_path = sys.argv[2]
texts = sys.argv[3:]
result = generate_meme(template_id, texts, output_path)
print(f"Meme saved to: {result}")

View File

@@ -0,0 +1,97 @@
{
"this-is-fine": {
"name": "This is Fine",
"url": "https://i.imgflip.com/wxica.jpg",
"best_for": "chaos, denial, pretending things are okay",
"fields": [
{"name": "top", "x_pct": 0.5, "y_pct": 0.08, "w_pct": 0.95, "align": "center"},
{"name": "bottom", "x_pct": 0.5, "y_pct": 0.92, "w_pct": 0.95, "align": "center"}
]
},
"drake": {
"name": "Drake Hotline Bling",
"url": "https://i.imgflip.com/30b1gx.jpg",
"best_for": "rejecting one thing, preferring another",
"fields": [
{"name": "reject", "x_pct": 0.73, "y_pct": 0.25, "w_pct": 0.45, "align": "center"},
{"name": "approve", "x_pct": 0.73, "y_pct": 0.75, "w_pct": 0.45, "align": "center"}
]
},
"distracted-boyfriend": {
"name": "Distracted Boyfriend",
"url": "https://i.imgflip.com/1ur9b0.jpg",
"best_for": "distraction, shifting priorities, temptation",
"fields": [
{"name": "distraction", "x_pct": 0.18, "y_pct": 0.90, "w_pct": 0.30, "align": "center"},
{"name": "current", "x_pct": 0.55, "y_pct": 0.90, "w_pct": 0.30, "align": "center"},
{"name": "person", "x_pct": 0.82, "y_pct": 0.90, "w_pct": 0.30, "align": "center"}
]
},
"two-buttons": {
"name": "Two Buttons",
"url": "https://i.imgflip.com/1g8my4.jpg",
"best_for": "impossible choice, dilemma between two options",
"fields": [
{"name": "left_button", "x_pct": 0.30, "y_pct": 0.20, "w_pct": 0.28, "align": "center"},
{"name": "right_button", "x_pct": 0.62, "y_pct": 0.12, "w_pct": 0.28, "align": "center"},
{"name": "person", "x_pct": 0.5, "y_pct": 0.85, "w_pct": 0.90, "align": "center"}
]
},
"expanding-brain": {
"name": "Expanding Brain",
"url": "https://i.imgflip.com/1jwhww.jpg",
"best_for": "escalating irony, increasingly absurd ideas",
"fields": [
{"name": "level1", "x_pct": 0.25, "y_pct": 0.12, "w_pct": 0.45, "align": "center"},
{"name": "level2", "x_pct": 0.25, "y_pct": 0.38, "w_pct": 0.45, "align": "center"},
{"name": "level3", "x_pct": 0.25, "y_pct": 0.63, "w_pct": 0.45, "align": "center"},
{"name": "level4", "x_pct": 0.25, "y_pct": 0.88, "w_pct": 0.45, "align": "center"}
]
},
"change-my-mind": {
"name": "Change My Mind",
"url": "https://i.imgflip.com/24y43o.jpg",
"best_for": "strong or ironic opinion, controversial take",
"fields": [
{"name": "statement", "x_pct": 0.58, "y_pct": 0.78, "w_pct": 0.35, "align": "center"}
]
},
"woman-yelling-at-cat": {
"name": "Woman Yelling at Cat",
"url": "https://i.imgflip.com/345v97.jpg",
"best_for": "argument, blame, misunderstanding",
"fields": [
{"name": "woman", "x_pct": 0.27, "y_pct": 0.10, "w_pct": 0.50, "align": "center"},
{"name": "cat", "x_pct": 0.76, "y_pct": 0.10, "w_pct": 0.44, "align": "center"}
]
},
"one-does-not-simply": {
"name": "One Does Not Simply",
"url": "https://i.imgflip.com/1bij.jpg",
"best_for": "something that sounds easy but is actually hard",
"fields": [
{"name": "top", "x_pct": 0.5, "y_pct": 0.08, "w_pct": 0.95, "align": "center"},
{"name": "bottom", "x_pct": 0.5, "y_pct": 0.92, "w_pct": 0.95, "align": "center"}
]
},
"grus-plan": {
"name": "Gru's Plan",
"url": "https://i.imgflip.com/26jxvs.jpg",
"best_for": "a plan that backfires, unexpected consequence",
"fields": [
{"name": "step1", "x_pct": 0.5, "y_pct": 0.05, "w_pct": 0.45, "align": "center"},
{"name": "step2", "x_pct": 0.5, "y_pct": 0.30, "w_pct": 0.45, "align": "center"},
{"name": "step3", "x_pct": 0.5, "y_pct": 0.55, "w_pct": 0.45, "align": "center"},
{"name": "realization", "x_pct": 0.5, "y_pct": 0.80, "w_pct": 0.45, "align": "center"}
]
},
"batman-slapping-robin": {
"name": "Batman Slapping Robin",
"url": "https://i.imgflip.com/9ehk.jpg",
"best_for": "shutting down a bad idea, correcting someone",
"fields": [
{"name": "robin", "x_pct": 0.28, "y_pct": 0.08, "w_pct": 0.50, "align": "center"},
{"name": "batman", "x_pct": 0.72, "y_pct": 0.08, "w_pct": 0.50, "align": "center"}
]
}
}