---
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](#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):

```bash
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):

```bash
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

```bash
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

```python
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.

```json
{
  "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](#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:

```bash
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](#google-flow-reel-after-publish).

### 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](#ig-reels-post-production-subtitles--voiceover).

## 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):

```bash
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):

   ```bash
   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 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). 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. (For a face-less variant, use the lone-traveler /
  from-behind framing with `actor_5`.)
- **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 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).

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 and drop it at:

```
outputs/daily_video/{date}/source.mp4
```

(or anywhere — 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.**

```bash
# 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):

```bash
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`; `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_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.
