mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat(skills): add meme maker skill
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
|
- Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.
|
||||||
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.
|
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.
|
||||||
- QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. (#80323) Thanks @100yenadmin.
|
- QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. (#80323) Thanks @100yenadmin.
|
||||||
- QA-Lab: add a live-only Codex Pi-shaped Read vocabulary canary so runtime parity catches native workspace-read prompt compatibility drift. (#80323) Thanks @100yenadmin.
|
- QA-Lab: add a live-only Codex Pi-shaped Read vocabulary canary so runtime parity catches native workspace-read prompt compatibility drift. (#80323) Thanks @100yenadmin.
|
||||||
|
|||||||
42
skills/meme-maker/SKILL.md
Normal file
42
skills/meme-maker/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: meme-maker
|
||||||
|
description: Search meme templates, suggest formats, and generate local or hosted image memes.
|
||||||
|
metadata: { "openclaw": { "emoji": "🖼️", "requires": { "bins": ["node"] } } }
|
||||||
|
---
|
||||||
|
|
||||||
|
# Meme Maker
|
||||||
|
|
||||||
|
Create meme drafts from a curated template registry without bundling copyrighted template images.
|
||||||
|
|
||||||
|
Quick start
|
||||||
|
|
||||||
|
- Search: `{baseDir}/scripts/meme.mjs search "bad choice"`
|
||||||
|
- Suggest: `{baseDir}/scripts/meme.mjs suggest "slow python image scripts"`
|
||||||
|
- Local SVG: `{baseDir}/scripts/meme.mjs render drake --text "Python cold starts" --text "Node sharp cache" --out /tmp/meme.svg`
|
||||||
|
- Local PNG: `{baseDir}/scripts/meme.mjs render drake --text "Maybe API" --text "Local render" --out /tmp/meme.png`
|
||||||
|
- Imgflip hosted: `{baseDir}/scripts/meme.mjs render drake --service imgflip --text "before" --text "after"`
|
||||||
|
|
||||||
|
Modes
|
||||||
|
|
||||||
|
- `local` is default. It downloads template images from their source URL with a browser-like user agent, caches them under the user cache dir, embeds the image in an SVG, and writes SVG. If `--out` ends in `.png`, it uses `sharp` when available.
|
||||||
|
- `imgflip` calls Imgflip `caption_image` and prints the hosted URL. It requires `IMGFLIP_USER` and `IMGFLIP_PASS` unless supplied via `--username` and `--password`.
|
||||||
|
|
||||||
|
Commands
|
||||||
|
|
||||||
|
- `list [--json]`: list the built-in curated templates.
|
||||||
|
- `search <query> [--json]`: search template names, aliases, tags, and use cases.
|
||||||
|
- `suggest <topic> [--limit N] [--json]`: rank templates for the topic.
|
||||||
|
- `render <template> --text TEXT ... [--out PATH] [--service local|imgflip]`: generate a meme.
|
||||||
|
- `refresh [--limit N] [--json]`: fetch current Imgflip top templates for research; do not overwrite the curated registry automatically.
|
||||||
|
|
||||||
|
Template registry
|
||||||
|
|
||||||
|
- Read `{baseDir}/references/templates.json` for the curated 20-template registry.
|
||||||
|
- Each entry includes Imgflip metadata, Know Your Meme link, aliases, tags, fields, and local text placement boxes.
|
||||||
|
- Prefer `suggest` first when the user describes a joke but does not know the format.
|
||||||
|
|
||||||
|
Hygiene
|
||||||
|
|
||||||
|
- Do not ship template image files in the skill.
|
||||||
|
- Do not use shared or hardcoded Imgflip credentials.
|
||||||
|
- Keep Know Your Meme lookups out of the render hot path; use KYM links for explanation/provenance only.
|
||||||
358
skills/meme-maker/references/templates.json
Normal file
358
skills/meme-maker/references/templates.json
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "drake",
|
||||||
|
"name": "Drake Hotline Bling",
|
||||||
|
"imgflipId": "181913649",
|
||||||
|
"imageUrl": "https://i.imgflip.com/30b1gx.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/drakeposting",
|
||||||
|
"width": 1200,
|
||||||
|
"height": 1200,
|
||||||
|
"aliases": ["drakeposting", "drake"],
|
||||||
|
"tags": ["choice", "preference", "reject", "accept", "before after", "api", "local", "cache", "better option"],
|
||||||
|
"use": "Reject one option and endorse a better one.",
|
||||||
|
"fields": ["rejected option", "preferred option"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.52, "y": 0.05, "w": 0.43, "h": 0.38 },
|
||||||
|
{ "x": 0.52, "y": 0.55, "w": 0.43, "h": 0.38 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "two-buttons",
|
||||||
|
"name": "Two Buttons",
|
||||||
|
"imgflipId": "87743020",
|
||||||
|
"imageUrl": "https://i.imgflip.com/1g8my4.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/daily-struggle-two-buttons",
|
||||||
|
"width": 600,
|
||||||
|
"height": 908,
|
||||||
|
"aliases": ["daily struggle", "buttons"],
|
||||||
|
"tags": ["dilemma", "choice", "anxiety", "decision"],
|
||||||
|
"use": "Show a hard choice between two tempting or bad options.",
|
||||||
|
"fields": ["option A", "option B", "reaction"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.06, "y": 0.06, "w": 0.34, "h": 0.16, "rotate": -12 },
|
||||||
|
{ "x": 0.54, "y": 0.05, "w": 0.34, "h": 0.16, "rotate": 12 },
|
||||||
|
{ "x": 0.12, "y": 0.67, "w": 0.76, "h": 0.22 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "distracted-boyfriend",
|
||||||
|
"name": "Distracted Boyfriend",
|
||||||
|
"imgflipId": "112126428",
|
||||||
|
"imageUrl": "https://i.imgflip.com/1ur9b0.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/distracted-boyfriend",
|
||||||
|
"width": 1200,
|
||||||
|
"height": 800,
|
||||||
|
"aliases": ["boyfriend", "distracted"],
|
||||||
|
"tags": ["temptation", "switching", "old new", "attention"],
|
||||||
|
"use": "Compare current commitment, temptation, and disapproval.",
|
||||||
|
"fields": ["temptation", "me", "current commitment"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.05, "y": 0.04, "w": 0.28, "h": 0.18 },
|
||||||
|
{ "x": 0.41, "y": 0.06, "w": 0.2, "h": 0.14 },
|
||||||
|
{ "x": 0.66, "y": 0.05, "w": 0.28, "h": 0.18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uno-draw-25",
|
||||||
|
"name": "UNO Draw 25 Cards",
|
||||||
|
"imgflipId": "217743513",
|
||||||
|
"imageUrl": "https://i.imgflip.com/3lmzyx.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/uno-draw-25-cards",
|
||||||
|
"width": 500,
|
||||||
|
"height": 494,
|
||||||
|
"aliases": ["uno", "draw 25"],
|
||||||
|
"tags": ["refusal", "avoidance", "consequence", "stubborn"],
|
||||||
|
"use": "Refuse a simple instruction and accept absurd consequences.",
|
||||||
|
"fields": ["instruction", "person"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.08, "y": 0.08, "w": 0.46, "h": 0.24 },
|
||||||
|
{ "x": 0.48, "y": 0.66, "w": 0.42, "h": 0.18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "left-exit-12",
|
||||||
|
"name": "Left Exit 12 Off Ramp",
|
||||||
|
"imgflipId": "124822590",
|
||||||
|
"imageUrl": "https://i.imgflip.com/22bdq6.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/left-exit-12-off-ramp",
|
||||||
|
"width": 804,
|
||||||
|
"height": 767,
|
||||||
|
"aliases": ["off ramp", "exit 12"],
|
||||||
|
"tags": ["sudden choice", "escape", "pivot", "bad decision"],
|
||||||
|
"use": "A sudden hard pivot away from the expected route.",
|
||||||
|
"fields": ["normal route", "exit choice", "driver"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.18, "y": 0.11, "w": 0.28, "h": 0.13, "rotate": -8 },
|
||||||
|
{ "x": 0.52, "y": 0.1, "w": 0.28, "h": 0.13, "rotate": 9 },
|
||||||
|
{ "x": 0.52, "y": 0.54, "w": 0.35, "h": 0.18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bernie-asking",
|
||||||
|
"name": "Bernie I Am Once Again Asking",
|
||||||
|
"imgflipId": "222403160",
|
||||||
|
"imageUrl": "https://i.imgflip.com/3oevdk.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/i-am-once-again-asking-for-your-financial-support",
|
||||||
|
"width": 750,
|
||||||
|
"height": 750,
|
||||||
|
"aliases": ["bernie", "once again asking"],
|
||||||
|
"tags": ["request", "again", "support", "plea"],
|
||||||
|
"use": "Ask for the same thing again with weary sincerity.",
|
||||||
|
"fields": ["request", "context"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.08, "y": 0.06, "w": 0.84, "h": 0.2 },
|
||||||
|
{ "x": 0.08, "y": 0.74, "w": 0.84, "h": 0.18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "always-has-been",
|
||||||
|
"name": "Always Has Been",
|
||||||
|
"imgflipId": "252600902",
|
||||||
|
"imageUrl": "https://i.imgflip.com/46e43q.png",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/wait-its-all-ohio-always-has-been",
|
||||||
|
"width": 960,
|
||||||
|
"height": 540,
|
||||||
|
"aliases": ["astronaut", "always has been"],
|
||||||
|
"tags": ["realization", "twist", "always", "betrayal"],
|
||||||
|
"use": "Reveal that a surprising truth was always true.",
|
||||||
|
"fields": ["realization", "answer"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.05, "y": 0.08, "w": 0.42, "h": 0.18 },
|
||||||
|
{ "x": 0.55, "y": 0.06, "w": 0.38, "h": 0.18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "running-away-balloon",
|
||||||
|
"name": "Running Away Balloon",
|
||||||
|
"imgflipId": "131087935",
|
||||||
|
"imageUrl": "https://i.imgflip.com/261o3j.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/running-away-balloon",
|
||||||
|
"width": 761,
|
||||||
|
"height": 1024,
|
||||||
|
"aliases": ["balloon", "pink blob"],
|
||||||
|
"tags": ["avoidance", "temptation", "pull", "conflict"],
|
||||||
|
"use": "A person pulled toward temptation while another force stops them.",
|
||||||
|
"fields": ["temptation", "me", "responsibility", "force", "label"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.05, "y": 0.04, "w": 0.34, "h": 0.14 },
|
||||||
|
{ "x": 0.48, "y": 0.13, "w": 0.34, "h": 0.12 },
|
||||||
|
{ "x": 0.36, "y": 0.37, "w": 0.34, "h": 0.12 },
|
||||||
|
{ "x": 0.08, "y": 0.67, "w": 0.34, "h": 0.12 },
|
||||||
|
{ "x": 0.52, "y": 0.74, "w": 0.34, "h": 0.12 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "epic-handshake",
|
||||||
|
"name": "Epic Handshake",
|
||||||
|
"imgflipId": "135256802",
|
||||||
|
"imageUrl": "https://i.imgflip.com/28j0te.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/epic-handshake",
|
||||||
|
"width": 900,
|
||||||
|
"height": 645,
|
||||||
|
"aliases": ["handshake", "predator handshake"],
|
||||||
|
"tags": ["agreement", "common ground", "shared trait"],
|
||||||
|
"use": "Two groups agree over a shared behavior or belief.",
|
||||||
|
"fields": ["group A", "shared trait", "group B"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.05, "y": 0.08, "w": 0.28, "h": 0.18 },
|
||||||
|
{ "x": 0.36, "y": 0.48, "w": 0.28, "h": 0.18 },
|
||||||
|
{ "x": 0.67, "y": 0.08, "w": 0.28, "h": 0.18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grus-plan",
|
||||||
|
"name": "Gru's Plan",
|
||||||
|
"imgflipId": "131940431",
|
||||||
|
"imageUrl": "https://i.imgflip.com/26jxvz.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/grus-plan",
|
||||||
|
"width": 700,
|
||||||
|
"height": 449,
|
||||||
|
"aliases": ["gru", "plan"],
|
||||||
|
"tags": ["plan", "backfire", "sequence", "realization"],
|
||||||
|
"use": "A plan that looks good until the final step exposes the flaw.",
|
||||||
|
"fields": ["step 1", "step 2", "step 3", "bad consequence"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.04, "y": 0.06, "w": 0.28, "h": 0.22 },
|
||||||
|
{ "x": 0.37, "y": 0.06, "w": 0.28, "h": 0.22 },
|
||||||
|
{ "x": 0.04, "y": 0.56, "w": 0.28, "h": 0.22 },
|
||||||
|
{ "x": 0.37, "y": 0.56, "w": 0.28, "h": 0.22 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "anakin-padme",
|
||||||
|
"name": "Anakin Padme 4 Panel",
|
||||||
|
"imgflipId": "322841258",
|
||||||
|
"imageUrl": "https://i.imgflip.com/5c7lwq.png",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/for-the-better-right",
|
||||||
|
"width": 768,
|
||||||
|
"height": 768,
|
||||||
|
"aliases": ["for the better right", "padme", "anakin"],
|
||||||
|
"tags": ["assumption", "ominous", "right", "concern"],
|
||||||
|
"use": "Someone assumes the good interpretation, then realizes silence means trouble.",
|
||||||
|
"fields": ["claim", "optimistic assumption", "silence", "concern"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.05, "y": 0.05, "w": 0.4, "h": 0.18 },
|
||||||
|
{ "x": 0.55, "y": 0.05, "w": 0.4, "h": 0.18 },
|
||||||
|
{ "x": 0.05, "y": 0.55, "w": 0.4, "h": 0.18 },
|
||||||
|
{ "x": 0.55, "y": 0.55, "w": 0.4, "h": 0.18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "waiting-skeleton",
|
||||||
|
"name": "Waiting Skeleton",
|
||||||
|
"imgflipId": "4087833",
|
||||||
|
"imageUrl": "https://i.imgflip.com/2fm6x.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/waiting-skeleton",
|
||||||
|
"width": 298,
|
||||||
|
"height": 403,
|
||||||
|
"aliases": ["skeleton waiting", "waiting"],
|
||||||
|
"tags": ["waiting", "delay", "forever", "stale"],
|
||||||
|
"use": "Waiting so long the subject becomes absurdly stale.",
|
||||||
|
"fields": ["waiting for", "who waits"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.07, "y": 0.05, "w": 0.86, "h": 0.18 },
|
||||||
|
{ "x": 0.07, "y": 0.76, "w": 0.86, "h": 0.18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "disaster-girl",
|
||||||
|
"name": "Disaster Girl",
|
||||||
|
"imgflipId": "97984",
|
||||||
|
"imageUrl": "https://i.imgflip.com/23ls.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/disaster-girl",
|
||||||
|
"width": 500,
|
||||||
|
"height": 375,
|
||||||
|
"aliases": ["fire girl"],
|
||||||
|
"tags": ["chaos", "sabotage", "aftermath", "smirk"],
|
||||||
|
"use": "Someone quietly pleased by chaos behind them.",
|
||||||
|
"fields": ["chaos", "culprit"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.06, "y": 0.06, "w": 0.88, "h": 0.18 },
|
||||||
|
{ "x": 0.06, "y": 0.76, "w": 0.88, "h": 0.16 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sad-pablo",
|
||||||
|
"name": "Sad Pablo Escobar",
|
||||||
|
"imgflipId": "80707627",
|
||||||
|
"imageUrl": "https://i.imgflip.com/1c1uej.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/sad-pablo-escobar",
|
||||||
|
"width": 720,
|
||||||
|
"height": 709,
|
||||||
|
"aliases": ["pablo", "waiting pablo"],
|
||||||
|
"tags": ["lonely", "waiting", "bored", "empty"],
|
||||||
|
"use": "Waiting alone for something that never arrives.",
|
||||||
|
"fields": ["what I am waiting for", "me", "another wait"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.05, "y": 0.05, "w": 0.9, "h": 0.15 },
|
||||||
|
{ "x": 0.05, "y": 0.42, "w": 0.9, "h": 0.15 },
|
||||||
|
{ "x": 0.05, "y": 0.78, "w": 0.9, "h": 0.15 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "change-my-mind",
|
||||||
|
"name": "Change My Mind",
|
||||||
|
"imgflipId": "129242436",
|
||||||
|
"imageUrl": "https://i.imgflip.com/24y43o.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/steven-crowders-change-my-mind-campus-sign",
|
||||||
|
"width": 482,
|
||||||
|
"height": 361,
|
||||||
|
"aliases": ["crowder", "change my mind"],
|
||||||
|
"tags": ["opinion", "provocation", "debate", "take"],
|
||||||
|
"use": "State a strong opinion and invite disagreement.",
|
||||||
|
"fields": ["take", "speaker"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.25, "y": 0.42, "w": 0.44, "h": 0.2, "rotate": -6 },
|
||||||
|
{ "x": 0.06, "y": 0.05, "w": 0.88, "h": 0.14 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "woman-yelling-cat",
|
||||||
|
"name": "Woman Yelling At Cat",
|
||||||
|
"imgflipId": "188390779",
|
||||||
|
"imageUrl": "https://i.imgflip.com/345v97.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/woman-yelling-at-a-cat",
|
||||||
|
"width": 680,
|
||||||
|
"height": 438,
|
||||||
|
"aliases": ["cat yelling", "woman cat"],
|
||||||
|
"tags": ["argument", "misunderstanding", "accusation", "response"],
|
||||||
|
"use": "Two sides of an absurd argument.",
|
||||||
|
"fields": ["accusation", "cat response"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.05, "y": 0.06, "w": 0.44, "h": 0.2 },
|
||||||
|
{ "x": 0.54, "y": 0.06, "w": 0.4, "h": 0.2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "buff-doge-cheems",
|
||||||
|
"name": "Buff Doge vs. Cheems",
|
||||||
|
"imgflipId": "247375501",
|
||||||
|
"imageUrl": "https://i.imgflip.com/43a45p.png",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/buff-doge-vs-cheems",
|
||||||
|
"width": 937,
|
||||||
|
"height": 720,
|
||||||
|
"aliases": ["doge cheems", "then now"],
|
||||||
|
"tags": ["then now", "decline", "contrast", "strong weak"],
|
||||||
|
"use": "Contrast old strong version with current weaker version.",
|
||||||
|
"fields": ["old era", "old label", "new era", "new label"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.04, "y": 0.05, "w": 0.42, "h": 0.14 },
|
||||||
|
{ "x": 0.08, "y": 0.72, "w": 0.36, "h": 0.14 },
|
||||||
|
{ "x": 0.54, "y": 0.05, "w": 0.42, "h": 0.14 },
|
||||||
|
{ "x": 0.58, "y": 0.72, "w": 0.36, "h": 0.14 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mocking-spongebob",
|
||||||
|
"name": "Mocking Spongebob",
|
||||||
|
"imgflipId": "102156234",
|
||||||
|
"imageUrl": "https://i.imgflip.com/1otk96.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/mocking-spongebob",
|
||||||
|
"width": 502,
|
||||||
|
"height": 353,
|
||||||
|
"aliases": ["spongebob mocking"],
|
||||||
|
"tags": ["mocking", "sarcasm", "quote", "dismissive"],
|
||||||
|
"use": "Repeat a claim in mocking alternating-case tone.",
|
||||||
|
"fields": ["claim", "mocked claim"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.06, "y": 0.05, "w": 0.88, "h": 0.18 },
|
||||||
|
{ "x": 0.06, "y": 0.76, "w": 0.88, "h": 0.18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "expanding-brain",
|
||||||
|
"name": "Expanding Brain",
|
||||||
|
"imgflipId": "93895088",
|
||||||
|
"imageUrl": "https://i.imgflip.com/1jwhww.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/galaxy-brain",
|
||||||
|
"width": 857,
|
||||||
|
"height": 1202,
|
||||||
|
"aliases": ["galaxy brain", "brain"],
|
||||||
|
"tags": ["levels", "progression", "increasing", "absurd wisdom"],
|
||||||
|
"use": "Escalating stages from normal to absurdly enlightened.",
|
||||||
|
"fields": ["level 1", "level 2", "level 3", "level 4"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.04, "y": 0.03, "w": 0.42, "h": 0.18 },
|
||||||
|
{ "x": 0.04, "y": 0.28, "w": 0.42, "h": 0.18 },
|
||||||
|
{ "x": 0.04, "y": 0.53, "w": 0.42, "h": 0.18 },
|
||||||
|
{ "x": 0.04, "y": 0.78, "w": 0.42, "h": 0.18 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "this-is-fine",
|
||||||
|
"name": "This Is Fine",
|
||||||
|
"imgflipId": "55311130",
|
||||||
|
"imageUrl": "https://i.imgflip.com/wxica.jpg",
|
||||||
|
"kymUrl": "https://knowyourmeme.com/memes/this-is-fine",
|
||||||
|
"width": 580,
|
||||||
|
"height": 282,
|
||||||
|
"aliases": ["fine", "fire"],
|
||||||
|
"tags": ["denial", "crisis", "calm", "disaster"],
|
||||||
|
"use": "Calm denial while everything is visibly broken.",
|
||||||
|
"fields": ["crisis", "denial"],
|
||||||
|
"boxes": [
|
||||||
|
{ "x": 0.06, "y": 0.06, "w": 0.88, "h": 0.18 },
|
||||||
|
{ "x": 0.06, "y": 0.74, "w": 0.88, "h": 0.18 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
398
skills/meme-maker/scripts/meme.mjs
Executable file
398
skills/meme-maker/scripts/meme.mjs
Executable file
@@ -0,0 +1,398 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const BASE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const TEMPLATES_PATH = path.join(BASE_DIR, "references", "templates.json");
|
||||||
|
const IMGFLIP_GET_MEMES_URL = "https://api.imgflip.com/get_memes";
|
||||||
|
const IMGFLIP_CAPTION_URL = "https://api.imgflip.com/caption_image";
|
||||||
|
const USER_AGENT = "OpenClawMemeMaker/1.0";
|
||||||
|
const STOPWORDS = new Set([
|
||||||
|
"a",
|
||||||
|
"an",
|
||||||
|
"and",
|
||||||
|
"are",
|
||||||
|
"for",
|
||||||
|
"in",
|
||||||
|
"is",
|
||||||
|
"it",
|
||||||
|
"of",
|
||||||
|
"on",
|
||||||
|
"or",
|
||||||
|
"the",
|
||||||
|
"this",
|
||||||
|
"to",
|
||||||
|
"use",
|
||||||
|
"with",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function usage(exitCode = 0) {
|
||||||
|
const out = exitCode === 0 ? console.log : console.error;
|
||||||
|
out(`Usage:
|
||||||
|
meme.mjs list [--json]
|
||||||
|
meme.mjs search <query> [--json]
|
||||||
|
meme.mjs suggest <topic> [--limit N] [--json]
|
||||||
|
meme.mjs render <template> --text TEXT ... [--out PATH] [--service local|imgflip]
|
||||||
|
meme.mjs refresh [--limit N] [--json]`);
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const flags = { _: [] };
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (!arg.startsWith("--")) {
|
||||||
|
flags._.push(arg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const eq = arg.indexOf("=");
|
||||||
|
const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
||||||
|
const inline = eq === -1 ? undefined : arg.slice(eq + 1);
|
||||||
|
if (["json", "help"].includes(key)) {
|
||||||
|
flags[key] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = inline ?? argv[i + 1];
|
||||||
|
if (inline === undefined) i += 1;
|
||||||
|
if (value === undefined) throw new Error(`Missing value for --${key}`);
|
||||||
|
if (key === "text") {
|
||||||
|
flags.text = [...(flags.text ?? []), value];
|
||||||
|
} else {
|
||||||
|
flags[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(value) {
|
||||||
|
return String(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokens(value) {
|
||||||
|
return normalize(value)
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((token) => token && !STOPWORDS.has(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
return JSON.parse(await readFile(TEMPLATES_PATH, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateHaystack(template) {
|
||||||
|
return [
|
||||||
|
template.id,
|
||||||
|
template.name,
|
||||||
|
template.use,
|
||||||
|
...(template.aliases ?? []),
|
||||||
|
...(template.tags ?? []),
|
||||||
|
...(template.fields ?? []),
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreTemplate(template, query) {
|
||||||
|
const queryTokens = tokens(query);
|
||||||
|
const haystack = normalize(templateHaystack(template));
|
||||||
|
let score = 0;
|
||||||
|
for (const token of queryTokens) {
|
||||||
|
if (normalize(template.id).includes(token)) score += 8;
|
||||||
|
if (normalize(template.name).includes(token)) score += 6;
|
||||||
|
if ((template.aliases ?? []).some((alias) => normalize(alias).includes(token))) score += 5;
|
||||||
|
if ((template.tags ?? []).some((tag) => normalize(tag).includes(token))) score += 4;
|
||||||
|
if (normalize(template.use).includes(token)) score += 3;
|
||||||
|
if (haystack.includes(token)) score += 1;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTemplate(templates, selector) {
|
||||||
|
const wanted = normalize(selector);
|
||||||
|
const exact = templates.find(
|
||||||
|
(template) =>
|
||||||
|
normalize(template.id) === wanted ||
|
||||||
|
normalize(template.name) === wanted ||
|
||||||
|
(template.aliases ?? []).some((alias) => normalize(alias) === wanted),
|
||||||
|
);
|
||||||
|
if (exact) return exact;
|
||||||
|
const ranked = templates
|
||||||
|
.map((template) => ({ template, score: scoreTemplate(template, selector) }))
|
||||||
|
.filter((entry) => entry.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
return ranked[0]?.template;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTemplates(templates, json) {
|
||||||
|
if (json) {
|
||||||
|
console.log(JSON.stringify(templates, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const template of templates) {
|
||||||
|
console.log(`${template.id}: ${template.name}`);
|
||||||
|
console.log(` use: ${template.use}`);
|
||||||
|
console.log(` fields: ${template.fields.join(", ")}`);
|
||||||
|
console.log(` kym: ${template.kymUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheRoot() {
|
||||||
|
const root =
|
||||||
|
process.env.XDG_CACHE_HOME ||
|
||||||
|
(process.platform === "darwin"
|
||||||
|
? path.join(homedir(), "Library", "Caches")
|
||||||
|
: path.join(homedir(), ".cache"));
|
||||||
|
return path.join(root, "openclaw", "meme-maker");
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromUrl(url) {
|
||||||
|
const ext = path.extname(new URL(url).pathname).toLowerCase();
|
||||||
|
return ext && ext.length <= 5 ? ext : ".img";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBuffer(url) {
|
||||||
|
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
|
||||||
|
if (!response.ok) throw new Error(`Fetch failed ${response.status} for ${url}`);
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cachedTemplateImage(template) {
|
||||||
|
const dir = cacheRoot();
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const file = path.join(
|
||||||
|
dir,
|
||||||
|
`${template.id}-${template.imgflipId}${extFromUrl(template.imageUrl)}`,
|
||||||
|
);
|
||||||
|
if (existsSync(file)) return { file, buffer: await readFile(file) };
|
||||||
|
const buffer = await fetchBuffer(template.imageUrl);
|
||||||
|
await writeFile(file, buffer);
|
||||||
|
return { file, buffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapText(text, maxChars) {
|
||||||
|
const words = String(text).trim().split(/\s+/).filter(Boolean);
|
||||||
|
const lines = [];
|
||||||
|
let current = "";
|
||||||
|
for (const word of words) {
|
||||||
|
const candidate = current ? `${current} ${word}` : word;
|
||||||
|
if (candidate.length <= maxChars || !current) {
|
||||||
|
current = candidate;
|
||||||
|
} else {
|
||||||
|
lines.push(current);
|
||||||
|
current = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) lines.push(current);
|
||||||
|
return lines.length ? lines : [""];
|
||||||
|
}
|
||||||
|
|
||||||
|
function textSvg(text, box, width, height, index) {
|
||||||
|
const x = box.x * width;
|
||||||
|
const y = box.y * height;
|
||||||
|
const w = box.w * width;
|
||||||
|
const h = box.h * height;
|
||||||
|
const centerX = x + w / 2;
|
||||||
|
const centerY = y + h / 2;
|
||||||
|
const maxChars = Math.max(8, Math.floor(w / Math.max(12, width * 0.032)));
|
||||||
|
let lines = wrapText(text, maxChars).slice(0, 5);
|
||||||
|
const fontSize = Math.max(
|
||||||
|
18,
|
||||||
|
Math.min(
|
||||||
|
h / (lines.length * 1.15),
|
||||||
|
(w / Math.max(...lines.map((line) => line.length), 1)) * 1.65,
|
||||||
|
width * 0.07,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const lineHeight = fontSize * 1.08;
|
||||||
|
const totalHeight = lineHeight * lines.length;
|
||||||
|
const rotate = box.rotate ? ` rotate(${box.rotate} ${centerX} ${centerY})` : "";
|
||||||
|
lines = lines.map(escapeXml);
|
||||||
|
const tspans = lines
|
||||||
|
.map((line, lineIndex) => {
|
||||||
|
const dy = lineIndex === 0 ? -(totalHeight - lineHeight) / 2 : lineHeight;
|
||||||
|
return `<tspan x="${centerX.toFixed(1)}" dy="${dy.toFixed(1)}">${line}</tspan>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return `<text class="meme-text" data-box="${index}" transform="translate(0 ${centerY.toFixed(1)})${rotate}" font-size="${fontSize.toFixed(1)}">${tspans}</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultBoxes(count) {
|
||||||
|
if (count <= 1) return [{ x: 0.06, y: 0.74, w: 0.88, h: 0.18 }];
|
||||||
|
if (count === 2) {
|
||||||
|
return [
|
||||||
|
{ x: 0.06, y: 0.05, w: 0.88, h: 0.18 },
|
||||||
|
{ x: 0.06, y: 0.76, w: 0.88, h: 0.18 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
x: 0.05,
|
||||||
|
y: 0.04 + index * (0.9 / count),
|
||||||
|
w: 0.9,
|
||||||
|
h: Math.min(0.16, 0.8 / count),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderLocal(template, texts, flags) {
|
||||||
|
const { buffer } = await cachedTemplateImage(template);
|
||||||
|
const imageMime = extFromUrl(template.imageUrl) === ".png" ? "image/png" : "image/jpeg";
|
||||||
|
const imageData = `data:${imageMime};base64,${buffer.toString("base64")}`;
|
||||||
|
const boxes = template.boxes?.length
|
||||||
|
? template.boxes
|
||||||
|
: defaultBoxes(texts.length || template.fields.length);
|
||||||
|
const width = Number(template.width);
|
||||||
|
const height = Number(template.height);
|
||||||
|
const textNodes = texts
|
||||||
|
.map((text, index) =>
|
||||||
|
textSvg(text, boxes[index] ?? boxes[boxes.length - 1], width, height, index),
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<style>
|
||||||
|
.meme-text {
|
||||||
|
font-family: Impact, "Arial Black", Arial, sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
fill: #fff;
|
||||||
|
stroke: #000;
|
||||||
|
stroke-width: ${Math.max(3, Math.round(width / 250))};
|
||||||
|
paint-order: stroke;
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<image href="${imageData}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="none"/>
|
||||||
|
${textNodes}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
const out = flags.out ?? path.resolve(process.cwd(), `${template.id}.svg`);
|
||||||
|
await mkdir(path.dirname(path.resolve(out)), { recursive: true });
|
||||||
|
if (path.extname(out).toLowerCase() === ".png") {
|
||||||
|
let sharp;
|
||||||
|
try {
|
||||||
|
sharp = (await import("sharp")).default;
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
"PNG output needs the optional sharp package. Use --out meme.svg or install sharp near the skill runner.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await sharp(Buffer.from(svg)).png().toFile(out);
|
||||||
|
} else {
|
||||||
|
await writeFile(out, svg, "utf8");
|
||||||
|
}
|
||||||
|
const size = (await stat(out)).size;
|
||||||
|
console.log(`${out} (${size} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderImgflip(template, texts, flags) {
|
||||||
|
const username = flags.username || process.env.IMGFLIP_USER;
|
||||||
|
const password = flags.password || process.env.IMGFLIP_PASS;
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new Error(
|
||||||
|
"Imgflip service requires IMGFLIP_USER and IMGFLIP_PASS, or --username/--password.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
template_id: template.imgflipId,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if ((template.boxes?.length ?? template.fields.length) <= 2) {
|
||||||
|
texts.forEach((text, index) => body.set(`text${index}`, text));
|
||||||
|
} else {
|
||||||
|
texts.forEach((text, index) => body.set(`boxes[${index}][text]`, text));
|
||||||
|
}
|
||||||
|
const response = await fetch(IMGFLIP_CAPTION_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!payload.success) throw new Error(payload.error_message || "Imgflip caption_image failed");
|
||||||
|
console.log(payload.data.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(flags) {
|
||||||
|
const limit = Number(flags.limit || 25);
|
||||||
|
const response = await fetch(IMGFLIP_GET_MEMES_URL, { headers: { "User-Agent": USER_AGENT } });
|
||||||
|
if (!response.ok) throw new Error(`Imgflip get_memes failed ${response.status}`);
|
||||||
|
const payload = await response.json();
|
||||||
|
const memes = payload.data.memes.slice(0, limit);
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(memes, null, 2));
|
||||||
|
} else {
|
||||||
|
for (const meme of memes) {
|
||||||
|
console.log(
|
||||||
|
`${meme.id}: ${meme.name} (${meme.width}x${meme.height}, boxes=${meme.box_count})`,
|
||||||
|
);
|
||||||
|
console.log(` ${meme.url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const flags = parseArgs(process.argv.slice(2));
|
||||||
|
if (flags.help) usage(0);
|
||||||
|
const [command, ...rest] = flags._;
|
||||||
|
if (!command) usage(1);
|
||||||
|
const templates = await loadTemplates();
|
||||||
|
|
||||||
|
if (command === "list") {
|
||||||
|
printTemplates(templates, flags.json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "search" || command === "suggest") {
|
||||||
|
const query = rest.join(" ");
|
||||||
|
if (!query) throw new Error(`${command} needs a query`);
|
||||||
|
const limit = Number(flags.limit || (command === "suggest" ? 5 : 20));
|
||||||
|
const ranked = templates
|
||||||
|
.map((template) => ({ ...template, score: scoreTemplate(template, query) }))
|
||||||
|
.filter((template) => template.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, limit);
|
||||||
|
printTemplates(ranked, flags.json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "render") {
|
||||||
|
const selector = rest.join(" ");
|
||||||
|
if (!selector) throw new Error("render needs a template id/name");
|
||||||
|
const template = findTemplate(templates, selector);
|
||||||
|
if (!template) throw new Error(`No matching template: ${selector}`);
|
||||||
|
const texts = flags.text ?? [];
|
||||||
|
if (!texts.length)
|
||||||
|
throw new Error(`Add text with --text. Fields: ${template.fields.join(", ")}`);
|
||||||
|
const service = flags.service || "local";
|
||||||
|
if (service === "local") {
|
||||||
|
await renderLocal(template, texts, flags);
|
||||||
|
} else if (service === "imgflip") {
|
||||||
|
await renderImgflip(template, texts, flags);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown service: ${service}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "refresh") {
|
||||||
|
await refresh(flags);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`error: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user