# Stage Workflow Documentation

This document explains how each stage of the microdrama workflow works, with detailed examples and testing notes.

## Overview

The workflow consists of 13 stages, managed by `orchestrator_v5.py` with `pipeline.json` as single source of truth.

### V5 Pipeline Stages (current)

```
Stage  Directory             Type          Description
─────  ────────────────────  ──────────    ──────────────────────────────────────
 01    01-story              regular       Source story text
 02    02-understanding      regular       Story analysis for visual adaptation
 03    03-script             regular       Scene-by-scene script (JSON)
 04    04-assets             regular       Characters, locations, objects (JSON)
 05    05-references         regular       Reference image search (conditional)
 06    06-characteristic     regular       Characteristic shot (visual anchor)
 07    07-grids              regular       3x3/4x4 grid of scene frames
 08    08-frames             scene-based   Frame extraction + per-scene refinement
 09    09-script-adapted     regular       Adapt script to match selected frames
 10    10-videos             scene-based   Video generation per scene
 11    11-titles             scene-based   Intro/outro title cards
 12    12-captions           scene-based   Timed caption overlays
 13    13-assembly           regular       Final video concatenation
```

### Stage Dependencies

```
01-story → 02-understanding → 03-script → 04-assets → 05-references
                                  ↓              ↓
                           06-characteristic ←───┘
                                  ↓
                    03-script → 07-grids ← 04-assets, 06-characteristic
                                  ↓
                              08-frames (extract from grids, refine per-scene)
                                  ↓
                    03-script → 09-script-adapted ← 08-frames
                                  ↓
               08-frames → 10-videos ← 06-characteristic, 09-script-adapted
                                  ↓
                              11-titles ← 10-videos
                                  ↓
                    03-script → 12-captions ← 10-videos
                                  ↓
                10-videos → 13-assembly ← 12-captions, 11-titles
```

### Key Architecture Changes (2026-02-15)

**Stage reordering:** Frames (08) now come BEFORE script adaptation (09). Previously, script adaptation analyzed a single grid image before the user selected/refined frames. Now it reads all 16 selected frames with their refinement feedback, producing much more accurate descriptions for video generation.

**New titles stage (11):** Dedicated stage for intro/outro title cards, generated after videos exist so title content reflects actual episode content. Uses NanoBanana with Claude-generated prompts based on adapted script + style.

**Old → New stage mapping:**
| Old | New | Change |
|-----|-----|--------|
| 08-script-adapted | 09-script-adapted | Moved after frames |
| 09-frames | 08-frames | Moved before script adaptation |
| (none) | 11-titles | New stage |
| 11-captions | 12-captions | Renumbered |
| 12-assembly | 13-assembly | Renumbered |

Migration is automatic — `load_pipeline()` renames keys and directories on first load.

### Stage Types

- **Regular stages**: Single iteration selected per take (e.g., `pipeline["03-script"] = "006"`)
- **Scene-based stages**: Per-scene selections (e.g., `pipeline["08-frames"] = {"01-scene": "003-refined-002", ...}`)
- **11-titles**: Fixed 2 scenes (intro/outro): `pipeline["11-titles"] = {"intro": "001", "outro": "001-refined-002"}`

### Orchestrator Commands

```bash
# Main workflow
orchestrator_v5.py . run                    # Generate next stage
orchestrator_v5.py . run --accept           # Accept and advance
orchestrator_v5.py . run --refine "feedback" # Refine current
orchestrator_v5.py . run --regenerate       # Fresh generation

# Frame commands (Stage 08)
orchestrator_v5.py . extract-frames         # Extract from grids
orchestrator_v5.py . refine-frame SCENE BASE "feedback"
orchestrator_v5.py . select-frame SCENE FRAME
orchestrator_v5.py . status-frames

# Title commands (Stage 11)
orchestrator_v5.py . generate-title intro|outro
orchestrator_v5.py . refine-title TYPE BASE "feedback"
orchestrator_v5.py . select-title TYPE FRAME
orchestrator_v5.py . status-titles

# Video, caption, assembly commands
orchestrator_v5.py . new-video SCENE [--provider kling-direct]
orchestrator_v5.py . generate-captions
orchestrator_v5.py . assemble
orchestrator_v5.py . status
```

---

Each stage has:
- **Orchestration script** - Python script that prepares prompts and invokes Claude
- **Prompt template** - Template that gets filled with context
- **Claude session** - Claude reads context, writes outputs (SINGLE SESSION FOR ENTIRE STORY)
- **Output files** - What Claude produces (markdown, JSON, images, videos)
- **Iteration support** - Each stage can iterate with feedback

## Core Principles

1. **Scripts orchestrate, Claude does intelligence**
   - Scripts handle paths, logging, metadata, API calls
   - Claude reads, analyzes, generates content

2. **Single session for entire story**
   - Session ID: `{story}_{timestamp}` (e.g., `truck_stop_revenge_20260202_145215`)
   - Same session used for ALL stages and ALL takes
   - Claude maintains full context: "Earlier I analyzed the story... in take 001 I tried X approach... now in take 002 I'll make it darker..."
   - Natural conversation flow
   - Claude Code handles automatic summarization for long conversations

3. **Iteration with feedback**
   - User can iterate any stage with `--feedback "text"`
   - Creates new iteration (002, 003, etc.)
   - `current` symlink points to active iteration

4. **Metadata tracking**
   - Every iteration has metadata.json
   - Tracks sources, timestamps, models, feedback

5. **JSONL logging**
   - All actions logged to pipeline.log
   - Queryable with jq
   - Tracks costs, durations, errors

---

## Stage 0: Initialize Take

**Script:** `scripts/init_take_v3.py`

**Purpose:** Create directory structure and prepare story for processing.

### Usage

```bash
# Option 1: Provide story text inline
./venv/bin/python scripts/init_take_v3.py "story-name" --story "Story text here..."

# Option 2: Read from file
./venv/bin/python scripts/init_take_v3.py "story-name" --story-file path/to/story.txt

# Option 3: Manual editing (creates placeholder)
./venv/bin/python scripts/init_take_v3.py "story-name"
# Then edit /mnt/video/microdrama-v3/story-name/story.md

# Create new take with feedback from previous
./venv/bin/python scripts/init_take_v3.py "story-name" --new-take --feedback "Make it darker"
```

### What It Does

1. **Validates story name**
   - Converts to directory-safe name
   - Checks if story already exists (for --new-take)

2. **Creates/validates story directory**
   - `/mnt/video/microdrama-v3/{story-name}/`

3. **Creates pipeline.log**
   - JSONL format for all events
   - Append-only for concurrent safety

4. **Creates story.md**
   - Root-level story file
   - Either from --story, --story-file, or placeholder
   - Auto-generates title from story name

5. **Creates/loads session ID**
   - **First take:** Generates new session ID with timestamp
     - Format: `{story}_{timestamp}`
     - Example: `truck_stop_revenge_20260202_145215`
     - Saves to `session.txt` in story root
   - **New take:** Loads existing session ID from `session.txt`
     - Same session reused for continuity
     - Claude remembers all previous work

6. **Determines take number**
   - First take: `001`
   - New take: reads current symlink, increments
   - Example: `001/`, `002/`, `003/`

7. **Creates take directory structure**
   ```
   {story-name}/
   ├── pipeline.log
   ├── story.md
   ├── session.txt               # Single session ID for entire story
   ├── CLAUDE.md
   ├── current -> 001
   └── 001/
       ├── 01-story/
       │   ├── 001/
       │   │   ├── story.md          # Copy from root
       │   │   └── metadata.json
       │   └── current -> 001
       ├── 02-understanding/
       │   ├── 001/
       │   └── current -> 001
       ├── 03-script/
       │   ├── 001/
       │   └── current -> 001
       ├── 04-assets/
       ├── 05-references/
       ├── 06-characteristic/
       ├── 07-grids/
       ├── 08-frames/
       ├── 09-script-adapted/
       ├── 10-videos/
       ├── 11-titles/
       ├── 12-captions/
       └── 13-assembly/
   ```

8. **Copies story.md to take**
   - `001/01-story/001/story.md`
   - Creates metadata.json with source reference

9. **Creates CLAUDE.md**
   - From templates/CLAUDE.md.template
   - Filled with story name, take, **session ID**, feedback summary
   - Includes creative references, output formats, workflow guide

10. **Updates symlinks**
    - `story/current -> 001` (or new take number)
    - Each stage: `stage/current -> 001`

11. **Logs initialization**
    - Appends to pipeline.log
    ```json
    {
      "timestamp": "2026-02-02T14:52:15",
      "event": "init",
      "action": "init_take",
      "take": "001",
      "previous_take": null
    }
    ```

### Testing (Completed)

**Test Story:** truck-stop-revenge

**Test 1: First take initialization**
```bash
./venv/bin/python scripts/init_take_v3.py "truck-stop-revenge" --story "..."
```

**Results:**
- ✅ Directory structure created
- ✅ Story copied to both root and take/01-story/001/
- ✅ Session ID created: `truck_stop_revenge_20260202_145215`
- ✅ Session ID saved to `session.txt`
- ✅ CLAUDE.md generated with session ID
- ✅ pipeline.log initialized
- ✅ All stage directories created with 001 iterations
- ✅ Symlinks created correctly

**Test 2: New take with feedback**
```bash
./venv/bin/python scripts/init_take_v3.py "truck-stop-revenge" --new-take --feedback "Make it darker and more suspenseful"
```

