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:
parent
2988334fe5
commit
1f1fa71d0c
5 changed files with 744 additions and 0 deletions
46
optional-skills/creative/meme-generation/EXAMPLES.md
Normal file
46
optional-skills/creative/meme-generation/EXAMPLES.md
Normal 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"
|
||||||
|
```
|
||||||
129
optional-skills/creative/meme-generation/SKILL.md
Normal file
129
optional-skills/creative/meme-generation/SKILL.md
Normal 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
|
||||||
1
optional-skills/creative/meme-generation/scripts/.gitignore
vendored
Normal file
1
optional-skills/creative/meme-generation/scripts/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
.cache/
|
||||||
|
|
@ -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}")
|
||||||
|
|
@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue