From 1f1fa71d0c1e48924dd8514a515e7dd067568e36 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:48:57 -0700 Subject: [PATCH] =?UTF-8?q?feat(skill):=20meme-generation=20=E2=80=94=20re?= =?UTF-8?q?al=20image=20generator=20with=20Pillow=20(#2344)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../creative/meme-generation/EXAMPLES.md | 46 ++ .../creative/meme-generation/SKILL.md | 129 +++++ .../meme-generation/scripts/.gitignore | 1 + .../meme-generation/scripts/generate_meme.py | 471 ++++++++++++++++++ .../meme-generation/scripts/templates.json | 97 ++++ 5 files changed, 744 insertions(+) create mode 100644 optional-skills/creative/meme-generation/EXAMPLES.md create mode 100644 optional-skills/creative/meme-generation/SKILL.md create mode 100644 optional-skills/creative/meme-generation/scripts/.gitignore create mode 100644 optional-skills/creative/meme-generation/scripts/generate_meme.py create mode 100644 optional-skills/creative/meme-generation/scripts/templates.json diff --git a/optional-skills/creative/meme-generation/EXAMPLES.md b/optional-skills/creative/meme-generation/EXAMPLES.md new file mode 100644 index 000000000..2fdf77a52 --- /dev/null +++ b/optional-skills/creative/meme-generation/EXAMPLES.md @@ -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" +``` diff --git a/optional-skills/creative/meme-generation/SKILL.md b/optional-skills/creative/meme-generation/SKILL.md new file mode 100644 index 000000000..563408f4f --- /dev/null +++ b/optional-skills/creative/meme-generation/SKILL.md @@ -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" /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 diff --git a/optional-skills/creative/meme-generation/scripts/.gitignore b/optional-skills/creative/meme-generation/scripts/.gitignore new file mode 100644 index 000000000..ceddaa37f --- /dev/null +++ b/optional-skills/creative/meme-generation/scripts/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/optional-skills/creative/meme-generation/scripts/generate_meme.py b/optional-skills/creative/meme-generation/scripts/generate_meme.py new file mode 100644 index 000000000..288c38383 --- /dev/null +++ b/optional-skills/creative/meme-generation/scripts/generate_meme.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +"""Generate a meme image by overlaying text on a template. + +Usage: + python generate_meme.py [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 [text2] ...") + print(" generate_meme.py --image [--bars] [text2] ...") + print(" generate_meme.py --list # curated templates") + print(" generate_meme.py --search # 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 ") + sys.exit(1) + search_templates(sys.argv[2]) + sys.exit(0) + + if sys.argv[1] == "--image": + # Custom image mode: --image [--bars] ... + args = sys.argv[2:] + if len(args) < 3: + print("Usage: generate_meme.py --image [--bars] ...") + 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}") diff --git a/optional-skills/creative/meme-generation/scripts/templates.json b/optional-skills/creative/meme-generation/scripts/templates.json new file mode 100644 index 000000000..ad2f7828b --- /dev/null +++ b/optional-skills/creative/meme-generation/scripts/templates.json @@ -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"} + ] + } +}