**Results:**
- ✅ Session ID reused: `truck_stop_revenge_20260202_145215`
- ✅ Take 002 created
- ✅ Feedback included in CLAUDE.md
- ✅ Same session continues - Claude will have full context from take 001

### Key Code

**Function:** `init_take(story_name, new_take, feedback, story_text)`

**Session ID generation (metadata_utils_v3.py):**
```python
def get_session_id(story_name: str) -> str:
    """Generate session ID for entire story."""
    clean_story = story_name.replace(" ", "_").replace("-", "_").lower()
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    return f"{clean_story}_{timestamp}"
```

**Session ID management in init_take_v3.py:**
```python
# Generate or load session ID (one per story, reused across takes)
session_file = story_path / "session.txt"
if session_file.exists():
    # Reuse existing session ID
    session_id = session_file.read_text().strip()
    print(f"✓ Reusing session ID: {session_id}")
else:
    # Generate new session ID for this story
    session_id = get_session_id(story_name)
    session_file.write_text(session_id)
    print(f"✓ Created session ID: {session_id}")
```

**Path utilities used:**
- `get_story_path(story_name)` - Returns `/mnt/video/microdrama-v3/{story-name}`
- `get_stage_dirs()` - Returns dict of stage names to directory names
- `ensure_dir(path)` - Creates directory if doesn't exist
- `update_current_symlink(parent, target)` - Creates/updates symlink
- `get_session_id(story_name)` - Generates session ID with timestamp

**Logging:**
- `log_init(story_name, take, previous_take)` - Logs to pipeline.log

**Template variables:**
```python
template.format(
    story_name=story_name,
    current_take=take,
    current_stage="(not started)",
    session_id=session_id,  # Same for entire story
    feedback_summary=feedback_summary or "None yet - this is the first take."
)
```

### Creative References

**Added to CLAUDE.md template (filtered for English-speaking audiences):**

**Removed:** soviet_retro, russian_fairy_tale, russian_silver_age, russian_aristocracy, soviet_constructivism, stalinist_empire

**Kept:** Universal styles (cinematic, noir, western, horror, retro_50s, art_deco, fashion_runway, disco, rock_concert, punk, hip_hop, grunge, glam_rock, rave, cartoon, anime, comic_book, 3d_pixar, cyberpunk, vaporwave, steampunk, pop_art, watercolor, gothic)

**Added:** Director references (Michael Bay, Tarantino, Wes Anderson, Edgar Wright, Coen Brothers, Guy Ritchie)

**Emphasis:** These are references/inspiration, not limitations - Claude can combine, adapt, or create completely new approaches

---

## Stage 1: Story Understanding

**Status:** ✅ COMPLETED

**Script:** `scripts/run_stage_v3.py` with `--stage understanding`

**Purpose:** Analyze story for visual adaptation - identify what can be shown, dialogue potential, the critical 3-second hook, and what won't work.

### Usage

```bash
# First iteration
./venv/bin/python scripts/run_stage_v3.py "truck-stop-revenge" --stage understanding

# With user feedback (creates new iteration)
./venv/bin/python scripts/run_stage_v3.py "truck-stop-revenge" --stage understanding --feedback "Focus more on comedic aspects"
```

### What It Does

1. Loads prompt template from [prompts/stage_understanding.txt](../prompts/stage_understanding.txt)
2. Fills context: story text, take, iteration
3. **Loads session ID from `story/session.txt`** (single session for entire story)
4. Saves prompt to `02-understanding/{iteration}/prompt.txt`
5. Invokes Claude: `claude --session-id {uuid} --allowedTools Read,Write,Glob,Grep -p {prompt}`
6. Claude reads `../01-story/current/story.md` and writes `understanding.md`
   - Claude has full context from previous work (session history)
   - Can reference earlier analysis naturally
7. Validates output file exists
8. Creates metadata.json
9. Updates current symlink
10. Logs to pipeline.log

### Output Format

`02-understanding/{iteration}/understanding.md`:

```markdown
# Story Understanding

## Retelling
[Summarize story in 2-3 sentences]

## Meaning
[What makes this story work - the core appeal]

## 3-Second Hook
[CRITICAL - What stops viewers from scrolling in first 3 seconds?
Must be emotionally engaging, visually spectacular, weird, or high energy.
Concrete visual description of opening moment.]

## Visual Potential
### What Works for Video
- [List specific showable elements]

### What Doesn't Work for Video
- [List things that can't be shown]

## Dialogue Potential
### Spoken Lines
- [Character]: "[Quote]" - [Delivery style] - [When in story]

### Caption Text
- [Quote] - [When/why caption instead of speech]

## What WON'T Work
- [Perceptual tricks, compound actions, etc. to avoid]
- [Explain why each won't work]
```

### Key Feature: 3-Second Hook

The critical addition in V3. First 3 seconds must stop viewer scrolling via:
- **Emotionally engaging**: Immediate empathy, shock, curiosity, humor
- **Visual spectacle**: Striking effects, unexpected visuals, dramatic action
- **Weird/unexpected**: Something that doesn't make sense yet, creates questions
- **High energy**: Fast movement, intense situation, dramatic moment

Claude identifies the specific moment/visual/action that starts the video and why viewers will stop scrolling.

### Testing (Completed)

**Test Story:** truck-stop-revenge

**Test 1: First iteration**
```bash
./venv/bin/python scripts/run_stage_v3.py "truck-stop-revenge" --stage understanding
```

**Results:**
- ✅ Prompt template loaded correctly
- ✅ Session ID loaded: `ebcc8082-11ee-406c-968d-16b967a56ea7` (UUID format)
- ✅ Claude invoked with `--allowedTools Read,Write,Glob,Grep`
- ✅ Output file created: `003/02-understanding/005/understanding.md`
- ✅ Metadata saved with correct source references
- ✅ Current symlink updated: `02-understanding/current → 005`
- ✅ Pipeline.log entry created

**3-Second Hook Identified:**
```
Close-up of lit cigarette being slowly pushed into slice of apple pie,
ember glowing orange against golden crust, smoke rising, filling the frame.

Why this stops scrolling:
- Weird/unexpected: Immediately confusing - why is someone destroying food?
- Visual spectacle: Striking image - cigarette ember vs pie, rising smoke
- Creates questions: Who's doing this? Why? What's happening?
- Tension: Clearly an aggressive act, signals conflict
```

**Visual Analysis:**
- ✅ Identified showable elements: bikers' intimidation, old man's stone face, physical acts of disrespect
- ✅ Noted what won't work: Internal thoughts, implied roughness (needs explicit details)
- ✅ Dialogue potential: Waitress's deadpan punchline as spoken line

### Key Code

**Prompt Template:** [prompts/stage_understanding.txt](../prompts/stage_understanding.txt)

**Session invocation in run_stage_v3.py:**
```python
# Load session ID (one per story, reused for all stages/takes)
session_file = story_path / "session.txt"
session_id = session_file.read_text().strip()

# Run Claude with tools enabled for file operations
result = subprocess.run(
    [
        "claude",
        "--session-id", session_id,
        "--allowedTools", "Read,Write,Glob,Grep",
        "-p", prompt
    ],
    capture_output=True,
    text=True,
    cwd=str(iter_path),
    timeout=600
)

---

## Stage 2: Story Understanding (02-understanding)

**Status:** ✅ COMPLETED

**Script:** `scripts/run_stage_v3.py` with `--stage understanding`

**Purpose:** Analyze the source story for visual adaptation potential, identify what works for video, and establish creative direction.

### Usage

```bash
# First iteration
./venv/bin/python scripts/run_stage_v3.py "truck-stop-revenge" --stage understanding

# With user feedback
./venv/bin/python scripts/run_stage_v3.py "truck-stop-revenge" --stage understanding --feedback "Focus more on the revenge payoff"
```

### What It Does

1. Loads prompt template from [prompts/stage_understanding.txt](../prompts/stage_understanding.txt)
2. Fills context with story content
3. Creates new session ID for the entire story workflow
4. Saves prompt to `02-understanding/{iteration}/prompt.txt`
5. Invokes Claude with session ID
6. Claude reads `../01-story/current/story.md` and writes `understanding.md`
   - Retells story in own words
   - Identifies what makes it work (comedic mechanism, dramatic tension)
   - Analyzes visual potential (what CAN be shown vs what's just words)
   - Identifies what WON'T work (perceptual illusions, complex transformations)
   - Establishes the 3-second hook requirement
7. Creates metadata.json
8. Updates current symlink
9. Logs to pipeline.log

### Output Format

`02-understanding/{iteration}/understanding.md`:

```markdown
# Story Understanding: [Title]

## Retelling

