PoemLens
Poem (+ image) → PNGs out (fused 题款 / poemslip 诗笺 / postcard 明信片, or a subset). Pick the script by poem form (step 0), then pick Path A (user image) or Path B (generate) in step 3. Each script has exactly one rendering path: no auto-detection, no silent fallback to the wrong layout.
Hard rules — read before anything else
These are the reasons this skill has been slow and leaky in the past. Every one of them is non-negotiable.
-
Never check, print, or "verify" the API key. Do not run
echo $AGNES_API_KEY,cat ~/.zshrc,grep AGNES,env | grep, or anything similar — not "just to confirm", not ever. The script resolves the key itself from the env or~/.zshrc/~/.zprofileand stays silent about its value; if the key is missing and an API roll is actually needed, the script dies with a clear message. So: just run the script. Pre-checking the key costs minutes and leaks the secret in plaintext. There is no upside. -
Never look at the generated PNGs. No
Readon the output files, no image-analysis tool, no vision/远端 tool, no upload to any server, no "quick check that it came out right." Compositing is deterministic pixel math — there is literally nothing to inspect, and every look is a waste (or worse, ships the user's image off-box). The only thing you do with an output PNG is report its localfile://path to the user. They open it; you don't. -
Write the prompt once, run once. Follow step 1 and produce a precise prompt the first time. Don't re-roll speculatively to "compare" — each roll is a paid API call and ~30s. Only re-roll if the user asks, or use
--recomposite/image_inputfor free tweaks. -
Never fabricate the attribution. The poet and poem title must come from the user or from a search — never inferred from the first line or any line of verse. The first line is frequently NOT the title (e.g. Bishop's "Conversation" opens "The tumult in the heart", yet the title is "Conversation"). If the user didn't give the title and you're not certain, search before composing — a wrong title is misinformation typeset onto the image. The script typesets whatever string you pass with zero validation; the skill's correctness gate is you, here.
0. Route by poem form — pick the script
This is the critical step. The scripts never second-guess you — once you choose one, its layout is fixed. Choose wrong and the layout is wrong; there is no safety net. So decide deliberately.
| The poem is... | Script | Layout |
|---|---|---|
| Classical Chinese verse (古典诗词/绝句/律诗/词牌/古诗) | scripts/classical.py |
Vertical columns, right-to-left, one clause per column, punctuation kept (默认) |
| Modern Chinese free verse (现代诗/新诗) | scripts/modern.py |
Horizontal, centered |
| English / Latin verse | scripts/modern.py |
Horizontal calligraphic italic, centered |
You can tell these apart — a 5-/7-字 regulated verse, a 词牌 short form, or anything pre-modern is classical; free verse with uneven line lengths and modern diction is modern; anything Latin-alphabet is English. When in doubt for Chinese, the deciding question is "is this metered/regulated pre-modern verse?" — yes → classical, no → modern.
1. Prompt (only when generating the image — skip if user supplied one)
The goal: a prompt so precise the first generation is the one you want. Read this section, write the prompt once, run it.
The 5-step method — every time, in order
Step 1 — Does the poem name its own art style? Read for an ekphrastic clue first. Poets often hand you the visual reference directly. Scan for the names of painters, art movements, "like a painting/cartoon/tapestry/etching." If you find one, use that style — it is the poem's own instruction and overrides the mood table below. Seascape names "this cartoon by Raphael for a tapestry for a Pope" — that is the style: a Renaissance tapestry cartoon, radiant and idealized. Use it.
Step 2 — Read the poem for LIGHT. Before naming any object, fix the light: bright (heaven, morning, luminous, glare) or dark (night, elegiac, shadow). The light sets the whole palette. Seascape: "it does look like heaven... a strong glare" → bright, radiant.
Step 3 — Pick the style. If step 1 gave a style, use it. Otherwise choose by mood, matching the light from step 2 — the mood table below has a light column. Match the dominant visual register of the poem.
Step 4 — Choose 1 focal element + 2 supporting. One star, two things that frame it. Everything else (water, light, sky) is atmosphere, not a listed object. Seascape: lighthouse = focal; herons + mangrove island = supporting.
Step 5 — Decode each poetic image to its graceful literal form, then add composition. For each image in the poem, write the real-world thing it points at, in its botanically/physically accurate natural state — name the real structure, not a literary paraphrase. Then place the focal element by rule-of-thirds, name the light direction and quality, and state a warm-vs-cool color relationship. Good literal decodings:
- "Gothic arches of mangrove roots" → a mangrove with its characteristic prop roots: slender straight vertical stilts growing straight down from the branches into the water, forming a clean network of vertical lines. (Vague words like "curving gently into the water" make the model invent a shape; the real root is a stilt — name it.)
- "A lighthouse in clerical dress" → a lighthouse tower with black-and-white horizontal painted bands.
- "The meter glares like a moral owl" → a glowing taxi meter.
- "White herons got up as angels" → white herons in graceful ascending flight.
- Principle: write the noun a naturalist or architect would use, not a poet's paraphrase. "Prop roots" beats "curving roots"; "horizontal painted bands" beats "clerical pattern." The model draws accurately only when you name the real structure.
- When accuracy fights with beauty, demote the element instead of over-describing it. If a real structure (prop roots, bare branches, bones) keeps rendering strange, do NOT add more anatomical detail — that makes it bigger and stranger. Instead shrink it to a distant background element ("a small green island in the far distance") or drop it entirely. The poem's metaphors live in the typeset verse; the image only needs the scene's mood. A 3-word prop that reads as "greenery" beats a 20-word prop that renders as a monster.
Assemble the prompt (English)
Skeleton, filling each bracket once:
[focal element + rule-of-thirds placement]. [2 supporting elements, placed].
[light: direction, quality]. [atmosphere]. [warm-vs-cool color relationship].
[style suffix]
Worked example (Seascape, using the poem's own Raphael-tapestry clue):
A slender lighthouse on the right third as focal point. White herons in graceful ascending flight toward the upper light; a small mangrove island with bright green leaves anchoring the left foreground, its roots curving gently into the water. Radiant golden light from a low sun, flooding the calm sea with luminous reflections. Warm amber light against a cool luminous sky, an idealized heavenly atmosphere. Renaissance tapestry cartoon in the style of Raphael, rich luminous color, idealized natural beauty, graceful composition, no text, no letters.
Two hard rules
- No text in the prompt — the model garbles text; the script typesets it. End with
no text, no letters. - Each poetic image → its graceful literal form. Write the real-world thing the poet points at, in its natural living state. The metaphor lives in the typeset verse; the image shows the beautiful real thing.
Style library
The poem's own ekphrastic reference always wins (step 1). Failing that:
Chinese (classical & modern) → ink-wash (水墨画), always:
[1-3 core images, focal first]. [atmosphere].
Traditional Chinese ink wash painting, 水墨画, monochrome ink on rice paper,
vast empty space, minimalist, elegant brushwork, poetic atmosphere,
no text, no characters, no letters.
English → match mood AND light:
| Mood | Light | Style suffix |
|---|---|---|
| Romantic, lush nature | bright | Pre-Raphaelite romantic oil painting, soft natural light, rich detail, no text, no letters. |
| Light, lyrical, decorative | bright | Art Nouveau decorative illustration, Mucha style, flowing organic lines, flat color, ornamental, no text, no letters. |
| Hazy, dreamlike (default) | bright/medium | soft watercolor wash, impressionistic, hazy atmosphere, gentle color, dreamlike, no text, no letters. |
| Radiant, idealized, heavenly | bright | Renaissance tapestry cartoon in the style of Raphael, rich luminous color, idealized natural beauty, graceful composition, no text, no letters. |
| Solemn, elegiac | dark | classical copper-plate etching, fine cross-hatching, antique book illustration, monochrome, solemn, no text, no letters. |
Match the poem's dominant light: bright poems glow in bright styles; dark poems suit the dark row. When the poem mixes both (bright scene, dark turn), match the scene the image shows and let the verse carry the turn.
2. Verse to typeset
Classical Chinese (→ classical.py): the script splits by punctuation into one column per clause (a clause ends at ,。!?;). So pass the verse as-is and each clause becomes its own column.
- Short poem (≤ 4 clauses): pass the whole poem.
- Long poem (> 4 clauses): pick the 诗眼 — the 2–4 most image-rich clauses (count clauses by punctuation, NOT by 句号 — a , is a clause boundary too). The script renders exactly what you pass; it never truncates. Four columns is the sweet spot for a painting; more crowds it.
Modern / English (→ modern.py): the core line(s) — the most image-rich line, the emotional turn, or the opening. One piercing line beats a full stanza. Use \n between lines.
3. Run it — two paths
SKILL_DIR = this skill's folder. Pick by what the user gave you:
> JSON formatting: the input is JSONL — one complete JSON object per line, no newlines inside it. Don't pretty-print across multiple lines; the model has done this wrong repeatedly. (The script does tolerate a multi-line pretty-printed single object as a fallback, but stick to single-line — it's the contract.)
Path A — user has an image (0 API, instant). Set base to its path. No image_prompt, no API key needed. Any size — auto-normalized to ≤1536px long edge (downscale-only).
python3 "$SKILL_DIR/scripts/modern.py" --input - <<EOF # classical.py for classical verse
{"base":"/path/to/image.png","poem":"...","filename":"01_slug.png"}
EOF
Path B — generate the image. Set image_prompt. Two bases roll in parallel: portrait (for fused/poemslip) + 1:1 square (postcard — a portrait cropped to square loses ~40%).
python3 "$SKILL_DIR/scripts/modern.py" --input - <<EOF
{"image_prompt":"...","poem":"...","filename":"01_slug.png"}
EOF
Both paths find the best whitespace and emit variants automatically. Optional fields:
| field | when to use |
|---|---|
variants |
only save what you want: ["postcard"] or "fused". Default = all three. (Compositing still runs on all three — it's free pixel ops; only saving is filtered.) |
base_square |
(Path A) a separate image for the postcard instead of reusing base. |
attribution |
a poet + title line (e.g. "— Elizabeth Bishop, Song for the Rainy Season" or "—— 苏轼《水调歌头》"). Classical verse (classical.py): rendered as the leftmost vertical column — the 题款 落款, smaller than the verse, bottom-aligned with it. Horizontal punctuation (——, 《》) is auto-replaced with upright vertical Unicode equivalents (︱, ︽︾). If author+title exceeds 12 CJK chars, the 落款 is dropped entirely (too tall). Applied to all three variants including fused. Modern/English (modern.py): a smaller horizontal line below the verse on all three variants (fused/poemslip/postcard). Pass the full formatted string; you control the dash/punctuation. |
text_anchor |
text landed somewhere wrong; right/left/top/bottom |
x_offset / y_offset |
nudge text as fraction of image size (±0.05 small, up to ~0.45 for half-page shifts). Positive y = down. Re-run with --recomposite (no API cost). |
font_scale |
font size multiplier, default 1.0. 1.2 = one step bigger, 0.8 = smaller. |
text_color |
text color, default auto (auto-picks black/white by local background brightness). Accepts a named color (black/white/red/crimson/gold/brown/grey/indigo), a hex string ("#cc0000"), or an RGB list ([255,0,0]). |
stroke |
text outline toggle, default false. true adds a soft outline (useful when text sits on a busy background). Width uses the built-in stroke ratio. |
image_input |
(Path B) path to a saved _base.png for img2img — fix one element without re-rolling. Script looks up its source URL automatically. |
Per-variant override — font_scale / text_color / stroke / text_anchor / x_offset / y_offset can all be set independently for each variant. Put defaults at the top level, then override a single variant with a fused / poemslip / postcard object. Only the keys you list inside are overridden; the rest inherit the top-level default. Example:
{"poem": "...", "font_scale": 1.0, "fused": {"font_scale": 1.3, "stroke": true}, "postcard": {"text_color": "#8b0000"}}
→ fused gets bigger text + outline; postcard gets dark-crimson text; poemslip uses the top-level defaults. Re-run with --recomposite (free) to apply.
4. Report
Output paths as markdown links (file:///abs/path), one per variant actually saved (the script prints which), then the verse inline.
[slug.png](file:///abs/path/slug.png) — Fused 题款版
[slug_poemslip.png](file:///abs/path/slug_poemslip.png) — Poemslip 诗笺版
[slug_postcard.png](file:///abs/path/slug_postcard.png) — Postcard 明信片版
Don't Read the PNGs — compositing is deterministic, nothing to inspect, and reading/uploading them is explicitly forbidden (Hard rule 2). Tweaks: --recomposite + x_offset/y_offset/text_anchor/font_scale/text_color/stroke (free, applies per-variant), or image_input (re-roll one element).
Troubleshooting
- 503
system_memory_overloaded/image queue is full/ SSLUNEXPECTED_EOF→ transient gateway/upstream faults, not your request. Just rerun the same command; it succeeds on retry. The script already retries 3×, but a sustained gateway hiccup may need a manual rerun after a short wait. --recompositesays "_base.pngnot found" → recomposite derives the base path fromfilename, so thefilenameyou pass must match a base that already exists (run without--recompositefirst, or copy/rename a base to match your filename).- Postcard looks identical to the others / "saved postcard" but no
_base_square.png→ the square-base roll failed transiently and the postcard fell back to the portrait base (cropped). Rerun without--recompositeto roll a fresh square; or just rerun — both bases re-roll together.