lorescape-manual-daily-story

star 2

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, or fix/overwrite a specific date's daily story in Supabase.

easylive1989 By easylive1989 schedule Updated 6/11/2026

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:

  1. Look at the downloaded jpgs (read the image files) — don't judge from descriptions alone.

  2. 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.

  3. 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's url
    • image_attribution"<photographer> / Unsplash" (Unsplash License — attribution not legally required but include it in the IG caption as courtesy; keep the unsplash_page link)
  4. Verify the cover URL is publicly reachable (the 21:00 publisher uploads image_url to Instagram, fetched server-side):

    curl -sI -A "Mozilla/5.0" '<CHOSEN_URL>' | grep -iE "HTTP/|content-type"
    # must be HTTP 2?? + content-type: image/*
    
  5. 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_place for a different place and regenerate the whole story (Steps 2–5). The abandoned place is NOT burned: mark_place_used only runs on publish.
  • 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 characterdocs/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). Use actor_2.png (clear front face) as the identity reference; swap to actor_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 / facelessactor_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.
  • 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):

  • Uploadfile_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 page fetch('http://127.0.0.1:PORT/...') them into the file input (input.files via DataTransfer + dispatch change). 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 beforeinput event with inputType:'insertFromPaste' + a DataTransfer; execCommand('insertText') leaves the send button aria-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 nearest button ancestor, labelled cancel, 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 every beforeinput insert (insertText and insertFromPaste) silently no-ops — textContent stays length 0 even though focusedOk reads true. Recover with a real computer.left_click into the box (a trusted pointer event rebuilds Slate's selection), confirm document.activeElement is the contenteditable, then computer.type the 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 §0 beforeinput sequence; 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's GEMINI_API_KEY / GenaiSettings; default voice Kore (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 hits 429 RESOURCE_EXHAUSTED (switch to the Vertex backend, $0.004/reel, or use say). Duration is non-deterministic (±1s run to run).
  • --engine say (default, offline) — macOS say voice Meijia (美佳); no quota, tighter length control, more robotic. --rate sets 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.txtfinal.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_url reaches publish, 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_used only runs on publish, so an abandoned draft (or a place dropped in 5c) doesn't burn the place rotation.
  • generate sub-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=1 on the VPS and restart (keep this OFF while Claude writes the content). To let manual stories auto-post to Instagram, set DAILY_STORY_PUBLISH_ENABLED=1 instead.
Install via CLI
npx skills add https://github.com/easylive1989/instant_explore --skill lorescape-manual-daily-story
Repository Details
star Stars 2
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
easylive1989
easylive1989 Explore all skills →