[Claude's retelling of the story in own words]

## What Makes It Work

[Analysis of comedic/dramatic mechanism]

## Visual Potential

**Can be shown:**
- [List of elements that work visually]

**Cannot be shown:**
- [List of elements that won't work in video]

## 3-Second Hook

[Critical opening moment that creates immediate emotional response]

## Adaptation Strategy

[How to structure as 45-60 second video]
```

### Testing Completed

**Test Story:** truck-stop-revenge

**Results:**
- ✅ Session ID created and reused for all subsequent stages
- ✅ Understanding.md created with thorough analysis
- ✅ Identified 3-second hook (cigarette being stubbed into pie)
- ✅ Metadata saved
- ✅ Current symlink updated

---

## Stage 3: Script Generation (03-script)

**Status:** ✅ COMPLETED

**Script:** `scripts/run_stage_v3.py` with `--stage script`

**Purpose:** Create detailed scene-by-scene script with dialogue, camera movements, and audio specifications for 45-60 second video.

### Usage

```bash
# First iteration
./venv/bin/python scripts/run_stage_v3.py "truck-stop-revenge" --stage script

# With user feedback
./venv/bin/python scripts/run_stage_v3.py "truck-stop-revenge" --stage script --feedback "Extend the tension in middle scenes"
```

### What It Does

1. Loads prompt template from [prompts/stage_script.txt](../prompts/stage_script.txt)
2. Fills context from understanding stage
3. **Loads same session ID** (Claude has context from Stage 2 understanding analysis)
4. Saves prompt to `03-script/{iteration}/prompt.txt`
5. Invokes Claude with same session
6. Claude reads `../02-understanding/current/understanding.md` and writes `script.json`
   - Uses the 3-second hook as scene 1 (CRITICAL requirement)
   - Maintains full context from Stage 2
7. Validates JSON output exists and is valid
8. Creates metadata.json
9. Updates current symlink
10. Logs to pipeline.log

### Output Format

`03-script/{iteration}/script.json`:

```json
{
  "title": "Story Title",
  "style": {
    "visual": "noir-cinematic",
    "genre": "comedy",
    "tone": "dark-comedy",
    "reference": "Coen Brothers aesthetic",
    "rationale": "Why this style fits the story"
  },
  "target_duration": 58,
  "scenes": [
    {
      "number": 1,
      "duration": 5,
      "action": "Single clear action",
      "what_is_shown": "Literal visual description",
      "shot_type": "extreme-close-up",
      "camera": "static",
      "dialogue": {
        "character": "character_id",
        "text": "Exact words spoken",
        "delivery": "mocking, cruel laugh"
      },
      "audio_ambient": "Background sounds, music"
    }
  ],
  "characters": [
    {
      "id": "character_id",
      "appears_in": [1, 2, 3],
      "speaking_in": [2]
    }
  ],
  "total_duration": 58
}
```

### Script Requirements

**Overall Structure:**
- Target duration: 45-60 seconds
- Scene count: 6-10 scenes (most stories need 7-9)
- Scene duration: 5-10 seconds each (Kling AI optimal range)
- **First scene MUST be the 3-second hook** from understanding

**Scene Design Principles:**
1. **ONE action per scene** - Not "walks in, sits down, and talks" but three separate scenes
2. **Literal visual descriptions** - Not "feels defeated" but "shoulders slump, eyes downcast"
3. **Simple camera movements** - static, slow-push, slow-pull, orbit
4. **Dialogue kept short** - 1-2 sentences maximum per scene
5. **Separate dialogue from ambient audio** - dialogue vs audio_ambient fields

**Visual Style Selection:**
- Choose from creative references in CLAUDE.md or create custom approach
- Consider story tone, setting, target audience
- Can reference directors (Michael Bay, Tarantino, Wes Anderson, etc.)
- Not limited to templates - can combine or invent new approaches

### Testing (Completed)

**Test Story:** truck-stop-revenge

**Test 1: First iteration**
```bash
./venv/bin/python scripts/run_stage_v3.py "truck-stop-revenge" --stage script
```

**Results:**
- ✅ Session ID reused from Stage 1 (full context preserved)
- ✅ Prompt referenced understanding.md correctly
- ✅ Output file created: `003/03-script/002/script.json`
- ✅ Valid JSON structure
- ✅ Metadata saved
- ✅ Current symlink updated: `03-script/current → 002`

**Script Analysis:**

**Style Selection:**
```json
{
  "visual": "noir-cinematic",
  "genre": "comedy",
  "tone": "dark-comedy",
  "reference": "Coen Brothers aesthetic - deadpan humor with dramatic noir lighting",
  "rationale": "Noir lighting adds drama, deadpan delivery matches old man's silent badass energy"
}
```

**Scene 1 (3-Second Hook):**
```json
{
  "number": 1,
  "duration": 5,
  "action": "Cigarette pushed into pie",
  "what_is_shown": "Extreme close-up of lit cigarette being slowly pushed into slice of apple pie, ember glowing orange against golden crust, smoke rising, filling frame",
  "shot_type": "extreme-close-up",
  "camera": "static",
  "dialogue": null,
  "audio_ambient": "Sizzling sound, tense music sting, quiet diner ambience"
}
```

✅ Hook correctly used from understanding.md
✅ Visually striking, weird/unexpected - stops scrolling

**Scene Structure:**
- 9 scenes total (target: 6-10) ✅
- Total duration: 58 seconds (target: 45-60) ✅
- Scene durations: 5-8 seconds each (optimal for Kling) ✅
- Single action per scene ✅

**Dialogue Examples:**

Scene 2 (Biker):
```json
{
  "character": "biker_1",
  "text": "Oops.",
  "delivery": "mocking, cruel laugh after speaking"
}
```

Scene 8 (Waitress punchline):
```json
{
  "character": "waitress",
  "text": "Not much of a truck driver either. He just backed his truck over three motorcycles.",
  "delivery": "completely deadpan, dry, matter-of-fact, no emotion"
}
```

✅ Short (1-2 sentences max)
✅ Delivery style specified
✅ Separated from ambient audio

**Character Tracking:**
```json
{
  "id": "waitress",
  "appears_in": [6, 7, 8],
  "speaking_in": [8]
}
```

✅ Tracks appearances and speech scenes

**Scene Progression:**
1. Hook (cigarette in pie) → 2. Reveal bikers → 3-4. Escalating humiliation → 5. Old man's silent exit → 6. Bikers mock → 7. Sound of crunching metal → 8. Waitress punchline → 9. Reveal destroyed motorcycles

✅ Perfect dramatic arc with satisfying payoff

### Key Code

**Prompt Template:** [prompts/stage_script.txt](../prompts/stage_script.txt)

**Key sections:**
- Opening requirement: Scene 1 MUST be the 3-second hook
- Scene design principles (ONE action, literal descriptions)
- Visual style selection guidance
- JSON output format with dialogue/audio separation
- Common mistakes to avoid

**Stage configuration in run_stage_v3.py:**
```python
STAGE_CONFIG = {
    "script": {
        "dir": "03-script",
        "prompt_template": "prompts/stage_script.txt",
        "output_file": "script.json",
        "description": "Create scene-by-scene script with dialogue"
    }
}
```

---

## Stage 4: Assets Identification (04-assets)

**Status:** ✅ COMPLETED

**Script:** `scripts/run_stage_v3.py` with `--stage assets`

**Purpose:** Identify all characters, locations, and objects needed for video generation AND determine which need reference images.

### Usage

```bash
# First iteration
./venv/bin/python scripts/run_stage_v3.py "truck-stop-revenge" --stage assets

# With user feedback
./venv/bin/python scripts/run_stage_v3.py "truck-stop-revenge" --stage assets --feedback "Add more detail to biker descriptions"
```

### What It Does

1. Loads prompt template from [prompts/stage_assets.txt](../prompts/stage_assets.txt)
2. Fills context from script stage
3. **Loads same session ID** (Claude has context from Stages 1-2)
4. Saves prompt to `04-assets/{iteration}/prompt.txt`
5. Invokes Claude with same session
6. Claude reads `../03-script/current/script.json` and writes `assets.json`
   - Identifies every character, location, object
   - **Determines if each needs reference image**
   - Maintains full context from previous stages
7. Validates JSON output exists and is valid
8. Creates metadata.json
9. Updates current symlink
10. Logs to pipeline.log

### Output Format

`04-assets/{iteration}/assets.json`:

```json
{
  "needs_references": false,
  "reference_summary": "All assets are generic...",
  "characters": [
    {
      "id": "old_man",
      "age": "late 50s to early 60s",
      "gender": "male",
      "appearance": "Detailed physical description",
      "clothing": "Specific clothing details",
      "appears_in": [2, 3, 4, 5],
      "speaking_in": [],
      "needs_reference": false,
      "reference_reason": null
    }
  ],
  "locations": [
    {
      "id": "diner_interior",
      "type": "interior - roadside diner",
      "description": "Detailed visual description",
      "time_of_day": "day or dusk",
      "key_features": "Counter, window, floor",
      "appears_in": [1, 2, 3],
      "needs_reference": false,
      "reference_reason": null
    }
  ],
  "objects": [
    {
      "id": "apple_pie",
      "description": "Detailed object description",
      "importance": "critical",
      "appears_in": [1, 2],
      "state_changes": {"scene_1": "pristine", "scene_1_end": "cigarette in it"},
      "needs_reference": false,
      "reference_reason": null
    }
  ]
}
```

### Reference Strategy

**Key innovation in V3:** Assets are evaluated for whether they need reference images.

**NanoBanana can generate from description:**
- Generic people (old man, waitress, biker)
- Common locations (diner, parking lot, office)
- Standard objects (pie, glass, plate, car)

**Must have reference images:**
- Real historical figures (cosmonaut Lebedev, politicians)
- Specific cultural objects (25 rouble bill, specific uniform)
- Famous locations (Red Square, Eiffel Tower)
- Trademarked items (Harley-Davidson logo)

**Workflow decision:**
- `needs_references: false` → **Skip Stage 5** (reference search) → Proceed to Stage 6 (characteristic)
- `needs_references: true` → **Run Stage 5** → Search and select references → Use in Stage 6

### Testing (Completed)

**Test Story:** truck-stop-revenge

**Test 1: First iteration**
```bash
./venv/bin/python scripts/run_stage_v3.py "truck-stop-revenge" --stage assets
```

**Results:**
- ✅ Session ID reused from Stages 1-2 (full context preserved)
- ✅ Prompt referenced script.json correctly
- ✅ Output file created: `003/04-assets/002/assets.json`
- ✅ Valid JSON structure
- ✅ Metadata saved
- ✅ Current symlink updated: `04-assets/current → 002`

**Assets Analysis:**

**needs_references:** `false` ✅
- All assets are generic American roadside diner archetypes
- No historical figures, specific currency, famous landmarks, or branded items
- Can generate everything from descriptions alone

**Characters identified:** 5
- old_man (non-speaking)
- biker_1, biker_2, biker_3 (2 speaking)
- waitress (speaking, deadpan punchline)

All speaking characters have:
- ✅ voice_description field
- ✅ emotional_range mapping to scenes

**Locations identified:** 2
- diner_interior (scenes 2,3,4,5,7,9)
- diner_exterior_parking_lot (scenes 6,8)

**Objects identified:** 9
- **Critical:** pie_slice, cigarette, glass_of_milk, plate_with_food, harley_motorcycles, semi_truck
- **Atmospheric:** counter_stool, diner_counter, diner_window

All critical objects have state_changes tracking.

**Reference decisions:**
- ✅ Every asset has `needs_reference` field
- ✅ Every asset has `reference_reason` (null for false)
- ✅ Top-level `needs_references` correctly set to false
- ✅ `reference_summary` explains why no references needed

**Next stage decision:**
Since `needs_references = false`, workflow will **skip Stage 5** and proceed directly to Stage 6 (Characteristic Shot).

### Key Code

**Prompt Template:** [prompts/stage_assets.txt](../prompts/stage_assets.txt)

**Key sections:**
- Reference strategy explanation
- When to mark needs_reference=true
- Character/location/object requirements
- Top-level needs_references flag
- Workflow impact section

**Stage configuration in run_stage_v3.py:**
```python
STAGE_CONFIG = {
    "assets": {
        "dir": "04-assets",
        "prompt_template": "prompts/stage_assets.txt",
        "output_file": "assets.json",
        "description": "Identify characters, locations, objects and reference needs"
    }
}
```

---

## Stage 5: Reference Search (05-references)

**Status:** ⚠️ **NOT YET IMPLEMENTED** - Will be built when needed for stories requiring specific references.

**Script:** `scripts/run_stage_v3.py --stage references` (to be created)

**Purpose:** Search for and select reference images for characters, locations, and objects that need visual anchors.

### When to Use

Stage 4 is **conditional** - only run when `needs_references: true` in assets.json (Stage 3 output).

**Skip Stage 4 if:**
- Generic characters (e.g., "old man", "waitress") - NanoBanana can generate
- Common locations (e.g., "diner interior", "parking lot") - NanoBanana can generate
- Simple objects (e.g., "cigarette", "pie") - NanoBanana can generate

**Run Stage 4 if:**
- Real historical figures (e.g., "cosmonaut Lebedev") - need photo references
- Specific cultural objects (e.g., "25 ruble coin", "1960s LaFrance fire truck") - need visual references
- Distinctive locations (e.g., "Eiffel Tower", "Red Square") - need photo references

### How It Will Work (Future Implementation)

For each asset marked `needs_reference: true` in assets.json:

1. **Claude generates search query**
2. **Yandex Images API search** - top 5 results
3. **User selects reference** - saved to `05-references/{asset_id}/selected.jpg`
4. **References used in Stage 6 and Stage 7**

**Note:** To be implemented when first story needs references.

---

## Stage 6: Characteristic Shot (06-characteristic)

**Status:** ⚠️ **NOT YET FULLY DOCUMENTED** - Basic implementation exists.

**Script:** `scripts/run_stage_v3.py --stage characteristic`

**Purpose:** Generate one characteristic reference image that establishes visual style for entire story.

### Why It Exists

The characteristic shot is the **visual anchor**:
- Establishes character appearances
- Establishes visual style (lighting, color palette)
- Establishes location aesthetic
- Used as reference for grid generation (Stage 7)

**Gate:** User must approve before proceeding to grid.

**Note:** Full documentation to be added when testing Stage 5 in production workflow.

---

## Stage 7: Grid Generation (07-grids)

**Script:** `scripts/run_stage_v3.py --stage grid`

**Purpose:** Generate a 3x3 or 4x4 grid of reference frames - one frame for each scene.

### Why Grid Generation Exists

The grid creates visual consistency for all scenes before video generation:
- Each frame = one scene's starting frame
- Grid ensures character/location/object consistency across all scenes
- NanoBanana generates all frames at once → same style, lighting, character appearance
- Grid becomes reference for video generation (Kling AI animates from these frames)

### Key Innovation: Two-Format Strategy

Grid generation supports TWO different prompt formats:

**FORMAT A - Full Detailed Descriptions (no reference)**
- Used when generating grid from scratch (iteration 001)
- Used when user feedback indicates complete regeneration needed
- Full frame-by-frame descriptions with all details

**FORMAT B - Simplified Reference-Based (with reference)**
- Used when previous iteration exists and feedback is minor tweaks
- Previous grid passed as reference image to NanoBanana
- Prompt only describes what needs to change
- Much shorter, more focused, better results

### Decision Logic

Claude reads `generation_config.json` and decides which format to use:

**Use FORMAT B (reference-based) if:**
- User feedback mentions specific frame numbers (e.g., "Frame 5 needs...")
- User says "I like it but..." or "almost perfect"
- Feedback requests changes to 1-3 specific frames
- Previous grid mostly correct, just needs tweaks

**Use FORMAT A (full descriptions) if:**
- User says grid is "completely wrong"
- Different story shown
- Style is incorrect
- Needs total regeneration

### generation_config.json

Claude creates this file to control reference usage:

```json
{
  "use_reference_grid": true,
  "reference_grid_path": "../009/grid.jpg",
  "reference_strength": 0.6,
  "reasoning": "User feedback requests minor corrections to 2 frames. Previous grid has excellent composition and consistency."
}
```

**reference_strength guide:**
- 0.3-0.4: Light reference, more freedom
- 0.5-0.6: Moderate reference, keep structure
- 0.7-0.8: Strong reference, minimal changes

Note: NanoBanana API doesn't actually support reference_strength parameter - the model interprets reference + text prompt together without strength control. The parameter is tracked for documentation purposes.

### FORMAT A Example (Full Descriptions)

```
3x3 grid of cinematic storyboard frames in 9:16 vertical format.

[VISUAL STYLE from characteristic shot]

FRAME-BY-FRAME DESCRIPTIONS:

FRAME 1 (top-left): Extreme close-up of lit cigarette with glowing orange ember being pushed into golden-brown apple pie crust, smoke rising. Focus on moment of contact - ember meeting crust. Dramatic noir lighting, high contrast, ember glowing bright.

FRAME 2 (top-center): Medium shot of weathered trucker (late 50s, gray beard, worn baseball cap) sitting at diner counter. In foreground: white plate with apple pie, cigarette butt extinguished and stuck into crust with small wisp of smoke. Glass of milk beside plate. Trucker's expression shows disgust and simmering rage. SAME diner aesthetic as Frame 1.

... [continue for all 9 frames with full details]

NEGATIVE PROMPT: inconsistent character appearances, different visual styles, warped faces, distorted hands, watermarks, text
```

### FORMAT B Example (Reference-Based)

```
3x3 grid in 9:16 vertical format (3072x5504 resolution).

REFERENCE IMAGE PROVIDED: This grid was generated for this exact story and is generally correct. Use it as the base and apply ONLY the specific changes listed below.

**KEEP ALL FRAMES EXACTLY AS IN REFERENCE EXCEPT:**

**FRAME 5 (middle-center):**
Same composition as reference frame 5, but: exactly THREE bikers visible in background (not four), laughing and mocking. Three bikers only.

**FRAME 6 (middle-right):**
Same composition as reference frame 6, but: large semi-truck with BRIGHT WHITE REVERSE LIGHTS illuminated, actively BACKING UP into motorcycles. Truck dominates right side, rear end visible with glowing lights. Motorcycles being impacted - falling/toppling. MOMENT OF IMPACT.

**ALL OTHER FRAMES:** Keep exactly as shown in reference (Frames 1, 2, 3, 4, 7, 8, 9).

Maintain photorealistic cinematic style from reference.

NEGATIVE PROMPT: cartoon, anime, four bikers in frame 5, no truck in frame 6, watermark, text overlay
```

### Lessons Learned (Truck-Stop-Revenge Story)

**Iteration Strategy Works:**
- Started with full descriptions (iteration 002)
- Iterations 003-009: Progressive refinement with reference grid
- Each iteration fixed 1-3 specific frames
- Result: Excellent consistency maintained while fixing specific issues

**Key Changes Across Iterations:**
1. **Iteration 004**: Fixed aspect ratio bug (1:1 → 9:16)
2. **Iteration 005**: Cigarette positioning and plate/glass consistency
3. **Iteration 006**: User: "I like it but..." - first use of reference strategy
4. **Iteration 007-009**: Minor frame-specific tweaks using FORMAT B
5. **Iteration 010-011**: Final refinements (biker count, truck visibility)

**Critical Details That Needed Multiple Attempts:**
- **Frame 2**: Cigarette butt state (lit vs extinguished with smoke)
- **Frame 5**: Number of bikers (4 → 3)
- **Frame 6**: Truck visibility (must show truck backing into bikes)
- **Frame 8**: Destruction aftermath (no truck, only destroyed bikes)
- **Frames 7, 9**: Background consistency (destroyed bikes visible through windows)

**What Works:**
- Explicit negative prompts ("NOT a lit cigarette", "NOT four bikers")
- Simplified prompts with reference work better than detailed prompts
- "SAME composition as reference frame N, but: [specific change]" pattern
- Stating "REFERENCE IMAGE PROVIDED" and "This grid was generated for this exact story" helps model understand

**What Doesn't Work:**
- Relying on reference_strength parameter (API doesn't support it)
- Assuming consistency without explicit reminders
- Vague descriptions like "truck destroying bikes" → must specify "truck with white reverse lights, actively backing into bikes, moment of impact"

### Orchestration Flow

1. **run_stage_v3.py** creates directory, writes prompt with template
2. **Claude** reads:
   - Original script: `../03-script/current/script.json`
   - Characteristic shot: `../06-characteristic/current/characteristic_shot.jpg`
   - Previous grid (if exists): `../07-grids/{previous_iteration}/grid.jpg`
   - Previous feedback (if exists): `feedback.md`
3. **Claude** creates:
   - `generation_config.json` (reference decision)
   - `nanobanana_prompt.txt` (FORMAT A or B)
4. **run_stage_v3.py** auto-calls `generate_grid_image_v3.py`
5. **generate_grid_image_v3.py**:
   - Reads `generation_config.json`
   - If `use_reference_grid: true`, loads reference image
   - Calls `nanobanana_text2img.py` with reference (if applicable)
6. **NanoBanana** generates grid image
7. User reviews, provides feedback if needed

### Testing Grid Generation

```bash
# First iteration (from scratch)
./venv/bin/python microdrama-v2/scripts/run_stage_v3.py truck-stop-revenge --take 003 --stage grid

# With feedback (will use reference if appropriate)
./venv/bin/python microdrama-v2/scripts/run_stage_v3.py truck-stop-revenge --take 003 --stage grid --feedback "Frame 5: three bikers not four"

# View generated grid
open /mnt/video/microdrama-v3/truck-stop-revenge/003/07-grids/current/grid.jpg
```

### Grid Layout Decision

Claude decides layout based on scene count:
- **9 scenes or fewer** → 3x3 grid (9 frames)
- **10-16 scenes** → 4x4 grid (16 frames)
- **More than 16** → Multiple grids or script simplification needed

### Common Issues

**Issue**: Grid shows wrong story or random scenes
**Solution**: Use feedback: "Grid shows completely wrong story" → Claude will regenerate from scratch (FORMAT A)

**Issue**: Character inconsistency between frames
**Solution**: Use feedback specifying exact differences → Claude will use FORMAT B with corrections

**Issue**: Specific object/action wrong in one frame
**Solution**: Use FORMAT B: "Frame N: [describe exact change needed]"

**Issue**: Style completely different from characteristic shot
**Solution**: Regenerate from scratch with stronger reference to characteristic shot

---

## Stage 8: Frame Extraction (08-frames)

**Status:** ✅ COMPLETED

**Type:** Scene-based (per-scene selections in pipeline.json)

**Script:** `stages/stage_08_frames.py` (via orchestrator_v5.py)

**Purpose:** Extract individual scene frames from grid iterations, then refine per-scene with NanoBanana chat. Users select the best frame for each scene from any grid iteration.

### Usage (via orchestrator)

```bash
# Extract frames from all grid iterations
orchestrator_v5.py . extract-frames

# Refine a specific scene's frame
orchestrator_v5.py . refine-frame 01-scene 003 "Make the lighting warmer, add more shadows"

# Select best frame for a scene
orchestrator_v5.py . select-frame 01-scene 003-refined-002

# Check status of all scenes
orchestrator_v5.py . status-frames
```

### What It Does

1. **Extracts frames** from all grid iterations in `07-grids/`
2. **Creates per-scene directories** under `08-frames/`
3. **Users review and refine** each scene's frame using NanoBanana chat
4. **Users select** the best frame for each scene
5. **Pipeline tracks** selections: `pipeline["08-frames"] = {"01-scene": "003-refined-002", ...}`

### Output Structure

```
{take}/08-frames/
├── extraction_metadata.json      # Grid source, scene count
├── 01-scene/
│   ├── 001.jpg                   # From grid iteration 001
│   ├── 003.jpg                   # From grid iteration 003
│   ├── 003-refined-001.jpg       # First refinement of 003
│   ├── 003-refined-002.jpg       # Second refinement
│   ├── feedback-003.txt          # Refinement feedback history
│   ├── nb-chat-history-003.json  # NanoBanana chat state
│   └── prompt-003.txt            # Original prompt for this frame
├── 02-scene/
│   └── ...
...
└── 16-scene/
    └── ...
```

### Refinement Flow

1. **Initial extraction** — frames cropped from grid images (multiple grid iterations available)
2. **NanoBanana chat refinement** — `refine-frame` sends feedback to NanoBanana's chat API to iteratively improve a frame while maintaining the conversation context
3. **Selection** — user picks the best frame (original or refined) for each scene
4. **All 16 scenes must be selected** before pipeline advances to Stage 09

### Pipeline State

```json
{
  "08-frames": {
    "01-scene": "003-refined-002",
    "02-scene": "005",
    "03-scene": "003-refined-001",
    "...": "..."
  }
}
```

---

## Stage 9: Script Adaptation (09-script-adapted)

**Script:** `stages/stage_09_script_adapted.py` (via orchestrator_v5.py)

**Purpose:** Adapt the original script to match the actual selected and refined frames from Stage 08.

### Why Script Adaptation Exists

**The Problem:**
- Stage 3 creates an ideal script based on story understanding
- Stage 7 generates grids, and users select/refine frames from multiple grid iterations
- The final selected frames may differ significantly from what the script described:
  - Characters may look older/younger
  - Compositions may vary from intended
  - Users may have refined frames with specific feedback
  - Shot types may differ (close-up → medium)

**The Solution:**
After frame selection (Stage 08), reconcile the script with what was actually selected. Claude reads all 16 selected frame images + refinement feedback, then updates the script descriptions to match.

### Key Change from Previous Version

Previously (old Stage 08): Claude viewed a single grid image and analyzed frames in grid layout order. This was inaccurate because users often selected frames from different grid iterations and refined them.

Now (new Stage 09): Claude reads each scene's actual selected frame image + any refinement feedback, producing descriptions that exactly match what will be sent to video generation.

### What Claude Does

1. **Reads all selected frames** from `08-frames/` (the actual images selected per-scene)
2. **Reads refinement feedback** per scene (if frames were refined)
3. **Reads original script** from `03-script/`
4. **Analyzes each frame**:
   - What is ACTUALLY shown in the selected/refined frame?
   - Character appearances (age, clothing, features)
   - How does refinement feedback affect interpretation?
5. **Creates adapted script.json**:
   - SAME structure as original
   - Descriptions updated to match actual selected frames
   - Adds "frame_observations" field noting differences

### Example Adaptation

**Original script (Scene 2):**
```json
{
  "number": 2,
  "duration": 5,
  "action": "Old man stares at ruined pie",
  "what_is_shown": "Close-up of elderly trucker (60s) looking down at pie with cigarette stub.",
  "shot_type": "close-up"
}
```

**Adapted script after viewing selected frame + feedback:**
```json
{
  "number": 2,
  "duration": 5,
  "action": "Old man stares at ruined pie",
  "what_is_shown": "Medium shot of weathered trucker (late 50s, gray beard, worn baseball cap) sitting at diner counter. In foreground: white plate with apple pie, cigarette butt extinguished and stuck into crust with small wisp of smoke. Glass of milk beside plate. Trucker's expression shows disgust and simmering rage.",
  "shot_type": "medium",
  "frame_observations": "Selected frame from grid 005-refined-001. User refined to add warmer diner lighting. Trucker appears late 50s with gray beard (script said 60s). Shot is medium, not close-up."
}
```

---

## Stage 10: Video Generation (10-videos)

**Status:** ✅ COMPLETED

**Script:** `scripts/generate_videos_kling_direct.py`

**Purpose:** Generate videos for each scene using Kling v2.6 Direct API with image-to-video generation and audio support.

### Usage

```bash
# Generate all scenes
./venv/bin/python scripts/generate_videos_kling_direct.py "truck-stop-revenge" --take 003

# Generate specific scene
./venv/bin/python scripts/generate_videos_kling_direct.py "truck-stop-revenge" 3 --take 003

# Regenerate with feedback (creates new iteration)
./venv/bin/python scripts/generate_videos_kling_direct.py "truck-stop-revenge" 3 --take 003 --feedback "biker just finishing spit of saliva in the glass, nothing more. He looks to the oldman with grin smile"
```

### What It Does

1. **Reads scene data** from `09-script-adapted/current/script.json`
2. **Loads selected frame** from `08-frames/{scene_key}/{selected_frame}.jpg`
3. **Determines duration**:
   - Default: 5 seconds
   - If dialogue word count suggests >5s: use 10 seconds
   - Kling v2.6 only supports 5 or 10 second durations
4. **Builds video prompt**:
   - Without feedback: Uses script data (action, visual, audio, dialogue)
   - With feedback: Invokes Claude Code CLI to regenerate prompt
5. **Collects previous feedback** from all iterations
6. **Generates JWT token** for Kling API authentication
7. **Calls Kling API** with image + prompt + duration
8. **Polls for completion** (async task model)
9. **Downloads generated video**
10. **Saves metadata** and updates `current` symlink
11. **Logs to pipeline.log**

### Output Structure

```
003/10-videos/
├── scene_01/
│   ├── 001/
│   │   ├── video.mp4          # Generated video
│   │   ├── prompt.txt         # Prompt used
│   │   └── metadata.json      # Generation details
│   ├── 002/
│   ├── 003/
│   ├── 004/
│   │   ├── video.mp4
│   │   ├── prompt.txt
│   │   ├── feedback.md        # User feedback
│   │   └── metadata.json
│   └── current → 004
├── scene_02/
│   ├── 003/
│   │   ├── video.mp4
│   │   ├── prompt.txt
│   │   └── metadata.json
│   └── current → 003
...
```

### Kling API Integration

**Authentication:**
```python
def generate_jwt_token(access_key: str, secret_key: str) -> str:
    """Generate JWT token for Kling API authentication."""
    headers = {"alg": "HS256", "typ": "JWT"}
    payload = {
        "iss": access_key,
        "exp": int(time.time()) + 1800,  # 30 minutes validity
        "nbf": int(time.time()) - 5       # Not before: 5 seconds ago
    }
    return jwt.encode(payload, secret_key, algorithm="HS256", headers=headers)
```

**API Request:**
```python
payload = {
    "model_name": "kling-v2-6",
    "mode": "pro",                      # Required for audio generation
    "image": image_base64,              # Base64-encoded frame (no data URI prefix)
    "prompt": prompt[:2500],            # Max 2500 chars
    "duration": str(duration),          # Must be string: "5" or "10"
    "sound": "on"                       # Audio generation enabled
}

headers = {
    "Authorization": f"Bearer {jwt_token}",
    "Content-Type": "application/json"
}

response = requests.post(
    f"{KLING_API_BASE}/v1/videos/image2video",
    json=payload,
    headers=headers
)
```

**Polling for completion:**
```python
status_url = f"{KLING_API_BASE}/v1/videos/image2video/{task_id}"
while True:
    response = requests.get(status_url, headers=headers)
    status = response.json()["data"]["task_status"]
    if status == "succeed":
        video_url = response.json()["data"]["task_result"]["videos"][0]["url"]
        break
    elif status == "failed":
        raise RuntimeError("Video generation failed")
    time.sleep(poll_interval)
```

### Prompt Building

**Without feedback (basic prompt):**
```python
def build_video_prompt(scene_data: dict, style_info: dict) -> str:
    lines = []

    # ACTION FIRST - what HAPPENS in the video
    action = scene_data.get("action", "")
    what_is_shown = scene_data.get("what_is_shown", "")
    if action:
        lines.append(f"ACTION: {action}.")
        lines.append(f"SETTING: {what_is_shown}")

    # Camera movement
    camera = scene_data.get("camera", "static")
    if camera != "static":
        lines.append(f"\nCamera: {camera}")

    # Style
    lines.append(f"\nStyle: {style_info['visual']}, {style_info['tone']}, photorealistic")

    # Audio
    audio_ambient = scene_data.get("audio_ambient", "")
    if audio_ambient:
        lines.append(f"\nAudio: {audio_ambient}")

    # Dialogue (without character variable names)
    dialogue = scene_data.get("dialogue")
    if dialogue and dialogue.get("text"):
        delivery = dialogue.get('delivery', 'natural')
        text = dialogue.get('text')
        lines.append(f"\nDialogue spoken with {delivery} delivery: \"{text}\"")

    # Negative prompts
    lines.append("\n\nNO text overlays, NO subtitles, NO watermarks, NO distorted faces or hands")

    return "\n".join(lines)
```

**With feedback (Claude regeneration):**
```python
def regenerate_prompt_with_claude(scene_data, style_info, feedback, previous_feedback):
    """Use Claude Code CLI to regenerate prompt with feedback."""

    # Build feedback section with priority marking
    feedback_section = f"MOST RECENT FEEDBACK (apply this):\n{feedback}"
    if previous_feedback:
        feedback_section += f"\n\nPREVIOUS FEEDBACK (already addressed, for context):\n{previous_feedback}"

    claude_prompt = f"""You are writing a video generation prompt for Kling AI video model.

SCENE CONTEXT:
- Action: {scene_data['action']}
- Visual setting: {scene_data['what_is_shown']}
- Camera: {scene_data['camera']}
- Audio: {scene_data['audio_ambient']}
- Dialogue: {scene_data.get('dialogue', {}).get('text', 'none')}
- Style: {style_info['visual']}, {style_info['tone']}

{feedback_section}

IMPORTANT RULES:
1. Describe the ACTION step-by-step: what happens from start to finish
2. NEVER use character variable names like "biker_2" - describe them (e.g., "biker with slicked hair")
3. Describe physical actions clearly and BRIEFLY - if feedback says "short", make it minimal
4. If feedback says "no dialogue", remove all dialogue
5. Keep it under 500 characters for Kling model
6. Include camera movement if specified
7. End with audio description
8. Add negative prompts: NO text overlays, NO subtitles, NO watermarks
9. Pay attention to scale/intensity words: "SHORT", "SMALL", "BRIEF" mean minimize the action

Write ONLY the final video prompt, nothing else."""

    # Call Claude Code CLI
    session_id = str(uuid.uuid4())
    result = subprocess.run(
        ["claude", "--session-id", session_id, "-p", claude_prompt],
        capture_output=True,
        text=True,
        timeout=120
    )

    return result.stdout.strip()
```

### Feedback System

**Key Innovation:** Feedback accumulates across iterations, with most recent feedback prioritized.

**Feedback collection:**
```python
# Collect all previous feedback from existing iterations
previous_feedback_list = []
for iter_num in sorted(existing_iterations):
    prev_iter_path = video_stage_path / f"{iter_num:03d}"
    prev_feedback_file = prev_iter_path / "feedback.md"
    if prev_feedback_file.exists():
        feedback_text = prev_feedback_file.read_text().replace("# User Feedback", "").strip()
        if feedback_text:
            previous_feedback_list.append(f"Iteration {iter_num:03d}: {feedback_text}")

previous_feedback = "\n\n".join(previous_feedback_list)
```

**Feedback to Claude:**
- **MOST RECENT FEEDBACK (apply this):** Current user feedback
- **PREVIOUS FEEDBACK (already addressed, for context):** All earlier feedback

This ensures Claude:
1. Prioritizes current feedback
2. Knows what was already tried
3. Doesn't repeat rejected approaches

### Duration Logic

```python
duration = 5  # Default

dialogue = scene_data.get("dialogue")
if dialogue and dialogue.get("text"):
    # Estimate dialogue duration: ~150 words per minute = 2.5 words per second
    # Add 30% buffer for natural speech pacing
    dialogue_text = dialogue.get("text", "")
    word_count = len(dialogue_text.split())
    estimated_duration = (word_count / 2.5) * 1.3

    if estimated_duration > 5:
        duration = 10  # Kling only supports 5 or 10
```

### Testing (Completed)

**Test Story:** truck-stop-revenge (9 scenes)

**Test Results:**

**Scene 1:** ✅ 5s, no dialogue
**Scene 2:** ✅ 5s, short dialogue ("Oops")
**Scene 3:** ✅ Multiple iterations with feedback
- Iteration 002: Initial generation
- Iteration 004: Feedback: "biker just spit to the glass..."
- Iteration 005: Feedback: "just short spit, no milk, only saliva..."
- Iteration 006: Feedback: "biker just finishing spit... looks to oldman with grin smile"
- **Feedback accumulation worked** - Claude referenced previous attempts

**Scene 4:** ✅ 5s
**Scene 5:** ✅ 5s
**Scene 6:** ✅ Multiple iterations
- Iteration 003: Initial
- Iteration 004: Feedback: "Truck must move and crush bikes as flies and move away"
- Iteration 005: Feedback: "truck must completely destroy bikes driving over them"
- Switched back to iteration 004 per user preference

**Scene 7:** ✅ Iteration 002
- Feedback: "bikers must be very agitated and swearing in disbelief while bikes crushing"

**Scene 8:** ✅ Multiple iterations
- Iteration 004: Feedback: "5 second length only, bikers running and swearing, 'Stop! Stop!', waving arms, clutching head"
- Iteration 005: Feedback: "no door, just running toward camera in desperate attempt to catch leaving truck"

**Scene 9:** ✅ Iteration 002, 10s (dialogue: 15 words)
- Feedback: "all good, but add, please, distant shouting stop! stop!"
- Waitress punchline: "Not much of a truck driver either. He just backed his truck over three motorcycles."

**Overall Results:**
- ✅ JWT authentication working correctly
- ✅ All 9 scenes generated successfully
- ✅ Duration logic working (5s default, 10s for long dialogue)
- ✅ Feedback system working (accumulation + prioritization)
- ✅ Claude prompt regeneration working
- ✅ Metadata tracking complete
- ✅ Current symlinks updated correctly

**Errors encountered and fixed:**
1. **401 authentication** - Fixed by implementing JWT token generation with HS256
2. **Error 1200 (invalid duration)** - Fixed by rounding to 5 or 10 seconds
3. **Poor prompt with feedback** - Fixed by using Claude Code CLI for regeneration
4. **Character variable names** - Fixed by removing "biker_2" style references
5. **No action description** - Fixed by putting ACTION first in prompts

### Metadata Saved

```json
{
  "stage": "10-videos",
  "take": "003",
  "scene": 3,
  "iteration": "006",
  "timestamp": "2026-02-03T00:35:42Z",
  "source_frame": "../08-frames/03-scene/005-refined-001.jpg",
  "source_script_scene": "../09-script-adapted/current/script.json#scenes[2]",
  "model": "kling-v2-6",
  "api": "direct",
  "api_endpoint": "https://api-singapore.klingai.com",
  "api_task_id": "847410503336607761",
  "duration_requested": 5,
  "duration_actual": 5.041,
  "has_dialogue": true,
  "has_ambient_audio": true,
  "prompt_file": "prompt.txt",
  "feedback_file": "feedback.md",
  "previous_iterations": [2, 4, 5],
  "status": "success"
}
```

### Key Code Locations

- `scripts/generate_videos_kling_direct.py` - Main video generation script
- `prompts/` - Prompt templates (embedded in script for this stage)
- Environment variables:
  - `KLING_ACCESS_KEY` - API access key
  - `KLING_SECRET_KEY` - API secret key for JWT signing
  - `KLING_API_BASE` - API endpoint (default: Singapore endpoint)

---

## Stage 11: Title Cards (11-titles)

**Type:** Scene-based (fixed 2 scenes: intro, outro)

**Script:** `stages/stage_11_titles.py` (via orchestrator_v5.py)

**Purpose:** Generate intro and outro title card images after videos exist, so title content reflects actual episode content.

### Usage (via orchestrator)

```bash
# Generate title cards
orchestrator_v5.py . generate-title intro
orchestrator_v5.py . generate-title outro

# Refine a title card
orchestrator_v5.py . refine-title intro 001 "Make the text bigger, add more contrast"

# Select title cards
orchestrator_v5.py . select-title intro 001-refined-002
orchestrator_v5.py . select-title outro 001

# Check status
orchestrator_v5.py . status-titles
```

### What It Does

1. **Reads adapted script** from `09-script-adapted/` for episode content context
2. **Reads characteristic shot** from `06-characteristic/` for visual style
3. **Claude generates NanoBanana prompt** based on episode content + style
4. **NanoBanana generates** title card image
5. **Users refine** with NanoBanana chat (same as frame refinement)
6. **Users select** intro and outro title cards

### Output Structure

```
{take}/11-titles/
├── intro/
│   ├── 001.jpg                   # First generated title
│   ├── 001-refined-001.jpg       # Refined version
│   ├── prompt-001.txt            # NanoBanana prompt
│   ├── nb-chat-history-001.json  # Chat state for refinement
│   └── metadata.json
└── outro/
    ├── 001.jpg
    ├── prompt-001.txt
    └── metadata.json
```

### Pipeline State

```json
{
  "11-titles": {
    "intro": "001-refined-002",
    "outro": "001"
  }
}
```

Both intro and outro must be selected before pipeline advances to Stage 12.

### Content

- **Intro:** Episode title + series name + artistic design matching the episode's visual style
- **Outro:** "To be continued" / credits design matching style

---

## Stage 12: Captions (12-captions)

**Type:** Scene-based

**Script:** `stages/stage_12_captions.py` (via orchestrator_v5.py)

**Purpose:** Generate timed caption overlays for scenes that need text on screen (non-dialogue text like "THE END", location titles, etc.).

### Usage (via orchestrator)

```bash
# Generate captions
orchestrator_v5.py . generate-captions

# Run with accept
orchestrator_v5.py . run --accept  # when pipeline is at 12-captions
```

### What It Does

1. **Reads original script** from `03-script/` for caption timing/text
2. **Reads selected videos** from `10-videos/` for duration verification
3. **Generates caption overlay data** (timing, position, text)
4. **Saves caption config** for assembly stage to apply

### When Captions Are Needed

- **NOT needed** when dialogue is generated as audio by Kling v2.6
- **Needed** for non-dialogue text overlays: "THE END", "2 HOURS LATER", location titles

---

## Stage 13: Assembly (13-assembly)

**Status:** ✅ COMPLETED

**Script:** `stages/stage_13_assembly.py` (via orchestrator_v5.py)

**Purpose:** Concatenate all scene videos into final output with intro/outro title cards and optional timed caption overlays.

### Usage (via orchestrator)

```bash
# Assemble final video
orchestrator_v5.py . assemble

# Or via run command when pipeline is at this stage
orchestrator_v5.py . run --accept

# Scene ordering commands
orchestrator_v5.py . scene-order              # Show current assembly order
orchestrator_v5.py . swap-scenes 07-scene 08-scene  # Swap two scenes
orchestrator_v5.py . scene-order --reset      # Reset to default numeric order
```

### Scene Ordering

By default, assembly sorts scenes numerically (01-scene, 02-scene, ...). A custom `scene_order` can be set in `pipeline.json` to reorder scenes for assembly without renaming directories.

**Pipeline state:**
```json
{
  "scene_order": ["01-scene", "02-scene", ..., "08-scene", "07-scene", ..., "16-scene"]
}
```

**Commands:**
- `scene-order` — show current order (custom or default)
- `swap-scenes <a> <b>` — swap two scenes; initializes custom order from default if not yet set
- `scene-order --reset` — delete custom order, revert to numeric sort

When `scene_order` exists, assembly uses it (filtering to only scenes with video selections). Any selected scenes missing from the custom order are appended at the end as a safety fallback.

### What It Does

1. **Reads scene videos** from `10-videos/` (per-scene selections from pipeline)
2. **Reads adapted script** from `09-script-adapted/current/script.json`
3. **Reads intro title card** from `11-titles/intro/` (with legacy fallback to `episode_title_card.jpg`)
4. **Reads outro title card** from `11-titles/outro/` (optional)
5. **Applies custom scene order** if `scene_order` exists in pipeline.json
6. **Checks for captions_overlay field** (optional for non-dialogue text)
7. **Normalizes clips** to consistent format:
   - Video: libx264, CRF 18, yuv420p pixel format
   - Audio: AAC, 192k bitrate
5. **Adds timed captions** if captions_overlay exists in script (using FFmpeg drawtext filter)
6. **Concatenates videos** using FFmpeg concat demuxer
7. **Prepends intro title card** (from `11-titles/intro/` or legacy `episode_title_card.jpg`)
8. **Appends outro title card** (from `11-titles/outro/`, if available)
9. **Saves final video** to `13-assembly/{iteration}/final.mp4`
8. **Creates metadata** and logs to pipeline.log

### Output Structure

```
003/13-assembly/
├── 001/
│   ├── final.mp4              # Final assembled video
│   ├── concat.txt             # FFmpeg concat file list
│   ├── metadata.json          # Assembly details
│   └── temp/                  # Normalized/captioned clips
│       ├── scene_01_normalized.mp4
│       ├── scene_02_normalized.mp4
│       ...
└── current → 001
```

### Caption System

**Optional feature** - captions_overlay only needed for non-dialogue text overlays.

**When captions are NOT needed:**
- Dialogue is generated as audio by Kling v2.6
- Example: truck-stop-revenge has all dialogue in audio, no captions needed

**When captions ARE needed:**
- Non-dialogue text: "THE END", "2 HOURS LATER", location titles
- Must be defined in script.json

**Caption format in script.json:**
```json
{
  "captions_overlay": [
    {
      "scene": 1,
      "text": "Meanwhile, 2000 miles away...",
      "timing": "0-3s",
      "position": "top"
    },
    {
      "scene": 9,
      "text": "THE END",
      "timing": "3-5s",
      "position": "bottom"
    }
  ]
}
```

**Caption implementation:**
- Uses FFmpeg drawtext filter with enable parameter for timing
- Positions: "top", "bottom", "center"
- Font: DejaVuSans-Bold
- Background: semi-transparent black box
- Escape handling for special characters

### Normalization

All clips normalized before concatenation to prevent:
- Resolution mismatches
- Encoding differences
- Audio sync issues
- Video freezing

**Normalization settings:**
```bash
ffmpeg -i input.mp4 \
  -c:v libx264 -preset fast -crf 18 \
  -c:a aac -b:a 192k \
  -pix_fmt yuv420p \
  output.mp4
```

### Concatenation

Uses FFmpeg concat demuxer for seamless concatenation:

```bash
# concat.txt format
file '/absolute/path/to/scene_01_normalized.mp4'
file '/absolute/path/to/scene_02_normalized.mp4'
...

# Concatenation command
ffmpeg -f concat -safe 0 -i concat.txt -c copy final.mp4
```

Stream copy used since all clips already normalized to same format.

### Testing (Completed)

**Test Story:** truck-stop-revenge

**Test 1: Assembly without captions**
```bash
./venv/bin/python scripts/assemble_video_v3_kling.py "truck-stop-revenge" --take 003
```

**Results:**
- ✅ All 9 scenes concatenated successfully
- ✅ Duration: 50.4 seconds (within 45-60s target)
- ✅ Resolution: 1084x1908 (Kling output format)
- ✅ Size: 35.0MB
- ✅ No caption overlay (correct - dialogue is in audio)
- ✅ Metadata saved with complete source tracking
- ✅ Current symlink updated: `13-assembly/current → 001`
- ✅ Pipeline.log entry created

**Output stats:**
```
Assembling video:
  Story: truck-stop-revenge
  Take: 003

Assembly iteration: 001

Scene 1: No caption [5.0s]
Scene 2: No caption [5.0s]
Scene 3: No caption [5.0s]
Scene 4: No caption [5.0s]
Scene 5: No caption [5.0s]
Scene 6: No caption [5.0s]
Scene 7: No caption [5.0s]
Scene 8: No caption [5.0s]
Scene 9: No caption [10.0s]

Assembling 9 clips...

============================================================
Final video assembled!
============================================================
Output: /mnt/video/microdrama-v3/truck-stop-revenge/003/13-assembly/001/final.mp4
Duration: 50.4s
Resolution: 1084x1908
Size: 35.0MB
Scenes: 9
```

**Validation:**
- ✅ FFmpeg concat completed without errors
- ✅ Audio synchronized correctly
- ✅ No visual artifacts or freezing
- ✅ Smooth transitions between scenes
- ✅ All scene videos preserved in temp/ for inspection

### Metadata Saved

```json
{
  "stage": "13-assembly",
  "take": "003",
  "iteration": "001",
  "timestamp": "2026-02-03T01:15:23Z",
  "source_script": "../09-script-adapted/current/script.json",
  "scene_count": 9,
  "scenes_used": [1, 2, 3, 4, 5, 6, 7, 8, 9],
  "caption_count": 0,
  "captioned_scenes": [],
  "final_duration": 50.4,
  "final_resolution": "1084x1908",
  "final_size_mb": 35.0,
  "output_file": "final.mp4",
  "concat_method": "ffmpeg_concat_demuxer",
  "normalization_applied": true
}
```

### Key Code

**Caption overlay function:**
```python
def add_timed_caption(
    input_path: Path,
    output_path: Path,
    text: str,
    start_time: float,
    end_time: float,
    font_size: int = 42,
    font_color: str = "white",
    bg_color: str = "black@0.7",
    position: str = "bottom",
) -> bool:
    """Add timed caption overlay to video using FFmpeg."""

    # Escape special characters for FFmpeg drawtext filter
    escaped_text = (
        text.replace("\\", "\\\\")
        .replace("'", "\u2019")  # Apostrophe → right single quotation mark
        .replace(":", "\\:")
        .replace("%", "\\%")
    )

    # FFmpeg drawtext filter with enable for timing
    filter_str = (
        f"drawtext=text='{escaped_text}':"
        f"fontsize={font_size}:"
        f"fontcolor={font_color}:"
        f"x=(w-tw)/2:"
        f"y={y_pos}:"
        f"box=1:"
        f"boxcolor={bg_color}:"
        f"boxborderw=15:"
        f"fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf:"
        f"enable='between(t,{start_time},{end_time})'"
    )

    cmd = [
        "ffmpeg", "-y",
        "-i", str(input_path),
        "-vf", filter_str,
        "-c:a", "copy",
        str(output_path)
    ]

    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    return result.returncode == 0
```

**Normalization function:**
```python
def normalize_clip(input_path: Path, output_path: Path) -> bool:
    """Normalize clip to consistent format for concatenation."""
    cmd = [
        "ffmpeg", "-y",
        "-i", str(input_path),
        "-c:v", "libx264", "-preset", "fast", "-crf", "18",
        "-c:a", "aac", "-b:a", "192k",
        "-pix_fmt", "yuv420p",  # Ensure consistent pixel format
        str(output_path)
    ]

    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    return result.returncode == 0
```

**Concatenation function:**
```python
def concatenate_videos(video_paths: list, output_path: Path) -> bool:
    """Concatenate videos using FFmpeg concat demuxer."""

    # Create concat file
    concat_file = output_path.parent / "concat.txt"
    with open(concat_file, 'w') as f:
        for path in video_paths:
            f.write(f"file '{path.resolve()}'\n")

    cmd = [
        "ffmpeg", "-y",
        "-f", "concat", "-safe", "0",
        "-i", str(concat_file),
        "-c", "copy",  # Stream copy since all clips normalized
        str(output_path)
    ]

    result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
    return result.returncode == 0
```

### Logging Integration

```python
# Log to pipeline.log
from lib import log_assembly

log_assembly(
    story_name,
    take_num,
    iteration,
    len(scenes),
    final_duration,
    size_mb
)
```

**JSONL log entry format:**
```json
{
  "timestamp": "2026-02-03T01:15:23",
  "event": "assembly",
  "action": "assemble_video",
  "take": "003",
  "stage": "13-assembly",
  "iteration": "001",
  "scene_count": 9,
  "duration_seconds": 50.4,
  "size_mb": 35.0,
  "status": "completed"
}
```

### Error Handling

**Graceful degradation:**
- If caption fails → normalize original and use that
- If normalization fails → use original (with warning)
- If any scene missing → error with specific scene number

**Common issues:**
- Missing scene video → Check `10-videos/scene_XX/current` symlink
- Caption timing error → Invalid format in captions_overlay (must be "N-Ms" format)
- Concatenation failure → Check for resolution/codec mismatches (normalization should prevent this)

### Integration with run_stage_v3.py

Can be added to run_stage_v3.py for unified workflow:

```python
STAGE_CONFIG = {
    "assembly": {
        "dir": "13-assembly",
        "script": "scripts/assemble_video_v3_kling.py",
        "output_file": "final.mp4",
        "description": "Assemble final video from scene clips"
    }
}
```

### Next Steps

After assembly completes:
1. Review final video
2. If adjustments needed:
   - Regenerate specific scenes in Stage 10
   - Re-run assembly (creates new iteration)
3. If satisfied:
   - Export to target platforms
   - Archive project

---

Stages remaining to document in detail:
- Stage 5: Reference Search (05-references) - Conditional, not yet implemented
- Stage 6: Characteristic Shot (06-characteristic) - Basic docs exist, needs expansion
- Stage 7: Grid Generation (07-grids) - NanoBanana grid generation

---

## Session Management Details

### Single Session Benefits

1. **Natural conversation flow**
   - Claude: "Earlier I analyzed this story and noted the revenge punchline works best with..."
   - Claude: "In take 001 I used noir style, but based on your feedback for take 002, I'll try..."
   - No need to explicitly load previous outputs - Claude remembers

2. **Cross-take learning**
   - Take 001: Try approach A
   - User feedback: "Too serious"
   - Take 002: Claude naturally adapts based on session history

3. **Simplified orchestration**
   - Scripts just load one session ID from `session.txt`
   - No per-stage session management
   - No context reconstruction needed

4. **Automatic summarization**
   - Claude Code handles long conversation compaction
   - No manual context window management needed

### Session File Location

`/mnt/video/microdrama-v3/{story-name}/session.txt` contains single line:
```
truck_stop_revenge_20260202_145215
```

All stages and takes use this same session ID when invoking Claude.

### How run_stage_v3.py Loads Session

```python
# Load session ID (one per story, reused for all stages/takes)
story_path = get_story_path(story_name)
session_file = story_path / "session.txt"

if not session_file.exists():
    print(f"Error: Session file not found: {session_file}")
    print(f"Run init_take_v3.py first to initialize story")
    return False

session_id = session_file.read_text().strip()

# Invoke Claude with story-level session ID
subprocess.run([
    "claude",
    "--session-id", session_id,  # Same for all stages/takes
    "--cwd", str(iter_path),
    "-p", prompt
])
```

---

## Appendix: File Locations

### Key Scripts
- `scripts/init_take_v3.py` - Initialize take (creates/loads session ID)
- `scripts/run_stage_v3.py` - Run any stage (loads session ID)
- `scripts/lib/path_utils_v3.py` - Path validation
- `scripts/lib/logging_utils_v3.py` - JSONL logging
- `scripts/lib/metadata_utils_v3.py` - Metadata + session ID generation

### Templates
- `templates/CLAUDE.md.template` - Instructions for Claude
- `prompts/stage_understanding.txt` - Stage 1 prompt (TODO)
- `prompts/stage_script.txt` - Stage 2 prompt (TODO)

### Working Directory
- `/mnt/video/microdrama-v3/{story-name}/` - All story files
- `/mnt/video/microdrama-v3/{story-name}/session.txt` - **Single session ID for entire story**

### Documentation
- `docs/plans/v3-workflow.md` - Overall workflow plan
- `docs/stage-workflow.md` - This file
- `PHASE1_README.md` - Phase 1 testing guide

---

## Update Log

- **2026-02-02 14:52**: Created document, documented Stage 0 (init)
- **2026-02-02 14:52**: Implemented single session ID per story (not per stage)
- **2026-02-02 14:52**: Tested with truck-stop-revenge: session creation + reuse across takes
- **2026-02-02**: Documented Stages 1-8 (Understanding, Script, Assets, References, Characteristic, Grid, Script Adaptation)
- **2026-02-03**: Documented Stage 9 (Frame Extraction) - ✅ COMPLETED
- **2026-02-03**: Documented Stage 10 (Video Generation) - ✅ COMPLETED
  - Kling v2.6 Direct API integration with JWT authentication
  - Claude-based prompt regeneration with feedback system
  - Feedback accumulation across iterations
  - Duration logic (5s or 10s based on dialogue)
  - Tested with truck-stop-revenge: all 9 scenes generated successfully with multiple feedback iterations
- **2026-02-03**: Documented Stage 11 (Assembly) - ✅ COMPLETED
  - FFmpeg-based concatenation with normalization
  - Optional timed caption overlay system
  - Metadata tracking and JSONL logging
  - Tested with truck-stop-revenge: 9 scenes assembled into 50.4s final video
- **Next**: Complete end-to-end workflow testing and document Stages 5-6 (Reference Search, Characteristic Shot)
