name: lorescape-manual-daily-story description: Use when the user wants to manually generate, review, regenerate, or publish a Lorescape daily story (the server's 08:00 cron is paused via DAILY_STORY_ENABLED=0), pick the next place from daily_story_places, fix/overwrite a specific date's daily story in Supabase, gather Unsplash photos of the place, resolve a missing cover image, or — only after the user publishes — generate a Google Flow reel of the Lorescape guide character touring the place (via the ai-media-generator skill).
Lorescape Manual Daily Story
Overview
Interactive replacement for the paused daily-story cron. End to end:
pick a place from Supabase → Claude generates zh-TW + en stories
directly → gather real photos of the place → the user reviews everything
in chat → revise until satisfied → write to daily_stories via
publish → (only after publish) generate a Google Flow reel of the
Lorescape guide touring the place via the ai-media-generator skill →
(IG Reels) add a short zh-TW voiceover + burned-in captions in post.
Content is generated by Claude, not Gemini. Do NOT use the
generate sub-command (it calls Gemini). Follow the Claude-driven
workflow below to produce the draft JSON manually, then use publish to
write it to Supabase.
Core principle: the App has NO review gate. It shows whatever row
has the newest publish_date for the language. The moment publish
runs, users can see the story in the App. Content review must finish
BEFORE publish, never after.
Unsplash ALWAYS runs. Every place gets an Unsplash photo pass, because these photos serve three jobs: (1) the IG/App cover image when Wikipedia has no commercially usable lead image, (2) the visual reference Claude uses to write an accurate video prompt, and (3) the genuine-place check that decides whether to keep this place at all. The search is narrowed to the place itself — only photos of the actual site, or that directly represent it. Discard anything loosely related.
Cover image is required for the IG card. A NULL image_url makes
mapper.build_card_content return None, so the IG card can't render
and the Discord review is never posted (the App still shows the story
text, just no cover). The cover is resolved before publish (see
Image resolution).
publish also hands the story off to the Instagram flow. After
writing the rows it renders the IG card and posts it to Discord for
review, setting discord_message_id. With DAILY_STORY_PUBLISH_ENABLED
on, the 21:00 cron then auto-posts to Instagram once an approver reacts
✅ in Discord. So review_state / Discord review gates Instagram,
not the App. If Discord isn't configured the hand-off is skipped (story
still goes live in the App).
The Tools
Publish (writes the reviewed draft to Supabase + Discord hand-off):
uv run python -m scripts.manual_daily_story publish # today
uv run python -m scripts.manual_daily_story publish --date 2026-06-12
publish reads /tmp/lorescape_daily_story_draft.json, upserts both
language rows (idempotent on publish_date+language), marks the place
used, and posts the IG card to Discord (best-effort). It writes whatever
image_url / image_attribution the draft holds, so the cover must
already be resolved in the draft.
Unsplash search (always run — folded in from the former
lorescape-unsplash-images skill):
cd backend && uv run python -m scripts.unsplash_images # today
cd backend && uv run python -m scripts.unsplash_images --date 2026-06-16
It reads the draft, runs 5 place-anchored landscape queries (every
query contains the place name), and saves results + downloaded jpgs to
outputs/daily_image/{date}/unsplash_results.json (repo root). Needs
UNSPLASH_ACCESS_KEY in backend/.env (free demo key, 50 req/hr, from
https://unsplash.com/developers).
Workflow
Step 1 — Pick the next place
python3 -c "
from dotenv import load_dotenv; import os; load_dotenv()
os.environ.pop('GOOGLE_API_KEY', None)
from supabase import create_client
from lorescape_backend.config import Config
from lorescape_backend.daily_story import place_picker
sb = create_client(Config.from_env().supabase_url, Config.from_env().supabase_service_role_key)
p = place_picker.pick_next_place(sb)
print(p.id, p.wikipedia_title_en)
"
Step 2 — Fetch Wikipedia material
from lorescape_backend.daily_story import wikipedia
summary = wikipedia.fetch_summary(title)
intro = wikipedia.fetch_intro_extract(title) or summary.extract
lead = wikipedia.fetch_lead_image(title)
zh_url = wikipedia.fetch_langlink_url(title, 'zh') or summary.en_url
en_url = summary.en_url
image_url = summary.image_url if (lead and lead.is_commercial_ok) else None
image_attr= lead.attribution if (lead and lead.is_commercial_ok) else None
Use WebFetch on the Wikipedia article for richer content when the
intro extract is too short (< 300 chars).
Step 3 — Claude writes the stories
Generate both zh-TW and en content following this contract:
| Field | zh-TW spec | en spec |
|---|---|---|
paragraphs (×3) |
200–300 chars each; 起/承/合 arc | 80–130 words each; setup/development/resolution |
card_paragraphs (×3) |
60–100 chars; same arc compressed; first char = concrete noun/name | 60–100 words; same; avoid The/A/An/In/On/At/It/This/That as first word |
card_title |
≤14 chars; captures tension, not just place name | ≤28 chars |
card_title_sub |
≤20 chars | ≤50 chars |
card_pull_quote |
「」or 『』wrapped; prefer real source quote | "…" wrapped; prefer real source quote |
card_pull_quote_attrib |
starts with ── | starts with — |
card_anno_roman |
representative year as Roman numerals | same |
hashtags |
3–5 lowerCamelCase ASCII, no '#' | same |
Step 4 — Write the draft JSON
Save to /tmp/lorescape_daily_story_draft.json. Set
image_url/image_attribution from Step 2; if Wikipedia had no
commercial lead image, leave them null for now — Step 5 resolves them.
{
"place_id": "<uuid>",
"wikipedia_title_en": "<title>",
"image_url": "<url or null>",
"image_attribution": "<attribution or null>",
"wiki_urls": { "zh-TW": "<url>", "en": "<url>" },
"stories": {
"zh-TW": { "place_name": "", "place_location": "", "era": "",
"hashtags": [], "paragraphs": [], "card_title": "",
"card_title_sub": "", "card_paragraphs": [],
"card_pull_quote": "", "card_pull_quote_attrib": "",
"card_anno_roman": "" },
"en": { ... same fields ... }
}
}
Use a bash heredoc to write it: cat > /tmp/lorescape_daily_story_draft.json << 'EOF' ... EOF
Step 5 — Image resolution (always run Unsplash)
Run the Image resolution chain. This step ALWAYS runs the Unsplash pass (the draft must exist first), keeps only genuine place photos, sets the cover, and builds the photo pool the video prompt will reference. If no genuine place photo exists from any source, it stops and asks you before switching place.
Step 6 — Present for review
Show the user both languages in full: all paragraphs, card text, pull quote, hashtags, and the resolved cover image (URL + source + attribution). List the genuine place photos kept for video reference. Do NOT summarize.
Step 7 — Iterate
Apply user feedback directly by editing the draft JSON and re-presenting.
Use sed -i '' 's/old/new/g' for simple replacements, or rewrite the
relevant section.
Step 8 — Publish after explicit approval
Only run publish after the user says 可以 / 通過 / 發布 / yes / ok:
uv run python -m scripts.manual_daily_story publish
Relay the verification output (row count + place_name per language) and
the IG review hand-off result (the printed message_id=… line, or the
"skipping IG review hand-off" / "posted nothing" note). If it posted to
Discord, tell the user to approve with ✅ for the 21:00 IG publish.
Step 9 — Generate the Google Flow reel (ONLY after publish)
Gate: do NOT start until the user has decided to publish (Step 8). The reel is a post-publish deliverable, not part of content review. Once the story is published, generate a Flow reel of the Lorescape guide touring THIS place, built from this run's genuine place photos + the actor character. See Google Flow reel.
Step 10 — IG Reels post-production (subtitles + voiceover)
Gate: only after the Step 9 reel exists AND the user has downloaded it. The Flow reel is the clean master (no narration, no on-screen text); this step turns it into the IG Reels cut by adding a short zh-TW voiceover and full burned-in captions. See IG Reels post-production.
Image resolution
The goal is a genuine place photo — one a reasonable viewer accepts as depicting THIS place or its immediate setting. Run every time.
5a — Wikipedia commercial lead image
From Step 2. Only CC0 / CC BY / CC BY-SA lead images qualify
(lead.is_commercial_ok). If present, it depicts the actual place — use
it as the cover and add it to the photo pool.
5b — Unsplash pass (ALWAYS)
Run regardless of 5a (the place photos are also needed for the video prompt):
cd backend && uv run python -m scripts.unsplash_images
This reads the draft, runs 5 place-anchored queries, and downloads
candidates to outputs/daily_image/{date}/. Then:
Look at the downloaded jpgs (read the image files) — don't judge from descriptions alone.
Keep only genuine place photos. A photo qualifies only if it shows THIS place (or its immediate, recognizable setting). Discard anything loosely related — generic textures, unrelated cities, stock scenes, abstract moods. It is better to keep two true shots than ten tangential ones.
Cover image: if 5a gave a Wikipedia lead, keep it as the cover. Otherwise pick the single best landscape place photo and patch the draft:
image_url→ that photo'surlimage_attribution→"<photographer> / Unsplash"(Unsplash License — attribution not legally required but include it in the IG caption as courtesy; keep theunsplash_pagelink)
Verify the cover URL is publicly reachable (the 21:00 publisher uploads
image_urlto Instagram, fetched server-side):curl -sI -A "Mozilla/5.0" '<CHOSEN_URL>' | grep -iE "HTTP/|content-type" # must be HTTP 2?? + content-type: image/*The photo pool for the video prompt = the Wikipedia lead (if any)
- every Unsplash photo you kept in step 2.
5c — Switch place when no genuine photo exists (ASK FIRST)
If neither Wikipedia nor Unsplash yields a single genuine place photo, stop and ask the user before switching — the story is already written and discarding it is their call. Present: the place name, that no usable place photo was found, and the options:
- Switch place — re-run Step 1
pick_next_placefor a different place and regenerate the whole story (Steps 2–5). The abandoned place is NOT burned:mark_place_usedonly runs onpublish. - Manual override — the user supplies a specific image URL, or names a specific place to use instead.
Never silently switch places, publish with a NULL cover, or pad the pool with loosely-related images.
Google Flow reel (after publish)
Generate the reel only after Step 8 (publish). It uses the
ai-media-generator skill (Google Flow / Omni Flash) — invoke that
skill and follow its automation/site-profiles/flow.md profile for the
live UI.
Inputs (both locked as Flow Ingredients so identity + real site hold):
- Guide character —
docs/ig/reels/actor/(the recurring Lorescape guide: a woman in her late 50s, curly grey hair, round glasses, olive linen jacket, cream scarf, holding a brown leather journal). Useactor_2.png(clear front face) as the identity reference; swap toactor_5.png(back view) when the shot is framed from behind. - Place photo — this run's best genuine shot from
outputs/daily_image/{date}/(the Wikipedia cover or a kept Unsplash photo). This locks the REAL building, not an AI hallucination.
Creative direction (Lorescape standing choices):
- Concept — the guide walks the place and warmly presents it: a
knowing smile, gesturing at the architecture as if telling its story.
Knowledgeable, intimate documentary feel — matches Lorescape's "voice
tour guide" brand. Three standing variants (the user picks; each is a
separate paid generation):
- Guide-led (default) —
actor_2.png+ place photo as Ingredients; the guide steps into frame and presents the place. Front face visible. - From-behind / faceless —
actor_5.png(back view) + place photo; a lone figure gazes out at the place. Still a person, no face. - Place-only / no-guide ("無導覽員版本") — drop the actor
Ingredient entirely, keep ONLY the place photo, and rewrite the
prompt as a pure camera move with no person (drone-style glide /
push-in / shoreline drift) ending with an explicit
No people, no on-screen text.Quiet, immersive, POV-traveler feel.
- Guide-led (default) —
- Flow settings — Omni Flash · 視頻 · 素材 (Ingredients) · 9:16 · 10s · 1x. IG Reels are vertical, so always generate 9:16 (Flow defaults to 16:9 — switch it in the settings panel before sending).
- Audio — ambient diegetic only (footsteps, wind); a single warm cello note works well. No narration, no on-screen text. This is the clean master by design — the IG Reels narration and captions are layered on afterwards in Step 10, never baked into the Flow generation.
- Prompt — Ingredients mode means the visuals come from the
reference images, so the prompt is camera-first + action, lightly
referencing "the woman" and "the brick church / the place" (~60–90
words). Also save the prompt text to
outputs/daily_image/{date}/video_prompt.md.
⛔ Paid checkpoint — Flow generation spends the user's Flow credits (Omni Flash 9:16 10s ≈ 15 credits ×1). Stage everything, then get an explicit "go" before clicking send. Never log in for the user — the Google OAuth login is theirs.
Flow automation notes (battle-tested 2026-06):
- Upload —
file_upload's host-path mode is unavailable. Resize the actor + place images small (sips -s format jpeg -Z 640 in.png --out out.jpg), serve them from a localhost CORS server, and have the pagefetch('http://127.0.0.1:PORT/...')them into the file input (input.filesviaDataTransfer+ dispatchchange). Then add each via the prompt box+→ 素材 picker → 「添加到提示」 (one at a time; the picker is single-select). - Prompt box is a Slate contentEditable — inject via a
beforeinputevent withinputType:'insertFromPaste'+ aDataTransfer;execCommand('insertText')leaves the send buttonaria-disabled. - Verify before/after send — confirm the send button
aria-disabled="false"before clicking; the first send is sometimes a no-op, so confirm a new generating tile appears in the gallery before re-sending (a blind re-send double-charges credits). - Dropping an Ingredient for the place-only variant — each thumbnail
already in the prompt box carries its own remove button (find the
img's nearestbuttonancestor, labelledcancel, and.click()it). The picker re-filters so an already-added asset disappears from the list; a removed one reappears. - Re-editing the prompt after a JS delete breaks Slate (2026-06) —
once you
execCommand('delete')/ clear the box via JS, the Slate selection model goes stale and everybeforeinputinsert (insertTextandinsertFromPaste) silently no-ops —textContentstays length 0 even thoughfocusedOkreads true. Recover with a realcomputer.left_clickinto the box (a trusted pointer event rebuilds Slate's selection), confirmdocument.activeElementis thecontenteditable, thencomputer.typethe new prompt. Map the click coord by the screenshot↔CSS scale:screenshot_xy = css_xy × (screenshot_innerW / window.innerWidth)(≈ 0.80 at 1542-wide screenshots / 1920 CSS). The fresh-editor first injection still works with the §0beforeinputsequence; this gotcha is only on re-edits after a delete.
Then present the Flow result in chat. Offer preview / download (download needs explicit permission) / regenerate a variant — then continue to Step 10 to produce the IG Reels cut.
IG Reels post-production (subtitles + voiceover)
The Flow reel is the clean master. For Instagram — which autoplays muted —
add a short zh-TW voiceover plus full burned-in captions so a viewer knows
the place at a glance. This is a local post-production step driven by
backend/scripts/daily_video_post.py (macOS say + ffmpeg); it never
re-runs Flow.
1. Get the master onto disk (user action). Downloading is the user's
job — the ai-media-generator skill never auto-downloads. Ask the user to
download the finished reel from the Flow gallery. Once it lands in
~/Downloads, point them at the helper that renames + files it for you:
scripts/import_source_video.sh # date = today
scripts/import_source_video.sh {date} # specific date
It moves the mp4 from ~/Downloads to
outputs/daily_video/{date}/source.mp4 (renaming it), prompting which to
pick when several mp4s are present. They can also drop it there manually,
or put it anywhere and pass the path with --input.
2. Write the narration (Claude, grounded in the published story). Write a short zh-TW intro that fits inside the 10s clip — 1–2 sentences, ~8–10 seconds spoken, conversational and for the ear (not a transcript of the card). Put one caption line per row in:
outputs/daily_video/{date}/narration.txt
Show it to the user for a quick confirm / tweak before rendering. Each row becomes one on-screen caption shown only while that line is spoken.
3. Render the IG cut.
# Gemini TTS (natural voice; default voice Kore) — preferred for IG:
cd backend && uv run python -m scripts.daily_video_post --date {date} \
--engine gemini
# Offline fallback (macOS say, voice Meijia) — no daily quota:
cd backend && uv run python -m scripts.daily_video_post --date {date}
Standing choices baked into the defaults: zh-TW, full burned-in captions (white text + black outline, bottom third), and the master's ambient audio kept but ducked to ~28% under the voiceover. Two TTS engines:
--engine gemini(preferred) — Gemini TTS via the backend'sGEMINI_API_KEY/ GenaiSettings; default voiceKore(warm female). Other warm voices:Aoede,Callirrhoe,Leda,Vindemiatrix(set with--voice). Pacing is steered by--style(default asks for a warm, slightly-faster documentary tone). ⚠️ Free tier = 10 TTS requests/day per model and each line is one request, so a 2-line reel costs 2 — fine for the daily run but it leaves little room for re-rolls; heavy iteration hits429 RESOURCE_EXHAUSTED(switch to the Vertex backend,$0.004/reel, or use±1s run to run).say). Duration is non-deterministic (--engine say(default, offline) — macOSsayvoiceMeijia(美佳); no quota, tighter length control, more robotic.--ratesets speaking rate (say only).
Other overridable flags: --bg-volume, --font, --input,
--text/--line.
Watermark removal + brand lockup (--delogo + --badge). Flow/Veo
clips carry a visible Gemini diamond watermark. Erase it with
--delogo x,y,w,h (an ffmpeg delogo region) and cover the spot with the
Lorescape brand lockup via --badge docs/ig/lorescape-lockup.png --badge-x X --badge-y Y (the lockup = logo + "Lorescape", on transparent
bg). The badge defaults to the bottom-right corner if --badge-x/y are
omitted.
⚠️ Google moves the watermark per video, so the coordinates are NOT
fixed — measure them each run before setting --delogo/--badge-*:
extract ~20 frames and average them (fps=2 → mean), which blurs the
moving background and leaves the static watermark crisp; read its bounding
box off the averaged frame. (An opaque badge can cover the mark without
--delogo; the current transparent lockup needs --delogo underneath.)
Example (this is a per-video measurement, not a reusable constant):
cd backend && uv run python -m scripts.daily_video_post --date {date} \
--engine gemini \
--delogo 852,1694,88,84 \
--badge docs/ig/lorescape-lockup.png --badge-x 772 --badge-y 1669
The script speaks each line separately to measure its duration (so captions stay in sync), keeps the original ambient bed low under the narration, and if the voiceover runs past the clip it holds the last frame so nothing is cut. Output:
outputs/daily_video/{date}/final.mp4 ← the IG Reels deliverable
outputs/daily_video/{date}/voice.wav ← kept for review
Implementation note: captions are rendered to transparent PNGs with
Pillow and composited via ffmpeg's overlay filter — the Homebrew ffmpeg
here is built without libass/freetype, so the subtitles/drawtext
filters are unavailable. Don't switch to those without first confirming the
build supports them (ffmpeg -filters | grep subtitles).
Then present final.mp4 in chat — this is the IG Reels deliverable.
Quick Reference
| Fact | Detail |
|---|---|
| Draft location | /tmp/lorescape_daily_story_draft.json (no DB writes until publish) |
| Place selection | Unused active place first (oldest created_at), then recycles oldest used_at; --place-title overrides |
story column |
Joined card_paragraphs (short), NOT the long paragraphs — matches the cron job |
| Unsplash | ALWAYS runs; queries anchored on the place name; keep only genuine place shots |
| Image pool | Wikipedia lead (if any) + kept Unsplash shots → cover + video reference |
| Cover chain | Wikipedia commercial lead → best Unsplash place shot → ask user → switch place (never NULL at publish) |
| Unsplash key | UNSPLASH_ACCESS_KEY in backend/.env (free demo, 50 req/hr) |
| Unsplash output | outputs/daily_image/{date}/unsplash_results.json + jpgs (repo root) |
| Flow reel | ONLY after publish; ai-media-generator + Omni Flash; guide (docs/ig/reels/actor/) + place photo as Ingredients; 9:16 (vertical for Reels) · 10s; paid (~15 cr), confirm before send |
| Reel prompt output | outputs/daily_image/{date}/video_prompt.md (repo root, next to the photos) |
| IG Reels post-prod | After Flow + user downloads master to outputs/daily_video/{date}/source.mp4 (use scripts/import_source_video.sh [date] to move it from ~/Downloads and rename); uv run python -m scripts.daily_video_post --date X [--engine gemini] adds zh-TW voiceover + burned-in captions from narration.txt → final.mp4. Gemini TTS (voice Kore) preferred; free tier = 10 TTS req/day; say/Meijia is the offline fallback |
| Overwriting a date | publish --date X upserts, so re-publishing the same date replaces it |
review_state |
Starts pending; the 21:00 cron flips it to published/skipped/etc. based on the Discord ✅/❌ reaction |
| IG review hand-off | publish posts the card to Discord (sets discord_message_id); needs DISCORD_BOT_TOKEN + DISCORD_REVIEW_CHANNEL_ID + DISCORD_APPROVER_IDS, else skipped |
| Env flags | DAILY_STORY_GENERATE_ENABLED (09:00 Gemini, keep OFF) and DAILY_STORY_PUBLISH_ENABLED (21:00 IG, ON) — both fall back to legacy DAILY_STORY_ENABLED |
| Languages | Always both zh-TW and en (App queries per language) |
Gotchas
- GOOGLE_API_KEY shadowing: when calling backend Python modules
directly, pop it first:
os.environ.pop('GOOGLE_API_KEY', None). - Reel comes AFTER publish, never before. Don't start the Google
Flow generation during content review — it's a post-publish
deliverable. Wait until the user has decided to publish (Step 8), then
build it from this run's place photos + the
docs/ig/reels/actor/guide. - Run Unsplash every time, even when Wikipedia has a lead image. The place photos are needed for the video prompt and the genuine-place check — not only as a cover fallback.
- Keep only genuine place photos. The search is narrowed to the place name, but Unsplash can still return tangential shots. Filter visually; discard anything that isn't clearly this place or its setting. A misleading "place" image is worse than fewer images.
- Resolve the cover before review. A NULL
image_urlreachespublish, blocks the IG card, and skips the Discord review — the story goes live in the App with no cover. - Don't publish an unreviewed draft. Rewriting takes seconds; an unreviewed story going live in the App does not. Get an explicit "可以/通過/發布" first.
mark_place_usedonly runs on publish, so an abandoned draft (or a place dropped in 5c) doesn't burn the place rotation.generatesub-command is deprecated — it calls Gemini. Claude writes the content instead.- ~4% of places have no Wikipedia lead image (sampled on the 919-place pool, ≈ once a month) — the Unsplash pass normally covers these; a switch place (5c) is the rare case where Unsplash is empty too.
When NOT to Use
- Diagnosing why a past story is missing/broken → use the lorescape-debug skill (read-only) first.
- Re-enabling the automatic Gemini generation → set
DAILY_STORY_GENERATE_ENABLED=1on the VPS and restart (keep this OFF while Claude writes the content). To let manual stories auto-post to Instagram, setDAILY_STORY_PUBLISH_ENABLED=1instead.