#!/home/videobot/dev/venv/bin/python3
"""
V5 Orchestrator: Immutable Log + Pipeline File

Replaces V4 task queue with:
- pipeline.json: single source of truth for selected iterations
- Iteration directories as the log (metadata.json per iteration)
- No symlinks

Commands:
    init                             Create story structure + session
    analyze-refs [--force]           Analyze reference photos → index.json
    run                              Generate next stage / show pending
    run --accept ["feedback"]        Accept current and advance
    run --refine "feedback"          Refine latest iteration
    run --regenerate ["feedback"]    Fresh new iteration
    run --chat "feedback"            Chat refinement (06, 07)
    run --scene X --iteration Y      Target specific scene (09-11)
    run -n 5                         Grid variations count
    new STAGE [FEEDBACK]             Fresh iteration (power-user)
    refine STAGE ITERATION FEEDBACK  Refine specific iteration
    rate STAGE ITERATION RATING      Set rating (1-5)
    select STAGE ITERATION           Select for pipeline
    status                           Show pipeline state
    migrate                          Migrate v4 take to v5
"""

import argparse
import json
import subprocess
import sys
import uuid
import re
from pathlib import Path
from dotenv import load_dotenv

# Load .env from repository root
load_dotenv(Path(__file__).parent.parent.parent / ".env")

# Add parent to path for lib imports
sys.path.insert(0, str(Path(__file__).parent))

from lib.pipeline import (
    PIPELINE_STAGES,
    STAGE_DEPENDENCIES,
    load_pipeline,
    save_pipeline,
    find_next_stage,
    resolve_inputs,
    get_downstream_stages,
)
from lib.iteration import (
    list_iterations,
    read_metadata,
    save_iteration_metadata,
)
from lib.consistency import (
    validate_selection,
    find_invalidated_downstream,
)
from lib.path_utils_v3 import ensure_dir
from lib.reference_analysis import analyze_references
from lib import setup_generation_log, claude_env
import reference_loader

# Stage executors
from stages import (
    stage_01_story,
    stage_02_understanding,
    stage_03_script,
    stage_04_assets,
    stage_05_references,
    stage_06_characteristic,
    stage_07_grids,
    stage_08_frames,
    stage_09_script_adapted,
    stage_10_videos,
    stage_11_titles,
    stage_12_captions,
    stage_13_assembly,
)

STAGE_EXECUTORS = {
    "01-story": stage_01_story.execute,
    "02-understanding": stage_02_understanding.execute,
    "03-script": stage_03_script.execute,
    "04-assets": stage_04_assets.execute,
    "05-references": stage_05_references.execute,
    "06-characteristic": stage_06_characteristic.execute,
    "07-grids": stage_07_grids.execute,
    "09-script-adapted": stage_09_script_adapted.execute,
}


def resolve_take_path(story_path: Path, episode_override: str = None) -> Path:
    """Resolve take path from story path.

    Priority: --episode flag > 'current' symlink > latest take directory.
    """
    # 1. Explicit --episode override
    if episode_override:
        take_path = story_path / episode_override
        if not take_path.exists():
            raise ValueError(f"Episode not found: {take_path}")
        return take_path

    # 2. 'current' symlink
    current = story_path / "current"
    if current.is_symlink():
        return current.resolve()

    # 3. Latest take directory
    takes = sorted(
        d for d in story_path.iterdir()
        if d.is_dir() and d.name.isdigit()
    )
    if takes:
        return takes[-1]

    raise ValueError(f"No take found in {story_path}")


def get_story_name(story_path: Path) -> str:
    """Extract story slug from directory name."""
    name = story_path.name
    # Strip date prefix (e.g. "20260204-water-walking-dog" -> "water-walking-dog")
    match = re.match(r"^\d{8}-(.+)$", name)
    return match.group(1) if match else name


def get_project_defaults(story_path: Path) -> dict:
    """Read project-level defaults from story_pipeline.json."""
    pipeline_file = story_path / "story_pipeline.json"
    if pipeline_file.exists():
        try:
            data = json.loads(pipeline_file.read_text())
            return {
                "video_provider": data.get("video_provider", "pollo-kling"),
                "video_generator": data.get("video_generator", "kling-2.6"),
                "claude_model": data.get("claude_model", None),
                "provider_failover": data.get("provider_failover", None),
                "grid_generations": data.get("grid_generations", None),
                "auto_accept": data.get("auto_accept", False),
                "title_types": data.get("title_types", ["intro", "outro"]),
                "creative_notes": data.get("creative_notes", None),
                "video_mode": data.get("video_mode", "standard"),
                "laozhang_key": data.get("laozhang_key", None),
            }
        except (json.JSONDecodeError, OSError):
            pass
    return {"video_provider": "pollo-kling", "video_generator": "kling-2.6", "claude_model": None,
            "provider_failover": None, "grid_generations": None, "auto_accept": False,
            "title_types": ["intro", "outro"], "creative_notes": None, "video_mode": "standard"}


def _parse_auto_accept(value: str) -> bool:
    """Parse --auto-accept value: yes/1/true → True, no/0/false → False."""
    if value.lower() in ("yes", "1", "true"):
        return True
    if value.lower() in ("no", "0", "false"):
        return False
    raise argparse.ArgumentTypeError(f"Invalid --auto-accept value: {value!r} (use yes/no)")


def _resolve_auto_accept(args, story_path: Path) -> bool:
    """Resolve auto-accept from CLI flag (--auto-accept yes/no) or project config."""
    cli_val = getattr(args, 'auto_accept', None)
    if cli_val is not None:
        return cli_val  # Already a bool from _parse_auto_accept
    return get_project_defaults(story_path).get("auto_accept", False)


def _auto_accept_and_continue(story_path: Path, take_path: Path, stage: str,
                               iteration: str, pipeline: dict, args) -> int:
    """Select iteration, invalidate downstream, chain into cmd_run for remaining stages."""
    old_selection = pipeline.get(stage)
    pipeline[stage] = iteration
    save_pipeline(take_path, pipeline)
    print(f"[auto-accept] Selected: {stage}/{iteration}")

    if old_selection != iteration:
        _invalidate_stale_downstream(take_path, pipeline, stage)

    # Chain into cmd_run for downstream stages
    next_stage = find_next_stage(pipeline, take_path)
    if not next_stage:
        print("\nPipeline complete!")
        return 0

    print(f"\n{'='*60}")
    print(f"Continuing to next stage: {next_stage}")
    print(f"{'='*60}\n")

    import copy
    gen_args = copy.copy(args)
    gen_args.accept = None
    gen_args.refine = None
    gen_args.regenerate = None
    gen_args.chat = None
    if not hasattr(gen_args, 'auto_accept'):
        gen_args.auto_accept = True
    return cmd_run(story_path, gen_args)


# ============================================================
# Commands
# ============================================================


def cmd_init(story_path: Path, args) -> int:
    """Initialize story structure and Claude session."""
    story_name = get_story_name(story_path)
    print(f"Initializing story: {story_name}")
    print(f"Path: {story_path}")

    # Create story directory
    ensure_dir(story_path)

    # Create session
    session_file = story_path / "session.txt"
    session_created = False
    if not session_file.exists():
        session_id = str(uuid.uuid4())
        session_file.write_text(session_id)
        session_created = True
        print(f"Session: {session_id}")
    else:
        session_id = session_file.read_text().strip()
        print(f"Session exists: {session_id}")

    # Create take 001
    take_path = story_path / "001"
    ensure_dir(take_path)

    # Create current symlink if missing
    current = story_path / "current"
    if not current.exists():
        current.symlink_to("001")

    # Create pipeline.json
    pipeline = load_pipeline(take_path)
    print(f"Pipeline: {take_path / 'pipeline.json'}")

    # Create stage directories
    for stage in PIPELINE_STAGES:
        ensure_dir(take_path / stage)

    # Create 01-story directory
    ensure_dir(take_path / "01-story" / "001")

    # Initialize Claude session if newly created
    # This ensures the session exists in Claude before stages try to --resume it
    if session_created:
        print(f"\nCreating Claude session...")
        init_prompt = f"""You are starting a new microdrama project: "{story_name}"

This is a short-form video (45-60 seconds) production pipeline.
Working directory: {story_path}

Acknowledge this session initialization briefly."""

        try:
            result = subprocess.run(
                ["claude", "--session-id", session_id,
                 "--allowedTools", "Read,Glob",
                 "-p", init_prompt],
                capture_output=True, text=True,
                cwd=str(story_path), timeout=120,
                env=claude_env()
            )
            if result.returncode == 0:
                print("Claude session created successfully")
            else:
                print(f"Warning: Claude session creation returned code {result.returncode}")
                error_detail = result.stderr or result.stdout
                print(f"  error: {error_detail[:200] if error_detail else 'none'}")
        except subprocess.TimeoutExpired:
            print("Warning: Claude session creation timed out")
        except Exception as e:
            print(f"Warning: Failed to create Claude session: {e}")

    print(f"\nStory initialized. Place story text in:")
    print(f"  {take_path / '01-story' / '001' / 'story.md'}")
    print(f"\nThen run: orchestrator_v5.py {story_path} run")

    return 0


def cmd_new_episode(story_path: Path, args) -> int:
    """Create a new episode (take) within an existing project."""
    # Find existing episodes
    episodes = sorted(
        d.name for d in story_path.iterdir()
        if d.is_dir() and d.name.isdigit()
    )
    if not episodes:
        print("No episodes found. Run 'init' first.")
        return 1

    # Next episode number
    last_num = int(episodes[-1])
    new_num = f"{last_num + 1:03d}"
    take_path = story_path / new_num

    print(f"Creating episode {new_num}")

    # Create directory + pipeline + stage dirs
    ensure_dir(take_path)
    load_pipeline(take_path)

    for stage in PIPELINE_STAGES:
        ensure_dir(take_path / stage)

    ensure_dir(take_path / "01-story" / "001")

    # Update current symlink
    current = story_path / "current"
    if current.is_symlink():
        current.unlink()
    elif current.exists():
        current.unlink()
    current.symlink_to(new_num)
    print(f"Updated current → {new_num}")

    # Save brief if provided
    brief = getattr(args, 'brief', None)
    if brief:
        brief_file = take_path / "brief.txt"
        brief_file.write_text(brief)
        print(f"Brief: {brief}")

    print(f"Path: {take_path}")
    print(f"\nRun: orchestrator_v5.py {story_path} run")

    return 0


def cmd_init_template(project_path: Path, args) -> int:
    """Initialize project from template with references structure."""
    template_name = args.template
    variant = getattr(args, 'variant', None)

    # Validate template exists
    available_templates = reference_loader.list_available_templates()
    if template_name not in available_templates:
        print(f"Template not found: {template_name}")
        print(f"Available templates: {', '.join(available_templates)}")
        return 1

    # Check if variant is required/valid
    available_variants = reference_loader.list_template_variants(template_name)
    if available_variants:
        if variant and variant not in available_variants:
            print(f"Variant not found: {variant}")
            print(f"Available variants for {template_name}: {', '.join(available_variants)}")
            return 1
        if not variant:
            print(f"Template '{template_name}' has variants. Please specify one with --variant:")
            for v in available_variants:
                print(f"  --variant {v}")
            return 1

    # Load archetype to check input requirements
    archetype = reference_loader.load_template_archetype(template_name)
    needs_people = archetype and ("people: REQUIRED" in archetype or "people: OPTIONAL" in archetype)

    print(f"Initializing project with template: {template_name}" + (f"/{variant}" if variant else ""))
    print(f"Path: {project_path}")

    # Create project structure
    ensure_dir(project_path)

    # Create references directories based on template needs
    if needs_people:
        ensure_dir(project_path / "references" / "people" / "main")
    ensure_dir(project_path / "references" / "locations")
    ensure_dir(project_path / "references" / "style")

    # Create template.txt with variant
    template_spec = f"{template_name}/{variant}" if variant else template_name
    (project_path / "template.txt").write_text(template_spec)

    # Create brief.md (variant-specific if available)
    brief_content = _generate_brief_template(template_name, variant)
    (project_path / "brief.md").write_text(brief_content)

    # Create person description if template needs people
    if needs_people:
        (project_path / "references" / "people" / "main" / "description.md").write_text("""# Main Person

## Name
[Name or how to refer to them]

## Appearance
- Hair:
- Style:
- Distinguishing features:

## Personality/Vibe
[How should they come across on screen?]

## Notes for AI
[Any specific instructions for generating their likeness]
""")

    # Create first take directory
    ensure_dir(project_path / "001")

    # Create session
    session_file = project_path / "session.txt"
    session_created = False
    if not session_file.exists():
        session_id = str(uuid.uuid4())
        session_file.write_text(session_id)
        session_created = True
        print(f"Session: {session_id}")
    else:
        session_id = session_file.read_text().strip()
        print(f"Session exists: {session_id}")

    # Create current symlink
    current = project_path / "current"
    if not current.exists():
        current.symlink_to("001")

    # Create pipeline.json in take
    take_path = project_path / "001"
    load_pipeline(take_path)  # Creates empty pipeline

    # Create stage directories
    for stage in PIPELINE_STAGES:
        ensure_dir(take_path / stage)

    # Create 01-story directory
    ensure_dir(take_path / "01-story" / "001")

    # Create CLAUDE.md from template
    claude_md = project_path / "CLAUDE.md"
    if not claude_md.exists():
        story_name = get_story_name(project_path)
        claude_md_content = reference_loader.render_claude_md(story_name, template_spec)
        claude_md.write_text(claude_md_content)
        print(f"Created: CLAUDE.md")

    # Initialize Claude session
    if session_created:
        story_name = get_story_name(project_path)
        print(f"Creating Claude session...")
        init_prompt = f"""You are starting a new microdrama project: "{story_name}"
Template: {template_spec}

This is a short-form video (45-60 seconds) production pipeline.
Working directory: {project_path}

Acknowledge this session initialization briefly."""

        try:
            result = subprocess.run(
                ["claude", "--session-id", session_id,
                 "--allowedTools", "Read,Glob",
                 "-p", init_prompt],
                capture_output=True, text=True,
                cwd=str(project_path), timeout=120,
                env=claude_env()
            )
            if result.returncode == 0:
                print("Claude session created")
            else:
                print(f"Warning: session init returned code {result.returncode}")
                error_detail = result.stderr or result.stdout
                if error_detail:
                    print(f"  {error_detail[:200]}")
        except subprocess.TimeoutExpired:
            print("Warning: session init timed out")
        except Exception as e:
            print(f"Warning: session init failed: {e}")

    print(f"\nProject initialized with template: {template_spec}")
    print(f"\nNext steps:")
    if needs_people:
        print(f"1. Add photos to: {project_path}/references/people/main/")
        print(f"2. Edit: {project_path}/references/people/main/description.md")
        print(f"3. Edit: {project_path}/brief.md")
        print(f"4. Run: orchestrator_v5.py {project_path} run")
    else:
        print(f"1. Edit: {project_path}/brief.md")
        print(f"2. Place story in: {take_path / '01-story' / '001' / 'story.md'}")
        print(f"3. Run: orchestrator_v5.py {project_path} run")

    return 0


def _generate_brief_template(template_name: str, variant: str = None) -> str:
    """Generate variant-specific brief template."""
    if template_name == "instagram-influencer":
        if variant == "lifestyle":
            return """# Brief

## What lifestyle/vibe do you want to show?
[Luxury travel? Daily glamour? Success story?]

## Locations (real or desired)
-
-

## Key visual moments
-
-
-

## Music/mood reference
[Link to song or describe the vibe]

## What to avoid
[Anything you don't want in the video]
"""
        elif variant == "product":
            return """# Brief

## What product are you featuring?
[Product name, what it does]

## Key benefits to show
-
-
-

## How do you use it?
[Natural usage scenario]

## What problem does it solve?
[Pain point it addresses]

## What to avoid
[Anything you don't want in the video]
"""
        elif variant == "problem-solution":
            return """# Brief

## What problem are you addressing?
[Specific pain point your audience relates to]

## What's your solution?
[Product, service, or lifestyle change]

## Before/After
- Before: [What life looks like with the problem]
- After: [What life looks like with the solution]

## Your personal connection
[Why does this matter to you?]

## What to avoid
[Anything you don't want in the video]
"""

    # Default brief template
    return """# Brief

## What do you want to create?
[Describe your vision for the video]

## Key moments you want to capture
-
-
-

## Style preferences
[Any specific look, mood, or references you like]

## What to avoid
[Anything you don't want in the video]
"""


def cmd_analyze_refs(story_path: Path, args) -> int:
    """Analyze reference photos and generate index.json."""
    force = getattr(args, 'force', False)

    refs_path = story_path / "references"
    if not refs_path.exists():
        print(f"No references directory: {refs_path}")
        return 1

    index = analyze_references(story_path, force=force)

    if index is None:
        print("Analysis failed or no photos found.")
        return 1

    # Print summary
    photos = index.get("photos", [])
    print(f"\nAnalyzed {len(photos)} photos:")

    # Group by category
    by_cat = {}
    for p in photos:
        cat = p.get("category", "other")
        person = p.get("person")
        key = f"{cat}/{person}" if person else cat
        if key not in by_cat:
            by_cat[key] = []
        by_cat[key].append(p)

    for key, entries in sorted(by_cat.items()):
        print(f"\n  {key}/ ({len(entries)} photos)")
        for e in entries:
            face = "face" if e.get("face_visible") else "no face"
            print(f"    {e['usefulness']:2d}  {e['type']:15s}  {face:8s}  {e['path']}")

    return 0


# ============================================================
# Unified run: helpers
# ============================================================


def _classify_stage(stage: str) -> str:
    """Classify stage into dispatch category."""
    if stage == "08-frames":
        return "frames"
    if stage == "10-videos":
        return "videos"
    if stage == "11-titles":
        return "titles"
    if stage == "12-captions":
        return "captions"
    if stage == "13-assembly":
        return "assembly"
    return "regular"


def _get_run_mode(args) -> str:
    """Determine which run mode from args."""
    if args.accept is not None:
        return "accept"
    if args.refine is not None:
        return "refine"
    if args.regenerate is not None:
        return "regenerate"
    if args.chat is not None:
        return "chat"
    return "generate"


def _get_latest_iteration(take_path: Path, stage: str) -> str | None:
    """Get the latest (highest-numbered) iteration for a stage."""
    iters = list_iterations(take_path / stage)
    return iters[-1] if iters else None


def _show_iteration_status(take_path: Path, stage: str, iteration: str) -> None:
    """Print user_message.txt from an iteration + available actions."""
    iter_dir = take_path / stage / iteration
    user_msg_file = iter_dir / "user_message.txt"

    if user_msg_file.exists():
        print(user_msg_file.read_text())
    else:
        # Fallback: show metadata summary
        meta = read_metadata(iter_dir)
        if meta:
            print(f"Stage: {stage}/{iteration}")
            if meta.get("refined_from"):
                print(f"Refined from: {meta['refined_from']}")
            if meta.get("rating"):
                print(f"Rating: {'*' * meta['rating']}")
        else:
            print(f"Stage: {stage}/{iteration} (no details)")

    print()
    print("Actions:")
    print(f"  run --accept            Accept and advance")
    print(f"  run --refine \"feedback\"  Refine with feedback")
    print(f"  run --regenerate        Generate fresh alternative")
    if stage in ["06-characteristic", "07-grids"]:
        print(f"  run --chat \"feedback\"   Chat refinement (NanoBanana)")


def save_accept_feedback(take_path: Path, stage: str, feedback: str) -> None:
    """Save accept feedback for a stage (forwarded to next stage as context)."""
    if not feedback:
        return
    feedback_dir = take_path / "accept_feedback"
    ensure_dir(feedback_dir)
    (feedback_dir / f"{stage}.txt").write_text(feedback)


def get_accept_feedbacks(take_path: Path, stage: str) -> dict:
    """Read accept feedbacks from parent stages.

    Returns dict: {parent_stage: feedback_text}
    """
    feedback_dir = take_path / "accept_feedback"
    if not feedback_dir.exists():
        return {}

    feedbacks = {}
    deps = STAGE_DEPENDENCIES.get(stage, [])
    for dep in deps:
        fb_file = feedback_dir / f"{dep}.txt"
        if fb_file.exists():
            feedbacks[dep] = fb_file.read_text().strip()
    return feedbacks


# ============================================================
# Unified run: stage dispatchers
# ============================================================


def _run_regular_stage(story_path: Path, take_path: Path, stage: str, pipeline: dict, args) -> int:
    """Handle run for stages 01-08 (regular iteration-based stages)."""
    story_name = get_story_name(story_path)
    mode = _get_run_mode(args)

    # --- ACCEPT ---
    if mode == "accept":
        latest = pipeline.get(stage) or _get_latest_iteration(take_path, stage)
        if not latest or not (take_path / stage / latest / "metadata.json").exists():
            print(f"Nothing to accept in {stage} — no completed iterations exist.")
            print(f"Generating instead...")
            # Fall through to generate below
        else:
            # Validate that iteration matches current pipeline parents
            meta = read_metadata(take_path / stage / latest)
            based_on = meta.get("based_on", {})
            deps = STAGE_DEPENDENCIES.get(stage, [])
            stale = False
            for dep in deps:
                expected = pipeline.get(dep)
                if expected and based_on.get(dep) != expected:
                    stale = True
                    print(f"Stale: {stage}/{latest} was based on {dep}/{based_on.get(dep)}, but pipeline has {dep}/{expected}")
            if stale:
                print(f"Cannot accept stale iteration. Generating fresh one...")
                # Fall through to generate below
            else:
                # Valid — select it
                old_selection = pipeline.get(stage)
                pipeline[stage] = latest
                save_pipeline(take_path, pipeline)
                # Invalidate stale downstream when selection changed
                if old_selection != latest:
                    _invalidate_stale_downstream(take_path, pipeline, stage)
                feedback = args.accept  # "" or "some feedback"
                save_accept_feedback(take_path, stage, feedback)
                print(f"Accepted: {stage}/{latest}")
                if feedback:
                    print(f"Feedback saved for next stage: \"{feedback}\"")
                return 0

    # --- REFINE ---
    if mode == "refine":
        latest = _get_latest_iteration(take_path, stage)
        if not latest:
            print(f"Nothing to refine in {stage} — no iterations exist.")
            return 1

        iter_dir = take_path / stage / latest
        meta = read_metadata(iter_dir)
        inputs = meta.get("based_on", {})

        executor = STAGE_EXECUTORS.get(stage)
        if not executor:
            print(f"No executor for stage {stage}")
            return 1

        feedback = args.refine
        print(f"Refining: {stage}/{latest}")
        print(f"Feedback: {feedback}")

        result = executor(
            take_path, story_name, inputs,
            feedback=feedback, refined_from=latest
        )

        if result["status"] != "success":
            print(f"\nError: {result.get('error')}")
            return 1

        print(f"\nGenerated: {stage}/{result['iteration']} (refined from {latest})")
        if result.get("user_message"):
            print(f"\n{result['user_message']}")
        return 0

    # --- REGENERATE ---
    if mode == "regenerate":
        executor = STAGE_EXECUTORS.get(stage)
        if not executor:
            print(f"No executor for stage {stage}")
            return 1

        try:
            inputs = resolve_inputs(pipeline, stage, take_path)
        except ValueError as e:
            print(f"Error: {e}")
            return 1

        feedback = args.regenerate or None  # "" → None

        # Special batch handling for 07-grids
        number = getattr(args, 'number', None)
        if stage == "07-grids" and number and number > 1:
            scale_refs = getattr(args, 'scale_refs', False)
            char_ref_only = getattr(args, 'char_ref_only', False)
            return _run_batch_grids(story_path, take_path, stage, pipeline, inputs, feedback, number, scale_refs=scale_refs, char_ref_only=char_ref_only)

        print(f"Regenerating: {stage}")
        if feedback:
            print(f"Feedback: {feedback}")

        char_ref_only = getattr(args, 'char_ref_only', False)
        extra_kwargs = {}
        if stage == "07-grids" and char_ref_only:
            extra_kwargs["char_ref_only"] = True
        result = executor(take_path, story_name, inputs, feedback=feedback, **extra_kwargs)

        if result["status"] != "success":
            print(f"\nError: {result.get('error')}")
            return 1

        print(f"\nGenerated: {stage}/{result['iteration']}")
        if result.get("user_message"):
            print(f"\n{result['user_message']}")
        return 0

    # --- CHAT ---
    if mode == "chat":
        if stage not in ["06-characteristic", "07-grids"]:
            print(f"Chat mode not supported for {stage}")
            return 1

        latest = _get_latest_iteration(take_path, stage)
        if not latest:
            print(f"Nothing to chat in {stage} — no iterations exist.")
            return 1

        iter_dir = take_path / stage / latest
        meta = read_metadata(iter_dir)
        inputs = meta.get("based_on", {})

        executor = STAGE_EXECUTORS.get(stage)
        if not executor:
            print(f"No executor for stage {stage}")
            return 1

        feedback = args.chat
        print(f"Chat refinement: {stage}/{latest}")
        print(f"Feedback: {feedback}")

        result = executor(
            take_path, story_name, inputs,
            feedback=feedback, refined_from=latest, chat_mode=True
        )

        if result["status"] != "success":
            print(f"\nError: {result.get('error')}")
            return 1

        print(f"\nGenerated: {stage}/{result['iteration']} (chat from {latest})")
        if result.get("user_message"):
            print(f"\n{result['user_message']}")
        return 0

    # --- GENERATE (default) ---
    # Check state: has real iterations (with metadata) but none selected → show pending
    # But only if the iteration matches current pipeline parents (not stale)
    iters = list_iterations(take_path / stage)
    real_iters = [i for i in iters if (take_path / stage / i / "metadata.json").exists()]
    if real_iters and pipeline.get(stage) is None:
        latest = real_iters[-1]
        # Check staleness
        meta = read_metadata(take_path / stage / latest)
        based_on = meta.get("based_on", {})
        deps = STAGE_DEPENDENCIES.get(stage, [])
        fresh = all(
            pipeline.get(dep) is None or based_on.get(dep) == pipeline.get(dep)
            for dep in deps
        )
        if fresh:
            print(f"Pending: {stage}/{latest}")
            print()
            _show_iteration_status(take_path, stage, latest)
            return 0
        # Stale — fall through to generate

    # Has selected iteration → stage is done, shouldn't be "next"
    # (find_next_stage already handles this)

    # Empty stage → generate
    executor = STAGE_EXECUTORS.get(stage)
    if not executor:
        print(f"No executor for stage {stage}")
        return 1

    try:
        inputs = resolve_inputs(pipeline, stage, take_path)
    except ValueError as e:
        print(f"Error: {e}")
        return 1

    # Inject accept feedback from parent stages
    parent_feedbacks = get_accept_feedbacks(take_path, stage)
    extra_context = None
    if parent_feedbacks:
        parts = []
        for parent, fb in parent_feedbacks.items():
            parts.append(f"[Feedback from {parent}]: {fb}")
        extra_context = "\n".join(parts)
        print(f"With parent feedback: {extra_context}")

    # Special batch handling for 07-grids
    number = getattr(args, 'number', None)
    if stage == "07-grids":
        if number is None:
            defaults = get_project_defaults(story_path)
            number = defaults.get("grid_generations") or 3
        if number > 1:
            scale_refs = getattr(args, 'scale_refs', False)
            char_ref_only = getattr(args, 'char_ref_only', False)
            return _run_batch_grids(story_path, take_path, stage, pipeline, inputs,
                                     extra_context, number, scale_refs=scale_refs, char_ref_only=char_ref_only)

    print(f"Generating: {stage}")

    # Pass extra_context as feedback for initial generation
    result = executor(take_path, story_name, inputs, feedback=extra_context)

    if result["status"] != "success":
        print(f"\nError: {result.get('error')}")
        return 1

    iteration = result["iteration"]
    print(f"\nGenerated: {stage}/{iteration}")

    if result.get("user_message"):
        print(f"\n{result['user_message']}")

    print()
    print("Actions:")
    print(f"  run --accept            Accept and advance")
    print(f"  run --refine \"feedback\"  Refine with feedback")
    print(f"  run --regenerate        Generate fresh alternative")

    return 0


def _run_batch_grids(story_path, take_path, stage, pipeline, inputs, feedback, number, scale_refs=True, char_ref_only=False) -> int:
    """Generate multiple grid variations."""
    story_name = get_story_name(story_path)

    print(f"Generating {number} grid variations...")
    if feedback:
        print(f"Feedback: {feedback}")

    results = []
    for i in range(number):
        print(f"\n{'='*60}")
        print(f"Generating grid {i+1} of {number}")
        print(f"{'='*60}")

        result = stage_07_grids.execute(
            take_path, story_name, inputs,
            feedback=feedback,
            variation_index=i,
            total_variations=number,
            scale_refs=scale_refs,
            char_ref_only=char_ref_only
        )

        if result["status"] != "success":
            print(f"Error on grid {i+1}: {result.get('error')}")
            continue

        results.append(result)
        print(f"Generated: 07-grids/{result['iteration']}")

    print(f"\n{'='*60}")
    print(f"Generated {len(results)} of {number} grids")
    print(f"{'='*60}")
    for r in results:
        print(f"  07-grids/{r['iteration']}")

    if results:
        print()
        print("Actions:")
        print(f"  run --accept            Accept latest grid")
        print(f"  run --chat \"feedback\"   Chat refine on latest")
        print(f"  run --regenerate        Generate more variations")

    return 0 if results else 1


def _run_frames_stage(story_path: Path, take_path: Path, pipeline: dict, args) -> int:
    """Handle run for stage 08-frames."""
    story_name = get_story_name(story_path)
    mode = _get_run_mode(args)
    scene = getattr(args, 'scene', None)
    iteration = getattr(args, 'iteration', None)

    # --- ACCEPT ---
    if mode == "accept":
        if not scene:
            # Accept all — check if all scenes have frames, auto-select latest for each
            result = stage_08_frames.get_frames_status(take_path)
            if result["status"] == "error":
                print(f"Error: {result.get('error')}")
                return 1

            # Prefer frames from the currently selected grid
            current_grid = pipeline.get("07-grids")

            accepted = 0
            for scene_data in result["scenes"]:
                if scene_data["selected"]:
                    accepted += 1
                    continue
                # Auto-select: prefer frame from current grid, fallback to latest
                all_frames = scene_data["available_base"] + scene_data["available_refined"]
                if not all_frames:
                    continue
                best_frame = all_frames[-1]  # default: latest
                if current_grid:
                    for f in all_frames:
                        if f.startswith(current_grid):
                            best_frame = f
                            break
                if all_frames:
                    sel_result = stage_08_frames.select_scene_frame(take_path, scene_data["scene"], best_frame)
                    if sel_result["status"] == "success":
                        print(f"Accepted: {scene_data['scene']} → {best_frame}")
                        accepted += 1

            if accepted == result["scene_count"]:
                print(f"\nAll {accepted} scenes accepted! Ready for Stage 10.")
            else:
                print(f"\n{accepted}/{result['scene_count']} scenes accepted.")
            return 0
        else:
            # Accept specific scene/iteration
            frame = iteration or None
            if not frame:
                # Find latest frame for this scene
                result = stage_08_frames.get_frames_status(take_path)
                if result["status"] == "error":
                    print(f"Error: {result.get('error')}")
                    return 1
                for sd in result["scenes"]:
                    if sd["scene"] == scene:
                        all_frames = sd["available_base"] + sd["available_refined"]
                        frame = all_frames[0] if all_frames else None
                        break
            if not frame:
                print(f"No frames found for {scene}")
                return 1

            sel_result = stage_08_frames.select_scene_frame(take_path, scene, frame)
            if sel_result["status"] == "success":
                print(f"Accepted: {scene} → {frame}")
                feedback = args.accept
                if feedback:
                    save_accept_feedback(take_path, f"08-frames/{scene}", feedback)
                return 0
            else:
                print(f"Error: {sel_result.get('error')}")
                return 1

    # --- REFINE ---
    if mode == "refine":
        if not scene:
            print("--scene required for frame refinement")
            return 1
        if not iteration:
            # Find latest frame for this scene
            result = stage_08_frames.get_frames_status(take_path)
            for sd in result.get("scenes", []):
                if sd["scene"] == scene:
                    all_frames = sd["available_base"] + sd["available_refined"]
                    iteration = all_frames[0] if all_frames else None
                    break
            if not iteration:
                print(f"No frames found for {scene}")
                return 1

        feedback = args.refine
        print(f"Refining frame: {scene}/{iteration}")
        print(f"Feedback: {feedback}")

        scale_refs = getattr(args, 'scale_refs', False)
        result = stage_08_frames.refine_scene_frame(
            take_path, story_name, scene, iteration, feedback,
            scale_refs=scale_refs
        )

        if result["status"] == "success":
            print(f"\nRefined: {scene}/{result['frame']}")
            return 0
        else:
            print(f"Error: {result.get('error')}")
            return 1

    # --- REGENERATE ---
    if mode == "regenerate":
        force = getattr(args, 'force', True)
        print(f"Re-extracting frames (force={force})...")
        result = stage_08_frames.extract_frames(take_path, story_name, force=force)
        if result["status"] == "success":
            pipeline["08-frames"] = "extracted"
            save_pipeline(take_path, pipeline)
            print(f"Extracted: {result['grid_count']} grids × {result['scene_count']} scenes")
            return 0
        else:
            print(f"Error: {result.get('error')}")
            return 1

    # --- GENERATE (default) ---
    # Check if frames already extracted
    extraction_meta = take_path / "08-frames" / "extraction_metadata.json"
    needs_extract = False
    if extraction_meta.exists():
        # Check if extraction is stale (grid changed since last extraction)
        ext_meta = json.loads(extraction_meta.read_text())
        extracted_grids = set(ext_meta.get("grid_iterations_extracted", []))
        current_grid = pipeline.get("07-grids")
        if current_grid and current_grid not in extracted_grids:
            print(f"Grid changed ({current_grid} not in extracted {extracted_grids}), re-extracting...")
            needs_extract = True
        else:
            # Up to date — show status
            return cmd_status_frames(story_path, args)
    else:
        needs_extract = True

    if needs_extract:
        print("Extracting frames from grids...")
        result = stage_08_frames.extract_frames(take_path, story_name, force=True)
        if result["status"] == "success":
            print(f"\nExtracted: {result['grid_count']} grids × {result['scene_count']} scenes = {result['total_frames']} frames")
            print()

            # Show frame status
            cmd_status_frames(story_path, args)

            print()
            print("Actions:")
            print(f"  run --accept --scene 01-scene --iteration 001   Accept a frame")
            print(f"  run --refine \"feedback\" --scene 01-scene --iteration 001   Refine")
            print(f"  run --accept                                    Accept all (first available)")
            return 0
        else:
            print(f"Error: {result.get('error')}")
            return 1


def _run_videos_stage(story_path: Path, take_path: Path, pipeline: dict, args) -> int:
    """Handle run for stage 10-videos."""
    mode = _get_run_mode(args)
    scene = getattr(args, 'scene', None)
    iteration = getattr(args, 'iteration', None)

    # --- ACCEPT ---
    if mode == "accept":
        if not scene:
            # Accept all latest videos
            result = stage_10_videos.status_videos(take_path)
            if result["status"] == "error":
                print(f"Error: {result.get('error')}")
                return 1
            accepted = 0
            for sd in result["scenes"]:
                if sd["selected"]:
                    accepted += 1
                    continue
                if sd["iterations"]:
                    sel_result = stage_10_videos.select_video(take_path, sd["scene"], sd["iterations"][-1])
                    if sel_result["status"] == "success":
                        print(f"Accepted: {sd['scene']} → {sd['iterations'][-1]}")
                        accepted += 1
            if accepted == result["scene_count"]:
                print(f"\nAll {accepted} scenes accepted! Ready for Stage 11 (Titles).")
            else:
                print(f"\n{accepted}/{result['scene_count']} scenes accepted.")

            feedback = args.accept
            if feedback:
                save_accept_feedback(take_path, "10-videos", feedback)
            return 0
        else:
            # Accept specific
            if not iteration:
                # Find latest iteration for scene
                scene_dir = take_path / "10-videos" / scene
                if scene_dir.exists():
                    iters = sorted(d.name for d in scene_dir.iterdir()
                                   if d.is_dir() and d.name.isdigit())
                    iteration = iters[-1] if iters else None
            if not iteration:
                print(f"No videos found for {scene}")
                return 1
            sel_result = stage_10_videos.select_video(take_path, scene, iteration)
            if sel_result["status"] == "success":
                print(f"Accepted: {scene} → {iteration}")
                feedback = args.accept
                if feedback:
                    save_accept_feedback(take_path, f"10-videos/{scene}", feedback)
                return 0
            else:
                print(f"Error: {sel_result.get('error')}")
                return 1

    # --- REFINE ---
    if mode == "refine":
        if not scene:
            print("--scene required for video refinement")
            return 1
        if not iteration:
            scene_dir = take_path / "10-videos" / scene
            if scene_dir.exists():
                iters = sorted(d.name for d in scene_dir.iterdir()
                               if d.is_dir() and d.name.isdigit())
                iteration = iters[-1] if iters else None
            if not iteration:
                print(f"No videos found for {scene}")
                return 1

        feedback = args.refine
        defaults = get_project_defaults(story_path)
        provider = getattr(args, 'provider', None) or defaults["video_provider"]
        failover_provider = defaults.get("provider_failover")
        duration = getattr(args, 'duration', None)
        resolution = getattr(args, 'resolution', None)

        print(f"Refining video: {scene}/{iteration}")
        print(f"Feedback: {feedback}")

        result = stage_10_videos.refine_video(
            take_path=take_path,
            scene=scene,
            iteration=iteration,
            feedback=feedback,
            provider=provider,
            duration=duration,
            resolution=resolution
        )

        if result["status"] != "success" and failover_provider and failover_provider != provider:
            print(f"Primary provider failed, trying failover: {failover_provider}")
            result = stage_10_videos.refine_video(
                take_path=take_path,
                scene=scene,
                iteration=iteration,
                feedback=feedback,
                provider=failover_provider,
                duration=duration,
                resolution=resolution
            )

        if result["status"] == "success":
            print(f"\nRefined: {scene}/{result['iteration']}")
            print(f"Output: {result['video_path']}")
            return 0
        else:
            print(f"Error: {result.get('error')}")
            return 1

    # --- REGENERATE ---
    if mode == "regenerate":
        if not scene:
            # Batch: generate for all scenes without videos
            return _run_videos_batch(story_path, take_path, pipeline, args)

        feedback = args.regenerate or None
        defaults = get_project_defaults(story_path)
        provider = getattr(args, 'provider', None) or defaults["video_provider"]
        failover_provider = defaults.get("provider_failover")
        duration = getattr(args, 'duration', None)
        no_audio = getattr(args, 'no_audio', False)
        resolution = getattr(args, 'resolution', "1080p")

        print(f"Generating new video: {scene}")
        result = stage_10_videos.new_video(
            take_path=take_path,
            scene=scene,
            feedback=feedback,
            provider=provider,
            duration=duration,
            generate_audio=not no_audio,
            resolution=resolution
        )

        if result["status"] != "success" and failover_provider and failover_provider != provider:
            print(f"Primary provider failed, trying failover: {failover_provider}")
            result = stage_10_videos.new_video(
                take_path=take_path,
                scene=scene,
                feedback=feedback,
                provider=failover_provider,
                duration=duration,
                generate_audio=not no_audio,
                resolution=resolution
            )

        if result["status"] == "success":
            print(f"\nGenerated: {scene}/{result['iteration']}")
            print(f"Output: {result['video_path']}")
            return 0
        else:
            print(f"Error: {result.get('error')}")
            return 1

    # --- GENERATE (default) ---
    # If pipeline["10-videos"] was invalidated (upstream changed), force regeneration
    if pipeline.get("10-videos") is None:
        return _run_videos_batch(story_path, take_path, pipeline, args)

    # Check if any videos exist
    result = stage_10_videos.status_videos(take_path)
    if result["status"] == "error":
        # No videos at all — batch generate
        return _run_videos_batch(story_path, take_path, pipeline, args)

    # Has videos → show status
    has_any_video = any(sd["iterations"] for sd in result.get("scenes", []))
    if not has_any_video:
        return _run_videos_batch(story_path, take_path, pipeline, args)

    # In interpolation mode, check if pipeline has all required selections
    # (start scenes selected + end scenes marked as interpolation-end)
    defaults = get_project_defaults(story_path)
    if defaults.get("video_mode") == "interpolation":
        vid_sel = pipeline.get("10-videos")
        if not isinstance(vid_sel, dict) or not all(
            vid_sel.get(f"{i:02d}-scene") for i in range(1, len(result["scenes"]) + 1)
        ):
            return _run_interpolation_batch(story_path, take_path, pipeline, args, defaults)

    # Show status
    cmd_status_videos(story_path, args)

    unselected = [sd["scene"] for sd in result["scenes"] if not sd["selected"]]
    no_videos = [sd["scene"] for sd in result["scenes"] if not sd["iterations"]]

    if no_videos:
        print(f"\nScenes without videos: {', '.join(no_videos)}")
        print(f"  run --regenerate --scene {no_videos[0]}   Generate video for scene")
    if unselected:
        print(f"\nActions:")
        print(f"  run --accept --scene {unselected[0]}   Accept latest video")
        print(f"  run --accept                          Accept all latest")

    return 0


def _run_videos_batch(story_path, take_path, pipeline, args) -> int:
    """Batch generate videos for all scenes missing videos."""
    defaults = get_project_defaults(story_path)

    # Dispatch to interpolation batch if video_mode is set
    if defaults.get("video_mode") == "interpolation":
        return _run_interpolation_batch(story_path, take_path, pipeline, args, defaults)
    provider = getattr(args, 'provider', None) or defaults["video_provider"]
    failover_provider = defaults.get("provider_failover")
    duration = getattr(args, 'duration', None)
    no_audio = getattr(args, 'no_audio', False)
    resolution = getattr(args, 'resolution', "1080p")

    # Get scene count from extraction metadata
    extraction_meta_file = take_path / "08-frames" / "extraction_metadata.json"
    if not extraction_meta_file.exists():
        print("Error: Frame extraction metadata not found. Complete Stage 08 first.")
        return 1

    with open(extraction_meta_file) as f:
        extraction_meta = json.load(f)
    scene_count = extraction_meta["scene_count"]

    # If pipeline["10-videos"] was invalidated (None), ALL scenes need new videos
    # regardless of what old video dirs exist on disk
    videos_invalidated = pipeline.get("10-videos") is None

    scenes_to_generate = []
    for i in range(1, scene_count + 1):
        scene = f"{i:02d}-scene"
        scene_dir = take_path / "10-videos" / scene
        has_videos = scene_dir.exists() and any(d.is_dir() and d.name.isdigit() for d in scene_dir.iterdir() if d.is_dir())
        if not has_videos or videos_invalidated:
            scenes_to_generate.append(scene)

    if not scenes_to_generate:
        print("All scenes already have videos.")
        return cmd_status_videos(story_path, args)

    print(f"Generating videos for {len(scenes_to_generate)} scenes: {', '.join(scenes_to_generate)}")
    print(f"Provider: {provider}")

    results = []
    for scene in scenes_to_generate:
        print(f"\n{'='*60}")
        print(f"Generating video: {scene}")
        print(f"{'='*60}")

        result = stage_10_videos.new_video(
            take_path=take_path,
            scene=scene,
            provider=provider,
            duration=duration,
            generate_audio=not no_audio,
            resolution=resolution
        )

        if result["status"] != "success" and failover_provider and failover_provider != provider:
            print(f"Primary provider failed, trying failover: {failover_provider}")
            result = stage_10_videos.new_video(
                take_path=take_path,
                scene=scene,
                provider=failover_provider,
                duration=duration,
                generate_audio=not no_audio,
                resolution=resolution
            )

        if result["status"] == "success":
            results.append(result)
            print(f"Generated: {scene}/{result['iteration']}")
        else:
            print(f"Error: {result.get('error')}")

    print(f"\n{'='*60}")
    print(f"Generated {len(results)} of {len(scenes_to_generate)} videos")
    print(f"{'='*60}")

    print()
    print("Actions:")
    print(f"  run --accept             Accept all latest videos")
    print(f"  run --accept --scene X   Accept specific scene video")

    return 0 if results else 1


def _run_interpolation_batch(story_path, take_path, pipeline, args, defaults) -> int:
    """Generate interpolated video pairs: scenes 1+2 → clip 1, scenes 3+4 → clip 2.

    Instead of generating 4 independent videos, pairs frames as start/end
    for frame interpolation. Result: 2 clips (~8s each) + outro ≈ 19s.
    """
    from lib.pipeline import load_pipeline, save_pipeline

    provider = getattr(args, 'provider', None) or defaults["video_provider"]
    failover_provider = defaults.get("provider_failover")
    duration = getattr(args, 'duration', None)
    no_audio = getattr(args, 'no_audio', False)
    resolution = getattr(args, 'resolution', "1080p")

    # Get frame selections
    frames_selections = pipeline.get("08-frames")
    if not isinstance(frames_selections, dict):
        print("Error: Frames not selected. Complete Stage 08 first.")
        return 1

    # Get scene count from extraction metadata
    extraction_meta_file = take_path / "08-frames" / "extraction_metadata.json"
    if not extraction_meta_file.exists():
        print("Error: Frame extraction metadata not found. Complete Stage 08 first.")
        return 1

    with open(extraction_meta_file) as f:
        extraction_meta = json.load(f)
    scene_count = extraction_meta["scene_count"]

    if scene_count < 2:
        print("Error: Interpolation mode requires at least 2 scenes.")
        return 1

    # Define pairs: (start_scene, end_scene)
    pairs = []
    for i in range(1, scene_count, 2):
        start_scene = f"{i:02d}-scene"
        if i + 1 <= scene_count:
            end_scene = f"{i+1:02d}-scene"
            pairs.append((start_scene, end_scene))
        else:
            # Odd scene count: last scene has no end pair, generate normally
            pairs.append((start_scene, None))

    print(f"Interpolation mode: {len(pairs)} clip(s) from {scene_count} scenes")
    for start, end in pairs:
        if end:
            print(f"  {start} → {end} (start+end frame interpolation)")
        else:
            print(f"  {start} (solo, no end frame)")
    print(f"Provider: {provider}")

    results = []
    vid_selections = pipeline.get("10-videos") or {}
    for start_scene, end_scene in pairs:
        # Check if start scene already has a selected video in pipeline
        if isinstance(vid_selections, dict) and vid_selections.get(start_scene):
            print(f"\n{start_scene}: Already selected ({vid_selections[start_scene]}), skipping")
            continue

        print(f"\n{'='*60}")
        print(f"Generating interpolated video: {start_scene}" +
              (f" → {end_scene}" if end_scene else " (solo)"))
        print(f"{'='*60}")

        # Get end frame path if we have an end scene
        end_frame_path = None
        if end_scene:
            end_frame_selection = frames_selections.get(end_scene)
            if end_frame_selection:
                end_frame_path = str(
                    take_path / "08-frames" / end_scene / f"{end_frame_selection}.jpg"
                )
                if not Path(end_frame_path).exists():
                    print(f"  Warning: End frame not found: {end_frame_path}")
                    end_frame_path = None

        result = stage_10_videos.new_video(
            take_path=take_path,
            scene=start_scene,
            provider=provider,
            duration=duration,
            generate_audio=not no_audio,
            end_frame=end_frame_path,
            end_scene=end_scene,
            resolution=resolution
        )

        if result["status"] != "success" and failover_provider and failover_provider != provider:
            print(f"Primary provider failed, trying failover: {failover_provider}")
            result = stage_10_videos.new_video(
                take_path=take_path,
                scene=start_scene,
                provider=failover_provider,
                duration=duration,
                generate_audio=not no_audio,
                end_frame=end_frame_path,
                end_scene=end_scene,
                resolution=resolution
            )

        if result["status"] == "success":
            results.append(result)
            print(f"Generated: {start_scene}/{result['iteration']}")

            # Auto-select the video
            pipeline = load_pipeline(take_path)
            if not isinstance(pipeline.get("10-videos"), dict):
                pipeline["10-videos"] = {}
            pipeline["10-videos"][start_scene] = result["iteration"]

            # Mark end scene as interpolation-end (skip in captions/assembly)
            if end_scene:
                pipeline["10-videos"][end_scene] = "interpolation-end"

            save_pipeline(take_path, pipeline)
        else:
            print(f"Error: {result.get('error')}")

    print(f"\n{'='*60}")
    print(f"Generated {len(results)} of {len(pairs)} interpolated clips")
    print(f"{'='*60}")

    return 0 if results else 1


def _run_captions_stage(story_path: Path, take_path: Path, pipeline: dict, args) -> int:
    """Handle run for stage 12-captions."""
    mode = _get_run_mode(args)
    scene = getattr(args, 'scene', None)
    iteration = getattr(args, 'iteration', None)

    # --- ACCEPT ---
    if mode == "accept":
        if scene and iteration:
            result = stage_12_captions.select_caption(take_path, scene, iteration)
            if result["status"] == "success":
                print(f"Accepted: {scene} → {iteration}")
                return 0
            else:
                print(f"Error: {result.get('error')}")
                return 1
        else:
            # Captions auto-select during generation, so just confirm
            feedback = args.accept
            if feedback:
                save_accept_feedback(take_path, "12-captions", feedback)
            print("Captions accepted.")
            # Set pipeline to current state (captions are stored as dict)
            return 0

    # --- REFINE ---
    if mode == "refine":
        if not scene:
            print("--scene required for caption refinement")
            return 1
        if not iteration:
            # Find latest
            cap_sel = pipeline.get("12-captions", {})
            if isinstance(cap_sel, dict):
                iteration = cap_sel.get(scene)
            if not iteration:
                print(f"No caption found for {scene}")
                return 1

        feedback = args.refine
        print(f"Refining caption: {scene}/{iteration}")
        result = stage_12_captions.refine_caption(take_path, scene, iteration, feedback)
        if result["status"] == "success":
            print(f"\nRefined: {scene}/{result['iteration']}")
            print(f"  Text: \"{result['text']}\"")
            return 0
        else:
            print(f"Error: {result.get('error')}")
            return 1

    # --- REGENERATE ---
    if mode == "regenerate":
        if scene:
            feedback = args.regenerate or None
            result = stage_12_captions.new_caption(take_path, scene, feedback)
            if result["status"] == "success":
                print(f"Generated: {scene}/{result['iteration']}")
                return 0
            else:
                print(f"Error: {result.get('error')}")
                return 1
        else:
            force = getattr(args, 'force', True)
            result = stage_12_captions.generate_captions(take_path, force_regenerate=force)
            if result["status"] == "error":
                print(f"Error: {result.get('error')}")
                return 1
            generated = result["results"]["generated"]
            print(f"Generated {len(generated)} captions")
            return 0

    # --- GENERATE (default) ---
    # Check if captions already exist
    cap_sel = pipeline.get("12-captions")
    if isinstance(cap_sel, dict) and cap_sel:
        # Show status
        return cmd_status_captions(story_path, args)

    # Generate captions batch
    print("Generating captions for all scenes...")
    result = stage_12_captions.generate_captions(take_path)
    if result["status"] == "error":
        print(f"Error: {result.get('error')}")
        return 1

    results = result["results"]
    if results["generated"]:
        print(f"\nGenerated {len(results['generated'])} captions:")
        for item in results["generated"]:
            print(f"  {item['scene']}: \"{item['text'][:60]}{'...' if len(item['text']) > 60 else ''}\"")

    if results["skipped"]:
        print(f"\nSkipped (no dialogue): {', '.join(results['skipped'])}")

    print()
    print("Actions:")
    print(f"  run --accept                                Accept all captions")
    print(f"  run --refine \"feedback\" --scene 01-scene    Refine specific caption")
    print(f"  run --regenerate --scene 01-scene            Fresh caption for scene")
    return 0


def _run_assembly_stage(story_path: Path, take_path: Path, pipeline: dict, args) -> int:
    """Handle run for stage 13-assembly."""
    mode = _get_run_mode(args)

    # --- ACCEPT ---
    if mode == "accept":
        # Find latest assembly iteration
        assembly_dir = take_path / "13-assembly"
        if assembly_dir.exists():
            iters = sorted(d.name for d in assembly_dir.iterdir()
                           if d.is_dir() and d.name.isdigit())
            if iters:
                latest = iters[-1]
                pipeline["13-assembly"] = latest
                save_pipeline(take_path, pipeline)
                feedback = args.accept
                if feedback:
                    save_accept_feedback(take_path, "13-assembly", feedback)
                print(f"Accepted: 13-assembly/{latest}")
                print("Pipeline complete!")
                return 0
        print("Nothing to accept — no assembly iterations exist.")
        return 1

    # --- REGENERATE ---
    if mode == "regenerate":
        no_captions = getattr(args, 'no_captions', False)
        is_final = getattr(args, 'clean', False)
        print("Re-assembling..." + (" (FINAL — no scene numbers)" if is_final else " (review mode — with scene numbers)"))
        result = stage_13_assembly.assemble(take_path, no_captions=no_captions, final=is_final)
        if result["status"] == "success":
            print(f"\nAssembled: 13-assembly/{result['iteration']}")
            print(f"Output: {result['output_path']}")
            print(f"Duration: {result['duration']:.1f}s")
            return 0
        else:
            print(f"Error: {result.get('error')}")
            return 1

    # --- GENERATE (default) ---
    # Check if assembly exists but not selected (manual review only)
    auto_accept = _resolve_auto_accept(args, story_path)
    if not auto_accept:
        assembly_result = stage_13_assembly.status_assembly(take_path)
        if assembly_result.get("iterations") and not assembly_result.get("selected"):
            # Show existing assembly for manual review
            cmd_status_assembly(story_path, args)
            print()
            print("Actions:")
            print(f"  run --accept          Accept this assembly")
            print(f"  run --regenerate      Re-assemble")
            return 0

    # Assemble (fresh or re-assemble)
    no_captions = getattr(args, 'no_captions', False)
    is_final = getattr(args, 'clean', False)
    if is_final:
        print("Assembling FINAL video (no scene numbers)...")
    else:
        print("Assembling review video (with scene numbers in top-right)...")
    result = stage_13_assembly.assemble(take_path, no_captions=no_captions, final=is_final)
    if result["status"] == "success":
        print(f"\nAssembled: 13-assembly/{result['iteration']}")
        print(f"Output: {result['output_path']}")
        print(f"Duration: {result['duration']:.1f}s")
        print(f"Resolution: {result['resolution']}")
        print(f"Size: {result['size_mb']:.1f}MB")
        print()
        print("Actions:")
        print(f"  run --accept          Accept and finish")
        print(f"  run --regenerate      Re-assemble")
        return 0
    else:
        print(f"Error: {result.get('error')}")
        return 1


# ============================================================
# Unified run: main command
# ============================================================


def cmd_run(story_path: Path, args) -> int:
    """Unified run command: generate → review → accept/refine/regenerate cycle."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    pipeline = load_pipeline(take_path)

    next_stage = find_next_stage(pipeline, take_path)
    if not next_stage:
        print("Pipeline complete! All stages have selected iterations.")
        return 0

    mode = _get_run_mode(args)
    stage_type = _classify_stage(next_stage)
    print(f"Stage: {next_stage}")
    print()

    if stage_type == "regular":
        rc = _run_regular_stage(story_path, take_path, next_stage, pipeline, args)
    elif stage_type == "frames":
        rc = _run_frames_stage(story_path, take_path, pipeline, args)
    elif stage_type == "videos":
        rc = _run_videos_stage(story_path, take_path, pipeline, args)
    elif stage_type == "titles":
        rc = _run_titles_stage(story_path, take_path, pipeline, args)
    elif stage_type == "captions":
        rc = _run_captions_stage(story_path, take_path, pipeline, args)
    elif stage_type == "assembly":
        rc = _run_assembly_stage(story_path, take_path, pipeline, args)
    else:
        print(f"Unknown stage type: {stage_type}")
        return 1

    # Auto-accept: after successful generate, automatically accept and continue
    auto_accept = _resolve_auto_accept(args, story_path)

    if rc == 0 and mode in ("generate", "regenerate", "refine") and auto_accept:
        # Reload pipeline to check if stage auto-selected during generate
        # (e.g. captions, frames auto-select themselves)
        pipeline = load_pipeline(take_path)
        next_after = find_next_stage(pipeline, take_path)

        if next_after != next_stage:
            # Stage auto-selected, skip accept → continue to next stage
            if not next_after:
                print("\nPipeline complete!")
                return 0
            print(f"\n[auto-accept] Continuing to next stage: {next_after}")
            import copy
            gen_args = copy.copy(args)
            gen_args.accept = None
            gen_args.refine = None
            gen_args.regenerate = None
            gen_args.chat = None
            return cmd_run(story_path, gen_args)
        else:
            # Stage needs explicit accept
            print(f"\n[auto-accept] Accepting and continuing...")
            import copy
            accept_args = copy.copy(args)
            accept_args.accept = ""  # empty string = accept without feedback
            accept_args.refine = None
            accept_args.regenerate = None
            accept_args.chat = None
            return cmd_run(story_path, accept_args)

    # After successful accept → continue to next stage (generate mode)
    if rc == 0 and mode == "accept":
        # Reload pipeline (accept modified it)
        pipeline = load_pipeline(take_path)
        prev_stage = next_stage  # stage we just accepted
        next_stage = find_next_stage(pipeline, take_path)
        if not next_stage:
            print("\nPipeline complete!")
            return 0

        # Detect stuck: if next stage is still the same we just accepted,
        # some items failed to generate — stop instead of looping forever
        if next_stage == prev_stage:
            print(f"\n⚠ Stage {next_stage} is not fully complete.")
            print(f"Some items could not be generated (provider errors or content filters).")
            print(f"Fix manually and re-run: orchestrator_v5.py . --episode {getattr(args, 'episode', '???')} run")
            return 1

        print()
        print(f"{'='*60}")
        print(f"Continuing to next stage: {next_stage}")
        print(f"{'='*60}")
        print()

        # Create a fresh args with no mode flags (plain generate)
        import copy
        gen_args = copy.copy(args)
        gen_args.accept = None
        gen_args.refine = None
        gen_args.regenerate = None
        gen_args.chat = None
        # Preserve auto_accept flag through the chain
        if auto_accept and not hasattr(gen_args, 'auto_accept'):
            gen_args.auto_accept = True
        return cmd_run(story_path, gen_args)

    return rc


def cmd_new(story_path: Path, args) -> int:
    """Generate fresh iteration for a stage."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    story_name = get_story_name(story_path)
    pipeline = load_pipeline(take_path)

    stage = args.stage
    feedback = args.feedback
    number = getattr(args, 'number', None)

    # Default to 3 for 07-grids, 1 for everything else
    if stage == "07-grids" and number is None:
        defaults = get_project_defaults(story_path)
        number = defaults.get("grid_generations") or 3
    elif number is None:
        number = 1

    # Batch generation for 07-grids with multiple variations
    if stage == "07-grids" and number > 1:
        return cmd_new_batch_grids(story_path, args, number)

    executor = STAGE_EXECUTORS.get(stage)
    if not executor:
        print(f"No executor for stage {stage}")
        return 1

    try:
        inputs = resolve_inputs(pipeline, stage, take_path)
    except ValueError as e:
        print(f"Error: {e}")
        return 1

    print(f"Generating new: {stage}")
    if inputs:
        print(f"Based on: {json.dumps(inputs)}")
    if feedback:
        print(f"Feedback: {feedback}")

    extra_kwargs = {}
    if stage in ("06-characteristic", "07-grids"):
        extra_kwargs["scale_refs"] = getattr(args, 'scale_refs', False)
    if stage == "07-grids" and getattr(args, 'char_ref_only', False):
        extra_kwargs["char_ref_only"] = True
    result = executor(take_path, story_name, inputs, feedback=feedback, **extra_kwargs)

    if result["status"] != "success":
        print(f"\nError: {result.get('error')}")
        return 1

    iteration = result["iteration"]
    print(f"\nGenerated: {stage}/{iteration}")

    if result.get("user_message"):
        print(f"\n{result['user_message']}")

    # Auto-accept: select + invalidate downstream + continue
    auto_accept = _resolve_auto_accept(args, story_path)
    if auto_accept:
        return _auto_accept_and_continue(story_path, take_path, stage, iteration, pipeline, args)

    # Manual mode: auto-select if first iteration, otherwise show hint
    stage_dir = take_path / stage
    iters = list_iterations(stage_dir)
    if len(iters) == 1:
        pipeline[stage] = iteration
        save_pipeline(take_path, pipeline)
        print(f"Auto-selected (only iteration)")
    else:
        current = pipeline.get(stage)
        if current:
            print(f"Current selection: {stage}/{current}")
        print(f"To select: orchestrator_v5.py {story_path} select {stage} {iteration}")

    return 0


def cmd_new_batch_grids(story_path: Path, args, number: int) -> int:
    """Generate multiple grid variations in batch."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    story_name = get_story_name(story_path)
    pipeline = load_pipeline(take_path)

    feedback = args.feedback

    try:
        inputs = resolve_inputs(pipeline, "07-grids", take_path)
    except ValueError as e:
        print(f"Error: {e}")
        return 1

    scale_refs = getattr(args, 'scale_refs', False)
    char_ref_only = getattr(args, 'char_ref_only', False)

    print(f"Generating {number} grid variations...")
    if inputs:
        print(f"Based on: {json.dumps(inputs)}")
    if feedback:
        print(f"Feedback: {feedback}")

    results = []
    for i in range(number):
        print(f"\n{'='*60}")
        print(f"Generating grid {i+1} of {number}")
        print(f"{'='*60}")

        result = stage_07_grids.execute(
            take_path, story_name, inputs,
            feedback=feedback,
            variation_index=i,
            total_variations=number,
            scale_refs=scale_refs,
            char_ref_only=char_ref_only
        )

        if result["status"] != "success":
            print(f"Error on grid {i+1}: {result.get('error')}")
            continue

        results.append(result)
        print(f"Generated: 07-grids/{result['iteration']}")

    print(f"\n{'='*60}")
    print(f"Batch generation complete!")
    print(f"{'='*60}")
    print(f"Generated {len(results)} of {number} grids:")
    for r in results:
        print(f"  07-grids/{r['iteration']}")

    if results:
        print(f"\nNext: Review grids and select one:")
        print(f"  orchestrator_v5.py {story_path} select 07-grids <iteration>")

    return 0 if results else 1


def cmd_refine(story_path: Path, args) -> int:
    """Refine a specific iteration."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    story_name = get_story_name(story_path)

    stage = args.stage
    iteration = args.iteration
    feedback = args.feedback

    # Validate iteration exists
    iter_dir = take_path / stage / iteration
    if not iter_dir.exists():
        print(f"Error: {stage}/{iteration} not found")
        return 1

    # Read its based_on to use same parents
    meta = read_metadata(iter_dir)
    inputs = meta.get("based_on", {})

    executor = STAGE_EXECUTORS.get(stage)
    if not executor:
        print(f"No executor for stage {stage}")
        return 1

    print(f"Refining: {stage}/{iteration}")
    print(f"Feedback: {feedback}")

    scale_refs = getattr(args, 'scale_refs', False)
    result = executor(
        take_path, story_name, inputs,
        feedback=feedback, refined_from=iteration,
        scale_refs=scale_refs
    )

    if result["status"] != "success":
        print(f"\nError: {result.get('error')}")
        return 1

    new_iteration = result["iteration"]
    print(f"\nGenerated: {stage}/{new_iteration} (refined from {iteration})")

    if result.get("user_message"):
        print(f"\n{result['user_message']}")

    # Auto-accept: select + invalidate downstream + continue
    auto_accept = _resolve_auto_accept(args, story_path)
    if auto_accept:
        pipeline = load_pipeline(take_path)
        return _auto_accept_and_continue(story_path, take_path, stage, new_iteration, pipeline, args)

    print(f"To select: orchestrator_v5.py {story_path} select {stage} {new_iteration}")
    return 0


def cmd_chat(story_path: Path, args) -> int:
    """Chat refinement - pass feedback directly to NanoBanana."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    story_name = get_story_name(story_path)

    stage = args.stage
    iteration = args.iteration
    feedback = args.feedback

    # Validate iteration exists
    iter_dir = take_path / stage / iteration
    if not iter_dir.exists():
        print(f"Error: {stage}/{iteration} not found")
        return 1

    # Read its based_on to use same parents
    meta = read_metadata(iter_dir)
    inputs = meta.get("based_on", {})

    executor = STAGE_EXECUTORS.get(stage)
    if not executor:
        print(f"No executor for stage {stage}")
        return 1

    print(f"Chat refinement: {stage}/{iteration}")
    print(f"Feedback: {feedback}")

    scale_refs = getattr(args, 'scale_refs', False)
    result = executor(
        take_path, story_name, inputs,
        feedback=feedback, refined_from=iteration, chat_mode=True,
        scale_refs=scale_refs
    )

    if result["status"] != "success":
        print(f"\nError: {result.get('error')}")
        return 1

    new_iteration = result["iteration"]
    print(f"\nGenerated: {stage}/{new_iteration} (chat from {iteration})")
    print(f"To select: orchestrator_v5.py {story_path} select {stage} {new_iteration}")

    if result.get("user_message"):
        print(f"\n{result['user_message']}")

    return 0


def cmd_reiterate(story_path: Path, args) -> int:
    """Reiterate: same prompt, new NanoBanana generation (skip Claude)."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    story_name = get_story_name(story_path)

    stage = args.stage
    iteration = args.iteration

    # Validate source iteration exists and has a prompt
    iter_dir = take_path / stage / iteration
    if not iter_dir.exists():
        print(f"Error: {stage}/{iteration} not found")
        return 1

    prompt_file = iter_dir / "nanobanana_prompt.txt"
    if not prompt_file.exists():
        print(f"Error: {stage}/{iteration} has no nanobanana_prompt.txt")
        return 1

    # Read its based_on to use same parents
    meta = read_metadata(iter_dir)
    inputs = meta.get("based_on", {})

    executor = STAGE_EXECUTORS.get(stage)
    if not executor:
        print(f"No executor for stage {stage}")
        return 1

    print(f"Reiterating: {stage}/{iteration} (same prompt, new image)")

    scale_refs = getattr(args, 'scale_refs', False)
    # Pass special reiterate_from to executor
    result = executor(
        take_path, story_name, inputs,
        feedback=None, refined_from=iteration, reiterate=True,
        scale_refs=scale_refs
    )

    if result["status"] != "success":
        print(f"\nError: {result.get('error')}")
        return 1

    new_iteration = result["iteration"]
    print(f"\nGenerated: {stage}/{new_iteration} (reiterated from {iteration})")
    print(f"To select: orchestrator_v5.py {story_path} select {stage} {new_iteration}")

    if result.get("user_message"):
        print(f"\n{result['user_message']}")

    return 0


def cmd_rate(story_path: Path, args) -> int:
    """Set rating on an iteration."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    stage = args.stage
    iteration = args.iteration
    rating = args.rating

    if rating < 1 or rating > 5:
        print("Error: rating must be 1-5")
        return 1

    iter_dir = take_path / stage / iteration
    if not iter_dir.exists():
        print(f"Error: {stage}/{iteration} not found")
        return 1

    meta = read_metadata(iter_dir)
    if not meta:
        print(f"Error: no metadata.json in {stage}/{iteration}")
        return 1

    old_rating = meta.get("rating")
    meta["rating"] = rating
    save_iteration_metadata(iter_dir, meta)

    print(f"Rated: {stage}/{iteration} = {rating}/5 (was: {old_rating})")
    return 0


def cmd_select(story_path: Path, args) -> int:
    """Select an iteration for the pipeline."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    pipeline = load_pipeline(take_path)

    stage = args.stage
    iteration = args.iteration

    iter_dir = take_path / stage / iteration
    if not iter_dir.exists():
        print(f"Error: {stage}/{iteration} not found")
        return 1

    # Validate consistency
    is_ok, warnings = validate_selection(take_path, stage, iteration, pipeline)

    if not is_ok:
        print(f"\nError: inconsistent selection {stage}/{iteration}:")
        for w in warnings:
            print(w)
        print()

        force = getattr(args, 'force', False)
        if not force:
            print("Use --force to select anyway.")
            return 1
        print("--force: selecting despite inconsistency.")

    # Update pipeline
    old = pipeline.get(stage)
    pipeline[stage] = iteration
    save_pipeline(take_path, pipeline)

    print(f"Selected: {stage}/{iteration}" + (f" (was: {old})" if old else ""))

    # Reselect downstream stages when parent changed
    if old and old != iteration:
        _invalidate_stale_downstream(take_path, pipeline, stage)

    return 0


def _invalidate_stale_downstream(take_path: Path, pipeline: dict, changed_stage: str):
    """Null out downstream stages whose selected iterations don't match current pipeline.

    Called after accept or select changes a stage's selection, so that stale
    downstream selections are cleared and will be regenerated on next run.
    """
    downstream = get_downstream_stages(changed_stage)
    changed = False
    for ds in downstream:
        current = pipeline.get(ds)
        if current is None:
            continue
        # Scene-based stages with dict values — null them out entirely
        if isinstance(current, dict):
            pipeline[ds] = None
            print(f"  {ds}: deselected (parent changed)")
            changed = True
            continue
        # Simple stages — check if any matching iteration exists
        best = _find_best_matching_iteration(take_path, ds, pipeline)
        if best and best == current:
            continue  # Still valid
        elif best:
            pipeline[ds] = best
            print(f"  {ds}: reselected {current} -> {best}")
            changed = True
        else:
            pipeline[ds] = None
            print(f"  {ds}: deselected (stale)")
            changed = True
    if changed:
        save_pipeline(take_path, pipeline)


def _find_best_matching_iteration(take_path: Path, stage: str, pipeline: dict) -> str | None:
    """Find the best iteration for a stage that matches current pipeline parents.

    Returns iteration with highest rating (then most recent) whose based_on
    matches the current pipeline selections for its parent stages.
    Returns None if no matching iteration exists.
    """
    stage_dir = take_path / stage
    iters = list_iterations(stage_dir)
    if not iters:
        return None

    # What parents should look like
    deps = STAGE_DEPENDENCIES.get(stage, [])
    expected_parents = {}
    for dep in deps:
        parent_sel = pipeline.get(dep)
        if parent_sel is None:
            # Parent not yet selected — no iteration can be valid
            return None
        expected_parents[dep] = parent_sel

    candidates = []
    for iter_num in iters:
        meta = read_metadata(stage_dir / iter_num)
        if not meta:
            continue
        based_on = meta.get("based_on", {})
        # Check all parent stages match
        matches = True
        for dep, expected in expected_parents.items():
            if based_on.get(dep) != expected:
                matches = False
                break
        if matches:
            rating = meta.get("rating") or 0
            candidates.append((rating, iter_num))

    if not candidates:
        return None

    # Sort by rating desc, then iteration desc (most recent)
    candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
    return candidates[0][1]


def cmd_status(story_path: Path, args) -> int:
    """Show pipeline state."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    pipeline = load_pipeline(take_path)

    print(f"Story: {story_path.name}")
    print(f"Take: {take_path.name}")
    print()
    print(f"{'Stage':<22} {'Selected':>8}  {'Rating':>6}  {'Iters':>5}")
    print("-" * 50)

    for stage in PIPELINE_STAGES:
        selected = pipeline.get(stage)
        stage_dir = take_path / stage

        # Special handling for scene-based stages
        if stage in ["08-frames", "10-videos", "11-titles", "12-captions"]:
            # Get total scene count from extraction metadata
            scene_count = 0
            extraction_meta_file = take_path / "08-frames" / "extraction_metadata.json"
            if extraction_meta_file.exists():
                with open(extraction_meta_file) as f:
                    meta = json.load(f)
                    scene_count = meta["scene_count"]

            if isinstance(selected, dict):
                selected_count = sum(1 for v in selected.values() if v)
                sel_str = f"{selected_count}/{scene_count}"
                rating_str = "-"
                n_iters = 1  # Indicate extraction/generation happened
            else:
                sel_str = "---"
                rating_str = ""
                n_iters = 0
        else:
            # Standard iteration-based stages
            iters = list_iterations(stage_dir)
            n_iters = len(iters)

            # Get rating of selected
            rating_str = ""
            if selected:
                meta = read_metadata(stage_dir / selected)
                r = meta.get("rating")
                if r:
                    rating_str = "*" * r
                else:
                    rating_str = "-"

            if selected:
                sel_str = selected
            elif n_iters > 0:
                sel_str = "???"  # iterations exist but none selected
            else:
                sel_str = "---"

        print(f"{stage:<22} {sel_str:>8}  {rating_str:>6}  {n_iters:>5}")

    # Show next stage
    next_stage = find_next_stage(pipeline, take_path)
    if next_stage:
        print(f"\nNext stage: {next_stage}")
        print(f"  run    - generate it")
    else:
        print("\nPipeline complete!")

    return 0


def cmd_migrate(story_path: Path, args) -> int:
    """Migrate v4 take to v5 format."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    print(f"Migrating: {take_path}")

    # Build pipeline from current symlinks
    pipeline = {}
    for stage in PIPELINE_STAGES:
        stage_dir = take_path / stage
        current_link = stage_dir / "current"

        if current_link.is_symlink():
            target = current_link.readlink().name
            pipeline[stage] = target
            print(f"  {stage}: current -> {target}")
        else:
            iters = list_iterations(stage_dir)
            if iters:
                pipeline[stage] = None  # iterations exist but no selection
                print(f"  {stage}: {len(iters)} iterations, no current symlink")
            else:
                pipeline[stage] = None
                print(f"  {stage}: ---")

    # Save pipeline.json
    save_pipeline(take_path, pipeline)
    print(f"\nCreated: {take_path / 'pipeline.json'}")

    # Enhance existing metadata files with V5 fields
    enhanced = 0
    for stage in PIPELINE_STAGES:
        stage_dir = take_path / stage
        for iter_num in list_iterations(stage_dir):
            iter_dir = stage_dir / iter_num
            meta = read_metadata(iter_dir)
            if not meta:
                continue

            changed = False

            if "based_on" not in meta:
                # Try to reconstruct from v4 fields
                based_on = {}
                for dep in STAGE_DEPENDENCIES.get(stage, []):
                    # Best guess: use the pipeline selection for the parent
                    parent_sel = pipeline.get(dep)
                    if parent_sel:
                        based_on[dep] = parent_sel
                meta["based_on"] = based_on
                changed = True

            if "refined_from" not in meta:
                meta["refined_from"] = meta.get("parent_iteration")
                changed = True

            if "rating" not in meta:
                meta["rating"] = None
                changed = True

            if "created_at" not in meta:
                meta["created_at"] = meta.get("timestamp")
                changed = True

            if changed:
                save_iteration_metadata(iter_dir, meta)
                enhanced += 1

    print(f"Enhanced {enhanced} iteration metadata files")

    # Rename v4 files
    queue_file = take_path / "task_queue.json"
    if queue_file.exists():
        backup = take_path / "task_queue.json.v4"
        if not backup.exists():
            queue_file.rename(backup)
            print(f"Backed up: task_queue.json -> task_queue.json.v4")

    feedback_file = take_path / "feedback.jsonl"
    if feedback_file.exists():
        backup = take_path / "feedback.jsonl.v4"
        if not backup.exists():
            feedback_file.rename(backup)
            print(f"Backed up: feedback.jsonl -> feedback.jsonl.v4")

    print("\nMigration complete!")
    return 0


# ============================================================
# Stage 08 Commands (Frame Extraction)
# ============================================================


def cmd_extract_frames(story_path: Path, args) -> int:
    """Extract frames from all grid iterations."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    story_name = get_story_name(story_path)

    force = getattr(args, 'force', False)

    print(f"Extracting frames: {take_path}")
    print()

    result = stage_08_frames.extract_frames(take_path, story_name, force=force)

    if result["status"] == "success":
        print()
        print("=" * 60)
        print(f"Frame extraction completed!")
        print("=" * 60)

        new_grids = result.get('new_grids', result['grid_count'])
        new_frames = result.get('new_frames', result['total_frames'])

        if new_grids == 0:
            print("No new grids to extract - all grids already processed")
            print(f"Total: {result['grid_count']} grids × {result['scene_count']} scenes = {result['total_frames']} frames")
        elif new_grids < result['grid_count']:
            print(f"Incremental extraction:")
            print(f"  New: {new_grids} grids × {result['scene_count']} scenes = {new_frames} frames")
            print(f"  Total: {result['grid_count']} grids × {result['scene_count']} scenes = {result['total_frames']} frames")
        else:
            print(f"Extracted: {result['grid_count']} grids × {result['scene_count']} scenes = {result['total_frames']} frames")

        print()
        print("Next steps:")
        print(f"  1. orchestrator_v5.py . status-frames")
        print(f"  2. orchestrator_v5.py . select-frame SCENE FRAME")
        print(f"  3. orchestrator_v5.py . refine-frame SCENE BASE_FRAME 'feedback' (optional)")

        # Update main pipeline
        pipeline = load_pipeline(take_path)
        pipeline["08-frames"] = "extracted"
        save_pipeline(take_path, pipeline)
        print()
        print("Updated main pipeline: 08-frames = extracted")

        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_refine_frame(story_path: Path, args) -> int:
    """Refine a scene frame using NanoBanana chat."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    story_name = get_story_name(story_path)

    scene = args.scene
    base_frame = args.base_frame
    feedback = args.feedback
    reference_images = getattr(args, 'reference_images', None)
    scale_refs = getattr(args, 'scale_refs', False)

    print(f"Refining frame: {take_path}")
    print(f"Scene: {scene}")
    print(f"Base frame: {base_frame}")
    print(f"Feedback: {feedback}")
    if reference_images:
        print(f"Reference images: {len(reference_images)}")
        for ref in reference_images:
            print(f"  - {ref}")
    print()

    result = stage_08_frames.refine_scene_frame(
        take_path, story_name, scene, base_frame, feedback, reference_images,
        scale_refs=scale_refs
    )

    if result["status"] == "success":
        print()
        print("=" * 60)
        print(f"Frame refinement completed!")
        print("=" * 60)
        print(f"Created: {result['frame']}.jpg")
        print(f"Chat turn: {result['chat_turn']}")
        print()
        print("Next steps:")
        print(f"  1. Review: 08-frames/{scene}/{result['frame']}.jpg")
        print(f"  2. select-frame {scene} {result['frame']} (if satisfied)")
        print(f"  3. refine-frame {scene} {base_frame} 'more feedback' (continue chat)")
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_select_frame(story_path: Path, args) -> int:
    """Select a frame for a scene."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    scene = args.scene
    frame = args.frame

    print(f"Selecting frame: {scene} → {frame}")

    result = stage_08_frames.select_scene_frame(take_path, scene, frame)

    if result["status"] == "success":
        print(f"✓ Selected {frame}.jpg for {scene}")
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_rate_frame(story_path: Path, args) -> int:
    """Rate a scene frame."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    scene = args.scene
    frame = args.frame
    rating = args.rating

    result = stage_08_frames.rate_scene_frame(take_path, scene, frame, rating)

    if result["status"] == "success":
        print(f"✓ Rated {frame}.jpg: {rating}/5")
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_status_frames(story_path: Path, args) -> int:
    """Show status of all scene frame selections."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    result = stage_08_frames.get_frames_status(take_path)

    if result["status"] == "error":
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1

    scenes = result["scenes"]
    grid_count = result["grid_count"]
    scene_count = result["scene_count"]

    print(f"Frame Status: {take_path}")
    print(f"Grids extracted: {grid_count}")
    print(f"Scenes: {scene_count}")
    print()
    print(f"{'Scene':<15} {'Selected':<20} Available Frames")
    print("-" * 80)

    unselected = []
    for scene_data in scenes:
        scene = scene_data["scene"]
        selected = scene_data["selected"] or "---"
        available_base = scene_data["available_base"]
        available_refined = scene_data["available_refined"]

        # Show warning if not selected
        warning = "" if scene_data["selected"] else " [⚠ NOT SELECTED]"

        # Format available frames
        all_available = available_base + available_refined
        avail_str = ", ".join(all_available[:5])
        if len(all_available) > 5:
            avail_str += f", ... ({len(all_available)} total)"

        print(f"{scene:<15} {selected:<20} {avail_str}{warning}")

        if not scene_data["selected"]:
            unselected.append(scene)

    print()
    if unselected:
        print(f"⚠ {len(unselected)} scenes not selected. Stage 10 is blocked.")
        print(f"Run: select-frame SCENE FRAME for each unselected scene")
    else:
        print(f"✓ All scenes selected! Ready for Stage 09 (script adaptation).")

    return 0


def _run_titles_stage(story_path: Path, take_path: Path, pipeline: dict, args) -> int:
    """Handle run for stage 11-titles."""
    mode = _get_run_mode(args)
    defaults = get_project_defaults(story_path)
    title_types = defaults.get("title_types", ["intro", "outro"])

    # Mark skipped title types in pipeline
    ep_pipeline = load_pipeline(take_path)
    titles_state = ep_pipeline.get("11-titles") or {}
    for tt in ["intro", "outro"]:
        if tt not in title_types:
            if titles_state.get(tt) != "skip":
                titles_state[tt] = "skip"
                print(f"Skipping {tt} (not in title_types config)")
    ep_pipeline["11-titles"] = titles_state
    save_pipeline(take_path, ep_pipeline)

    # --- ACCEPT ---
    if mode == "accept":
        status = stage_11_titles.get_titles_status(take_path)
        accepted = 0
        for title_type in title_types:
            ts = status.get(title_type, {})
            if ts.get("selected"):
                accepted += 1
                continue
            available = ts.get("available", [])
            if available:
                result = stage_11_titles.select_title(take_path, title_type, available[-1])
                if result["status"] == "success":
                    print(f"Accepted: {title_type} → {available[-1]}")
                    accepted += 1
        if accepted == len(title_types):
            print(f"\nAll {len(title_types)} title card(s) accepted! Ready for Stage 12.")
        else:
            print(f"\n{accepted}/{len(title_types)} title cards accepted.")
        return 0

    # --- REFINE ---
    if mode == "refine":
        scene = getattr(args, 'scene', None)
        iteration = getattr(args, 'iteration', None)
        title_type = scene or "intro"
        if title_type not in ("intro", "outro"):
            print(f"Title type must be 'intro' or 'outro', got: {title_type}")
            return 1
        base_frame = iteration
        if not base_frame:
            status = stage_11_titles.get_titles_status(take_path)
            ts = status.get(title_type, {})
            available = ts.get("available", [])
            base_frame = available[-1] if available else None
        if not base_frame:
            print(f"No frames found for {title_type}")
            return 1
        feedback = args.refine
        result = stage_11_titles.refine_title(take_path, title_type, base_frame, feedback)
        if result["status"] == "success":
            print(f"\nRefined: {title_type}/{result['frame']}")
            return 0
        else:
            print(f"Error: {result.get('error')}")
            return 1

    # --- GENERATE (default) ---
    status = stage_11_titles.get_titles_status(take_path)
    generated_any = False
    for title_type in title_types:
        ts = status.get(title_type, {})
        if not ts.get("available"):
            print(f"Generating {title_type} title card...")
            result = stage_11_titles.generate_title(take_path, title_type)
            if result["status"] == "success":
                print(f"Created: 11-titles/{title_type}/{result['frame']}.jpg")
                generated_any = True
            else:
                print(f"Error generating {title_type}: {result.get('error')}")
        else:
            print(f"{title_type}: {len(ts['available'])} frames available")

    # Show status
    cmd_status_titles(story_path, args)
    return 0


def cmd_generate_title(story_path: Path, args) -> int:
    """Generate title card image for intro or outro."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    title_type = getattr(args, 'type', 'intro')

    print(f"Generating {title_type} title card: {take_path}")
    print()

    result = stage_11_titles.generate_title(take_path, title_type)

    if result["status"] == "success":
        print()
        print("=" * 60)
        print(f"Title card generated!")
        print("=" * 60)
        print(f"Created: 11-titles/{title_type}/{result['frame']}.jpg")
        print()
        print("Next steps:")
        print(f"  1. Review: 11-titles/{title_type}/{result['frame']}.jpg")
        print(f"  2. select-title {title_type} {result['frame']} (if satisfied)")
        print(f"  3. refine-title {title_type} {result['frame']} 'feedback' (to iterate)")
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_refine_title(story_path: Path, args) -> int:
    """Refine title card using NanoBanana chat."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    title_type = args.type
    base_frame = args.base_frame
    feedback = args.feedback

    print(f"Refining {title_type} title card: {take_path}")
    print(f"Base frame: {base_frame}")
    print(f"Feedback: {feedback}")
    print()

    result = stage_11_titles.refine_title(take_path, title_type, base_frame, feedback)

    if result["status"] == "success":
        print()
        print("=" * 60)
        print(f"Title card refinement completed!")
        print("=" * 60)
        print(f"Created: 11-titles/{title_type}/{result['frame']}.jpg")
        print(f"Chat turn: {result['chat_turn']}")
        print()
        print("Next steps:")
        print(f"  1. Review: 11-titles/{title_type}/{result['frame']}.jpg")
        print(f"  2. select-title {title_type} {result['frame']} (if satisfied)")
        print(f"  3. refine-title {title_type} {base_frame} 'more feedback' (continue chat)")
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_select_title(story_path: Path, args) -> int:
    """Select a title card frame."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    title_type = args.type
    frame = args.frame

    print(f"Selecting title: {title_type} → {frame}")

    result = stage_11_titles.select_title(take_path, title_type, frame)

    if result["status"] == "success":
        print(f"Selected {frame}.jpg for {title_type}")
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_status_titles(story_path: Path, args) -> int:
    """Show status of title card selections."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    status = stage_11_titles.get_titles_status(take_path)

    print(f"\nTitle Card Status: {take_path}")
    print(f"{'Type':<10} {'Selected':<20} Available")
    print("-" * 60)

    for title_type in ["intro", "outro"]:
        ts = status.get(title_type, {})
        selected = ts.get("selected") or "---"
        available = ts.get("available", [])
        avail_str = ", ".join(available[:5]) if available else "none"
        warning = "" if ts.get("selected") else " [NOT SELECTED]"
        print(f"{title_type:<10} {selected:<20} {avail_str}{warning}")

    print()
    return 0


# ============================================================
# Stage 10: Video Generation Commands
# ============================================================


def cmd_new_video(story_path: Path, args) -> int:
    """Generate new video iteration for scene."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    story_name = story_path.name

    defaults = get_project_defaults(story_path)

    scene = args.scene
    feedback = args.feedback
    provider = args.provider or defaults["video_provider"]
    duration = args.duration
    generate_audio = not args.no_audio
    end_frame = args.end_frame
    resolution = args.resolution
    direct_prompt = getattr(args, 'prompt', None)

    # Auto-resolve end_frame and end_scene for interpolation mode
    end_scene = None
    if defaults.get("video_mode") == "interpolation" and not end_frame:
        from lib.pipeline import load_pipeline
        pipeline = load_pipeline(take_path)
        frames_selections = pipeline.get("08-frames", {})
        scene_num = int(scene.split('-')[0])
        if scene_num % 2 == 1:  # odd scene = start of interpolation pair
            end_scene = f"{scene_num + 1:02d}-scene"
            end_sel = frames_selections.get(end_scene)
            if end_sel:
                end_frame_candidate = str(take_path / "08-frames" / end_scene / f"{end_sel}.jpg")
                if Path(end_frame_candidate).exists():
                    end_frame = end_frame_candidate

    print(f"Generating new video: {take_path}")
    print(f"Scene: {scene}")
    print(f"Provider: {provider}")
    if duration:
        print(f"Duration: {duration}s")
    else:
        print(f"Duration: Auto-calculate from dialogue")
    print(f"Audio: {'on' if generate_audio else 'off'}")
    if resolution != "720p":
        print(f"Resolution: {resolution}")
    if feedback:
        print(f"Feedback: {feedback}")
    if end_frame:
        print(f"End frame: {end_frame}")
    if end_scene:
        print(f"End scene (interpolation): {end_scene}")
    if direct_prompt:
        print(f"Direct prompt: (using exact prompt, bypassing Claude)")
    print()

    result = stage_10_videos.new_video(
        take_path=take_path,
        scene=scene,
        feedback=feedback,
        provider=provider,
        duration=duration,
        generate_audio=generate_audio,
        end_frame=end_frame,
        end_scene=end_scene,
        resolution=resolution,
        direct_prompt=direct_prompt
    )

    if result["status"] == "success":
        print()
        print("=" * 60)
        print(f"Video generation completed!")
        print("=" * 60)
        print(f"Scene: {result['scene']}")
        print(f"Iteration: {result['iteration']}")
        print(f"Provider: {result['provider']}")
        print(f"Duration: {result['duration']}s")
        print(f"Output: {result['video_path']}")
        print()

        # Display feedback request if available
        if "feedback_request" in result:
            feedback_request_file = Path(result["feedback_request"])
            if feedback_request_file.exists():
                import json
                with open(feedback_request_file) as f:
                    feedback_req = json.load(f)

                print(feedback_req.get("user_message", "Review the video."))
                print()
                print("Actions:")
                for option in feedback_req.get("options", []):
                    label = option.get("label", "")
                    desc = option.get("description", "")
                    cmd = option.get("command", "")
                    print(f"  {label}: {desc}")
                    if cmd:
                        print(f"    → {cmd}")
                print()

        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_refine_video(story_path: Path, args) -> int:
    """Refine existing video with feedback."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    scene = args.scene
    iteration = args.iteration
    feedback = args.feedback
    provider = args.provider
    duration = args.duration
    generate_audio = None if args.no_audio is None else (not args.no_audio)
    end_frame = args.end_frame
    resolution = getattr(args, 'resolution', None)
    direct_prompt = getattr(args, 'prompt', None)

    print(f"Refining video: {take_path}")
    print(f"Scene: {scene}")
    print(f"Base iteration: {iteration}")
    if direct_prompt:
        print(f"Using direct prompt (bypassing Claude)")
    else:
        print(f"Feedback: {feedback}")
    if provider:
        print(f"Provider override: {provider}")
    if duration:
        print(f"Duration override: {duration}s")
    if generate_audio is not None:
        print(f"Audio override: {'on' if generate_audio else 'off'}")
    if resolution:
        print(f"Resolution override: {resolution}")
    if end_frame:
        print(f"End frame: {end_frame}")
    print()

    result = stage_10_videos.refine_video(
        take_path=take_path,
        scene=scene,
        iteration=iteration,
        feedback=feedback,
        provider=provider,
        duration=duration,
        generate_audio=generate_audio,
        end_frame=end_frame,
        resolution=resolution,
        direct_prompt=direct_prompt
    )

    if result["status"] == "success":
        print()
        print("=" * 60)
        print(f"Video refinement completed!")
        print("=" * 60)
        print(f"Created: {result['iteration']}")
        print(f"Refined from: {iteration}")
        print(f"Output: {result['video_path']}")
        print()

        # Display feedback request if available
        if "feedback_request" in result:
            feedback_request_file = Path(result["feedback_request"])
            if feedback_request_file.exists():
                import json
                with open(feedback_request_file) as f:
                    feedback_req = json.load(f)

                print(feedback_req.get("user_message", "Review the refined video."))
                print()
                print("Actions:")
                for option in feedback_req.get("options", []):
                    label = option.get("label", "")
                    desc = option.get("description", "")
                    cmd = option.get("command", "")
                    print(f"  {label}: {desc}")
                    if cmd:
                        print(f"    → {cmd}")
                print()

        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_reiterate_video(story_path: Path, args) -> int:
    """Reiterate video with same prompt but different settings."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    scene = args.scene
    iteration = args.iteration
    provider = args.provider
    duration = args.duration
    generate_audio = None if args.no_audio is None else (not args.no_audio)
    end_frame = args.end_frame
    resolution = getattr(args, 'resolution', None)

    print(f"Reiterating video: {take_path}")
    print(f"Scene: {scene}")
    print(f"Source iteration: {iteration}")
    if provider:
        print(f"Provider override: {provider}")
    if duration:
        print(f"Duration override: {duration}s")
    if generate_audio is not None:
        print(f"Audio override: {'on' if generate_audio else 'off'}")
    if resolution:
        print(f"Resolution override: {resolution}")
    if end_frame:
        print(f"End frame: {end_frame}")
    print()

    result = stage_10_videos.reiterate_video(
        take_path=take_path,
        scene=scene,
        iteration=iteration,
        provider=provider,
        duration=duration,
        generate_audio=generate_audio,
        end_frame=end_frame,
        resolution=resolution
    )

    if result["status"] == "success":
        print()
        print("=" * 60)
        print(f"Video reiteration completed!")
        print("=" * 60)
        print(f"Created: {result['iteration']}")
        print(f"Prompt from: {iteration}")
        print(f"Output: {result['output']}")
        print()

        # Display feedback request if available
        if "feedback_request" in result:
            feedback_request_file = Path(result["feedback_request"])
            if feedback_request_file.exists():
                import json
                with open(feedback_request_file) as f:
                    feedback_req = json.load(f)

                print(feedback_req.get("user_message", "Review the reiterated video."))
                print()
                print("Actions:")
                for option in feedback_req.get("options", []):
                    label = option.get("label", "")
                    desc = option.get("description", "")
                    cmd = option.get("command", "")
                    print(f"  {label}: {desc}")
                    if cmd:
                        print(f"    → {cmd}")
                print()

        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_select_video(story_path: Path, args) -> int:
    """Select a video for a scene."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    scene = args.scene
    iteration = args.iteration

    print(f"Selecting video: {scene} → {iteration}")

    result = stage_10_videos.select_video(take_path, scene, iteration)

    if result["status"] == "success":
        print(f"✓ Selected iteration {iteration} for {scene}")
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_rate_video(story_path: Path, args) -> int:
    """Rate a scene video."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    scene = args.scene
    iteration = args.iteration
    rating = args.rating

    result = stage_10_videos.rate_video(take_path, scene, iteration, rating)

    if result["status"] == "success":
        print(f"✓ Rated {scene}/{iteration}: {rating}/5")
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_status_videos(story_path: Path, args) -> int:
    """Show status of all scene video selections."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    result = stage_10_videos.status_videos(take_path)

    if result["status"] == "error":
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1

    scenes = result["scenes"]
    scene_count = result["scene_count"]

    print(f"Video Status: {take_path}")
    print(f"Scenes: {scene_count}")
    print()
    print(f"{'Scene':<15} {'Selected':<15} {'Iterations':<15} Available")
    print("-" * 80)

    unselected = []
    for scene_data in scenes:
        scene = scene_data["scene"]
        selected = scene_data["selected"] or "---"
        iteration_count = scene_data["iteration_count"]
        iterations = scene_data["iterations"]

        # Show warning if not selected
        warning = "" if scene_data["selected"] else " [⚠ NOT SELECTED]"

        # Format available iterations
        iter_str = ", ".join(iterations[:5])
        if len(iterations) > 5:
            iter_str += f", ... ({len(iterations)} total)"

        print(f"{scene:<15} {selected:<15} {iteration_count:<15} {iter_str}{warning}")

        if not scene_data["selected"]:
            unselected.append(scene)

    print()
    if unselected:
        print(f"⚠ {len(unselected)} scenes not selected. Stage 11/12/13 are blocked.")
        print(f"Run: select-video SCENE ITERATION for each unselected scene")
    else:
        print(f"✓ All scenes selected! Ready for Stage 11 (Titles), 12 (Captions), or 13 (Assembly).")

    return 0


# ============================================================
# Stage 12: Caption Commands
# ============================================================

def cmd_generate_captions(story_path: Path, args) -> int:
    """Generate captions for all scenes with dialogue."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    force = getattr(args, 'force', False)

    print(f"Generating captions: {take_path}")
    if force:
        print("Force regenerate: ON")
    print()

    result = stage_12_captions.generate_captions(take_path, force_regenerate=force)

    if result["status"] == "error":
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1

    results = result["results"]

    print()
    print("=" * 60)
    print("Caption Generation Complete")
    print("=" * 60)

    if results["generated"]:
        print(f"\nGenerated ({len(results['generated'])}):")
        for item in results["generated"]:
            print(f"  {item['scene']}: iteration {item['iteration']} - {item['lines']} line(s)")
            print(f"    \"{item['text'][:60]}{'...' if len(item['text']) > 60 else ''}\"")

    if results["already_exists"]:
        print(f"\nAlready exists ({len(results['already_exists'])}):")
        for item in results["already_exists"]:
            print(f"  {item['scene']}: iteration {item['iteration']}")

    if results["skipped"]:
        print(f"\nSkipped - no dialogue ({len(results['skipped'])}):")
        for scene in results["skipped"]:
            print(f"  {scene}")

    if results["no_video"]:
        print(f"\nNo video selected ({len(results['no_video'])}):")
        for scene in results["no_video"]:
            print(f"  {scene}")

    print()
    print("Next: Review captions with status-captions")
    print("      Refine with: refine-caption SCENE ITERATION --feedback \"...\"")
    print("      Then: assemble (Stage 13)")

    return 0


def cmd_new_caption(story_path: Path, args) -> int:
    """Generate new caption for a specific scene."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    scene = args.scene
    feedback = getattr(args, 'feedback', None)

    print(f"Generating new caption: {scene}")
    if feedback:
        print(f"Feedback: {feedback}")
    print()

    result = stage_12_captions.new_caption(take_path, scene, feedback)

    if result["status"] == "success":
        print(f"✓ Created caption iteration {result['iteration']} for {scene}")
        print(f"  Lines: {result['lines']}")
        print(f"  Text: \"{result['text']}\"")
        return 0
    elif result["status"] == "skipped":
        print(f"Skipped: {result.get('reason', 'no dialogue')}")
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_refine_caption(story_path: Path, args) -> int:
    """Refine existing caption with feedback."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    scene = args.scene
    iteration = args.iteration
    feedback = args.feedback

    print(f"Refining caption: {scene}/{iteration}")
    print(f"Feedback: {feedback}")
    print()

    result = stage_12_captions.refine_caption(take_path, scene, iteration, feedback)

    if result["status"] == "success":
        print(f"✓ Created refined caption iteration {result['iteration']}")
        print(f"  Lines: {result['lines']}")
        print(f"  Text: \"{result['text']}\"")
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_select_caption(story_path: Path, args) -> int:
    """Select caption iteration for a scene."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    scene = args.scene
    iteration = args.iteration

    print(f"Selecting caption: {scene} → {iteration}")

    result = stage_12_captions.select_caption(take_path, scene, iteration)

    if result["status"] == "success":
        print(f"✓ Selected caption iteration {iteration} for {scene}")
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_status_captions(story_path: Path, args) -> int:
    """Show caption status for all scenes."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    result = stage_12_captions.status_captions(take_path)

    if result["status"] == "error":
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1

    scenes = result["scenes"]

    print(f"Caption Status: {take_path}")
    print()
    print(f"{'Scene':<12} {'Dialogue':<10} {'Video':<10} {'Caption':<10} {'Iterations'}")
    print("-" * 70)

    needs_caption = []
    for scene_data in scenes:
        scene = scene_data["scene"]
        has_dialogue = "Yes" if scene_data["has_dialogue"] else "No"
        video = scene_data["video_selected"] or "---"
        caption = scene_data["caption_selected"] or "---"
        iterations = ", ".join(scene_data["iterations"][:3])
        if len(scene_data["iterations"]) > 3:
            iterations += f"... ({len(scene_data['iterations'])})"

        print(f"{scene:<12} {has_dialogue:<10} {video:<10} {caption:<10} {iterations}")

        if scene_data["has_dialogue"] and scene_data["video_selected"] and not scene_data["caption_selected"]:
            needs_caption.append(scene)

    print()
    if needs_caption:
        print(f"⚠ {len(needs_caption)} scenes need captions: {', '.join(needs_caption)}")
        print("Run: generate-captions")
    else:
        print("✓ All scenes with dialogue have captions selected.")
        print("Ready for Stage 12 (Assembly).")

    return 0


# ============================================================
# Stage 13: Assembly Commands
# ============================================================

def cmd_trim_scene(story_path: Path, args) -> int:
    """Set or clear trim duration and start offset for a scene during assembly."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    scene = args.scene
    duration = getattr(args, 'duration', None)
    start = getattr(args, 'start', None)

    pipeline = load_pipeline(take_path)
    scene_trims = pipeline.get("scene_trims", {})
    scene_trim_starts = pipeline.get("scene_trim_starts", {})

    if duration is None and start is None:
        # Clear all trims for this scene
        changed = False
        if scene in scene_trims:
            del scene_trims[scene]
            changed = True
        if scene in scene_trim_starts:
            del scene_trim_starts[scene]
            changed = True
        if changed:
            pipeline["scene_trims"] = scene_trims
            pipeline["scene_trim_starts"] = scene_trim_starts
            save_pipeline(take_path, pipeline)
            print(f"Cleared trim for {scene}")
        else:
            print(f"No trim set for {scene}")
        return 0

    if start is not None:
        scene_trim_starts[scene] = float(start)
        pipeline["scene_trim_starts"] = scene_trim_starts
    if duration is not None:
        scene_trims[scene] = float(duration)
        pipeline["scene_trims"] = scene_trims

    save_pipeline(take_path, pipeline)

    parts = []
    if start is not None:
        parts.append(f"start={float(start):.1f}s")
    if duration is not None:
        parts.append(f"duration={float(duration):.1f}s")
    print(f"Set trim for {scene}: {', '.join(parts)}")
    return 0


def _print_scene_order(scene_order):
    """Print numbered scene order list."""
    for i, scene in enumerate(scene_order, 1):
        print(f"  {i:2d}. {scene}")


def cmd_swap_scenes(story_path: Path, args) -> int:
    """Swap the assembly order of two scenes."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    pipeline = load_pipeline(take_path)

    video_selections = pipeline.get("10-videos", {})
    scene_order = pipeline.get("scene_order", None)

    # Build default order if not set
    if scene_order is None:
        scene_order = sorted(video_selections.keys(), key=lambda x: int(x.split('-')[0]))

    scene_a, scene_b = args.scene_a, args.scene_b
    if scene_a not in scene_order or scene_b not in scene_order:
        print(f"Error: both scenes must be in the order list: {scene_a}, {scene_b}")
        return 1

    idx_a = scene_order.index(scene_a)
    idx_b = scene_order.index(scene_b)
    scene_order[idx_a], scene_order[idx_b] = scene_order[idx_b], scene_order[idx_a]

    pipeline["scene_order"] = scene_order
    save_pipeline(take_path, pipeline)
    print(f"Swapped {scene_a} and {scene_b}")
    _print_scene_order(scene_order)
    return 0


def cmd_scene_order(story_path: Path, args) -> int:
    """Show, set, or reset scene assembly order.

    Usage:
      scene-order                              Show current order
      scene-order --reset                      Reset to default numeric order
      scene-order 06 05 01 02 03 07 09         Set custom order (unlisted scenes skipped)
    """
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    pipeline = load_pipeline(take_path)

    video_selections = pipeline.get("10-videos", {})
    default_order = sorted(video_selections.keys(), key=lambda x: int(x.split('-')[0]))

    if getattr(args, 'reset', False):
        if "scene_order" in pipeline:
            del pipeline["scene_order"]
            save_pipeline(take_path, pipeline)
        print("Reset to default scene order")
        _print_scene_order(default_order)
        return 0

    scenes = getattr(args, 'scenes', None)
    if scenes:
        # Parse scene numbers: "06" -> "06-scene", "06-scene" -> "06-scene"
        new_order = []
        for s in scenes:
            scene_name = s if "-scene" in s else f"{int(s):02d}-scene"
            if scene_name not in video_selections:
                print(f"Warning: {scene_name} has no video selected, skipping")
                continue
            if scene_name in new_order:
                print(f"Warning: {scene_name} already in order, skipping duplicate")
                continue
            new_order.append(scene_name)

        if not new_order:
            print("Error: no valid scenes specified")
            return 1

        # Show which scenes are skipped
        skipped = [s for s in default_order if s not in new_order]

        pipeline["scene_order"] = new_order
        save_pipeline(take_path, pipeline)
        print(f"Set scene order ({len(new_order)} scenes):")
        _print_scene_order(new_order)
        if skipped:
            print(f"\nSkipped: {', '.join(skipped)}")
        return 0

    scene_order = pipeline.get("scene_order", default_order)
    _print_scene_order(scene_order)
    return 0


def cmd_title_duration(story_path: Path, args) -> int:
    """Set title card duration for assembly."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))
    pipeline = load_pipeline(take_path)

    title_type = args.type
    duration = args.duration

    title_durations = pipeline.get("title_durations", {})
    title_durations[title_type] = float(duration)
    pipeline["title_durations"] = title_durations
    save_pipeline(take_path, pipeline)
    print(f"Set {title_type} title card duration: {duration}s")
    return 0


def cmd_assemble(story_path: Path, args) -> int:
    """Assemble final video from scene clips."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    no_captions = getattr(args, 'no_captions', False)
    is_clean = getattr(args, 'clean', False)

    print(f"Assembling video: {take_path}")
    if no_captions:
        print("Captions: DISABLED")
    if is_clean:
        print("Mode: CLEAN (no scene numbers)")
    else:
        print("Mode: REVIEW (scene numbers in top-right)")
    print()

    result = stage_13_assembly.assemble(take_path, no_captions=no_captions, final=is_clean)

    if result["status"] == "success":
        print()
        print("=" * 60)
        print("Assembly Complete!")
        print("=" * 60)
        print(f"Iteration: {result['iteration']}")
        print(f"Output: {result['output_path']}")
        print(f"Duration: {result['duration']:.1f}s")
        print(f"Resolution: {result['resolution']}")
        print(f"Size: {result['size_mb']:.1f}MB")
        print(f"Scenes: {result['scene_count']}")
        print(f"Caption lines: {result['caption_count']}")

        # Show streaming variants
        variants = result.get('variants', {})
        if variants:
            print()
            print("Streaming variants:")
            if 'web' in variants:
                print(f"  final_web.mp4: {variants['web']['size_mb']:.1f}MB (faststart)")
            if 'telegram' in variants:
                print(f"  final_telegram.mp4: {variants['telegram']['size_mb']:.1f}MB (optimized)")

        print()
        return 0
    else:
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1


def cmd_status_assembly(story_path: Path, args) -> int:
    """Show assembly status."""
    take_path = resolve_take_path(story_path, getattr(args, 'episode', None))

    result = stage_13_assembly.status_assembly(take_path)

    if result["status"] == "error":
        print(f"Error: {result.get('error', 'Unknown error')}")
        return 1

    print(f"Assembly Status: {take_path}")
    print()

    if not result["iterations"]:
        print("No assemblies yet.")
        print("Run: assemble")
        return 0

    print(f"Selected: {result['selected'] or '---'}")
    print()
    print(f"{'Iteration':<12} {'Duration':<12} {'Size':<12} {'Scenes':<10} {'Captions'}")
    print("-" * 60)

    for item in result["iterations"]:
        iteration = item.get("iteration", "???")
        duration = f"{item.get('duration', 0):.1f}s"
        size = f"{item.get('size_mb', 0):.1f}MB"
        scenes = str(item.get("scene_count", 0))
        captions = str(item.get("caption_count", 0))

        selected = " ✓" if iteration == result["selected"] else ""
        print(f"{iteration:<12} {duration:<12} {size:<12} {scenes:<10} {captions}{selected}")

    print()
    return 0


# ============================================================
# Main
# ============================================================


def main():
    parser = argparse.ArgumentParser(
        description="V5 Orchestrator: Immutable Log + Pipeline"
    )
    parser.add_argument("story_path", type=Path, help="Path to story directory")
    parser.add_argument("--episode", "-e", metavar="NUM",
                       help="Episode (take) number to operate on (default: current symlink or latest)")

    subparsers = parser.add_subparsers(dest="command", required=True)

    # init
    subparsers.add_parser("init", help="Initialize story structure + episode 001")

    # new-episode
    p_new_episode = subparsers.add_parser("new-episode", help="Create new episode (002, 003, ...)")
    p_new_episode.add_argument("--brief", help="Brief description of this episode's concept")

    # init-template
    p_init_template = subparsers.add_parser("init-template", help="Initialize project from template")
    p_init_template.add_argument("template", help="Template name (e.g., instagram-influencer)")
    p_init_template.add_argument("--variant", "-v", help="Template variant (e.g., lifestyle, product)")

    # analyze-refs
    p_analyze_refs = subparsers.add_parser("analyze-refs", help="Analyze reference photos and generate index")
    p_analyze_refs.add_argument("--force", action="store_true", help="Force re-analysis even if index is up-to-date")

    # run (unified command)
    p_run = subparsers.add_parser("run", help="Generate / accept / refine / regenerate next stage")
    run_mode = p_run.add_mutually_exclusive_group()
    run_mode.add_argument("--accept", nargs="?", const="", default=None, metavar="FEEDBACK",
                          help="Accept (select) current iteration and advance. Optional feedback forwarded to next stage.")
    run_mode.add_argument("--refine", metavar="FEEDBACK",
                          help="Refine latest iteration with feedback")
    run_mode.add_argument("--regenerate", nargs="?", const="", default=None, metavar="FEEDBACK",
                          help="Generate fresh new iteration (like 'new' command)")
    run_mode.add_argument("--chat", metavar="FEEDBACK",
                          help="Chat refinement (06-characteristic, 07-grids)")
    # Scene targeting (08-frames, 10-videos, 11-titles, 12-captions)
    p_run.add_argument("--scene", metavar="SCENE", help="Target scene (e.g. 01-scene)")
    p_run.add_argument("--iteration", metavar="ITER", help="Target iteration within scene")
    # Passthrough options
    p_run.add_argument("--provider", help="Video provider (e.g. kling-direct, pollo-veo3)")
    p_run.add_argument("--duration", type=int, choices=[4, 5, 6, 8, 10], help="Video duration")
    p_run.add_argument("--no-audio", action="store_true", help="Disable audio generation")
    p_run.add_argument("--resolution", default="1080p", choices=["720p", "1080p"], help="Video resolution")
    p_run.add_argument("-n", "--number", type=int, default=None, help="Number of variations (07-grids)")
    p_run.add_argument("--no-captions", action="store_true", help="Assembly without captions")
    p_run.add_argument("--clean", action="store_true", help="Assembly without debug scene numbers (clean output)")
    p_run.add_argument("--force", action="store_true", help="Force re-extract/regenerate")
    p_run.add_argument("--scale-refs", action="store_true",
                       help="Scale down reference images to 2K before sending to NanoBanana")
    p_run.add_argument("--char-ref-only", action="store_true",
                       help="Use ONLY characteristic shot as reference (no character sheets/locations)")
    p_run.add_argument("--auto-accept", type=_parse_auto_accept, default=None, metavar="yes|no",
                       help="Auto-accept each stage and continue (yes/no, overrides config)")

    # new
    p_new = subparsers.add_parser("new", help="Fresh iteration for stage")
    p_new.add_argument("stage", help="Stage name (e.g. 07-grids)")
    p_new.add_argument("feedback", nargs="?", default=None, help="Optional feedback")
    p_new.add_argument("--number", "-n", type=int, default=None,
                       help="Number of variations (07-grids default: 3)")
    p_new.add_argument("--scale-refs", action="store_true",
                       help="Scale down reference images to 2K before sending to NanoBanana")
    p_new.add_argument("--char-ref-only", action="store_true",
                       help="Use ONLY characteristic shot as reference (no character sheets/locations)")
    p_new.add_argument("--auto-accept", type=_parse_auto_accept, default=None, metavar="yes|no",
                       help="Auto-accept and continue downstream (yes/no, overrides config)")

    # refine
    p_refine = subparsers.add_parser("refine", help="Refine specific iteration")
    p_refine.add_argument("stage", help="Stage name")
    p_refine.add_argument("iteration", help="Iteration to refine (e.g. 013)")
    p_refine.add_argument("feedback", help="Feedback text")
    p_refine.add_argument("--scale-refs", action="store_true",
                          help="Scale down reference images to 2K before sending to NanoBanana")
    p_refine.add_argument("--auto-accept", type=_parse_auto_accept, default=None, metavar="yes|no",
                          help="Auto-accept and continue downstream (yes/no, overrides config)")

    # reiterate
    p_reiterate = subparsers.add_parser("reiterate", help="Same prompt, new NanoBanana generation (skip Claude)")
    p_reiterate.add_argument("stage", help="Stage name")
    p_reiterate.add_argument("iteration", help="Source iteration to copy prompt from")
    p_reiterate.add_argument("--scale-refs", action="store_true",
                             help="Scale down reference images to 2K before sending to NanoBanana")
    p_reiterate.add_argument("--auto-accept", type=_parse_auto_accept, default=None, metavar="yes|no",
                             help="Auto-accept and continue downstream (yes/no, overrides config)")

    # chat
    p_chat = subparsers.add_parser("chat", help="Chat refinement (direct to NanoBanana)")
    p_chat.add_argument("stage", help="Stage name")
    p_chat.add_argument("iteration", help="Iteration to chat from (e.g. 047)")
    p_chat.add_argument("feedback", help="Feedback text")
    p_chat.add_argument("--scale-refs", action="store_true",
                        help="Scale down reference images to 2K before sending to NanoBanana")
    p_chat.add_argument("--auto-accept", type=_parse_auto_accept, default=None, metavar="yes|no",
                        help="Auto-accept and continue downstream (yes/no, overrides config)")

    # rate
    p_rate = subparsers.add_parser("rate", help="Rate iteration (1-5)")
    p_rate.add_argument("stage", help="Stage name")
    p_rate.add_argument("iteration", help="Iteration number")
    p_rate.add_argument("rating", type=int, help="Rating 1-5")

    # select
    p_select = subparsers.add_parser("select", help="Select iteration for pipeline")
    p_select.add_argument("stage", help="Stage name")
    p_select.add_argument("iteration", help="Iteration number")
    p_select.add_argument("--force", action="store_true",
                          help="Select even if parents are inconsistent")

    # status
    subparsers.add_parser("status", help="Show pipeline state")

    # migrate
    subparsers.add_parser("migrate", help="Migrate v4 take to v5")

    # extract-frames
    p_extract = subparsers.add_parser("extract-frames", help="Extract frames from all grids")
    p_extract.add_argument("--force", action="store_true", help="Re-extract even if already extracted")

    # refine-frame
    p_refine_frame = subparsers.add_parser("refine-frame", help="Refine scene frame with NanoBanana chat")
    p_refine_frame.add_argument("scene", help="Scene name (e.g. 01-scene)")
    p_refine_frame.add_argument("base_frame", help="Base frame number (e.g. 001)")
    p_refine_frame.add_argument("feedback", help="Feedback text")
    p_refine_frame.add_argument("--reference", action="append", dest="reference_images", help="Additional reference image path (can be specified multiple times)")
    p_refine_frame.add_argument("--scale-refs", action="store_true",
                                help="Scale down reference images to 2K before sending to NanoBanana")

    # select-frame
    p_select_frame = subparsers.add_parser("select-frame", help="Select frame for scene")
    p_select_frame.add_argument("scene", help="Scene name (e.g. 01-scene)")
    p_select_frame.add_argument("frame", help="Frame name (e.g. 001 or 001-refined-002)")

    # rate-frame
    p_rate_frame = subparsers.add_parser("rate-frame", help="Rate scene frame (1-5)")
    p_rate_frame.add_argument("scene", help="Scene name")
    p_rate_frame.add_argument("frame", help="Frame name")
    p_rate_frame.add_argument("rating", type=int, help="Rating 1-5")

    # status-frames
    subparsers.add_parser("status-frames", help="Show frame selection status")

    # Stage 11: Title Card Commands
    # generate-title
    p_gen_title = subparsers.add_parser("generate-title", help="Generate title card (intro or outro)")
    p_gen_title.add_argument("type", choices=["intro", "outro"], help="Title type")

    # refine-title
    p_refine_title = subparsers.add_parser("refine-title", help="Refine title card with NanoBanana chat")
    p_refine_title.add_argument("type", choices=["intro", "outro"], help="Title type")
    p_refine_title.add_argument("base_frame", help="Base frame number (e.g. 001)")
    p_refine_title.add_argument("feedback", help="Feedback text")

    # select-title
    p_select_title = subparsers.add_parser("select-title", help="Select title card frame")
    p_select_title.add_argument("type", choices=["intro", "outro"], help="Title type")
    p_select_title.add_argument("frame", help="Frame name (e.g. 001 or 001-refined-002)")

    # status-titles
    subparsers.add_parser("status-titles", help="Show title card status")

    # Stage 10: Video Generation
    # new-video
    p_new_video = subparsers.add_parser("new-video", help="Generate new video iteration for scene")
    p_new_video.add_argument("scene", help="Scene name (e.g., 01-scene)")
    p_new_video.add_argument("--feedback", help="User feedback to modify prompt")
    p_new_video.add_argument("--provider", default=None,
                           help="Provider (default: from story_pipeline.json or pollo-kling)")
    p_new_video.add_argument("--duration", type=int, choices=[3, 4, 5, 6, 7, 8, 9, 10],
                           help="Duration in seconds (None = auto, Kling v2.6: 5/10, O1: 3-10, Veo3: 4/6/8)")
    p_new_video.add_argument("--no-audio", action="store_true",
                           help="Disable audio generation")
    p_new_video.add_argument("--end-frame", help="Optional end frame path for interpolation")
    p_new_video.add_argument("--resolution", default="1080p", choices=["720p", "1080p"],
                           help="Video resolution for Veo3 providers (default: 1080p)")
    p_new_video.add_argument("--prompt", help="Exact prompt to use (bypasses Claude generation)")

    # refine-video
    p_refine_video = subparsers.add_parser("refine-video", help="Refine existing video with feedback")
    p_refine_video.add_argument("scene", help="Scene name")
    p_refine_video.add_argument("iteration", help="Base iteration to refine")
    p_refine_video.add_argument("--feedback", required=True, help="User feedback")
    p_refine_video.add_argument("--provider", help="Override provider")
    p_refine_video.add_argument("--duration", type=int, choices=[4, 5, 6, 8, 10],
                              help="Override duration (Veo3: 4/6/8, Kling: 5/10)")
    p_refine_video.add_argument("--no-audio", action="store_true",
                              help="Disable audio")
    p_refine_video.add_argument("--end-frame", help="Optional end frame path")
    p_refine_video.add_argument("--resolution", choices=["720p", "1080p"],
                              help="Override resolution (for Veo3 providers)")
    p_refine_video.add_argument("--prompt", help="Exact prompt to use (bypasses Claude generation)")

    # reiterate-video
    p_reiterate_video = subparsers.add_parser("reiterate-video", help="Reiterate video with same prompt but different settings")
    p_reiterate_video.add_argument("scene", help="Scene name")
    p_reiterate_video.add_argument("iteration", help="Source iteration to copy prompt from")
    p_reiterate_video.add_argument("--provider", help="Override provider")
    p_reiterate_video.add_argument("--duration", type=int, choices=[4, 5, 6, 8, 10],
                                  help="Override duration (Veo3: 4/6/8, Kling: 5/10)")
    p_reiterate_video.add_argument("--no-audio", action="store_true",
                                  help="Disable audio")
    p_reiterate_video.add_argument("--end-frame", help="Optional end frame path")
    p_reiterate_video.add_argument("--resolution", choices=["720p", "1080p"],
                                  help="Override resolution (for Veo3 providers)")

    # select-video
    p_select_video = subparsers.add_parser("select-video", help="Select video for scene")
    p_select_video.add_argument("scene", help="Scene name")
    p_select_video.add_argument("iteration", help="Iteration to select")

    # rate-video
    p_rate_video = subparsers.add_parser("rate-video", help="Rate video (1-5)")
    p_rate_video.add_argument("scene", help="Scene name")
    p_rate_video.add_argument("iteration", help="Iteration to rate")
    p_rate_video.add_argument("rating", type=int, choices=[1, 2, 3, 4, 5],
                             help="Rating 1-5")

    # status-videos
    subparsers.add_parser("status-videos", help="Show video generation status")

    # Stage 12: Caption Commands
    # generate-captions
    p_gen_captions = subparsers.add_parser("generate-captions", help="Generate captions for all scenes with dialogue")
    p_gen_captions.add_argument("--force", action="store_true",
                                help="Force regenerate all captions")

    # new-caption
    p_new_caption = subparsers.add_parser("new-caption", help="Generate new caption for specific scene")
    p_new_caption.add_argument("scene", help="Scene name")
    p_new_caption.add_argument("--feedback", help="Optional guidance for caption generation")

    # refine-caption
    p_refine_caption = subparsers.add_parser("refine-caption", help="Refine existing caption with feedback")
    p_refine_caption.add_argument("scene", help="Scene name")
    p_refine_caption.add_argument("iteration", help="Base iteration to refine")
    p_refine_caption.add_argument("--feedback", required=True, help="Refinement feedback")

    # select-caption
    p_select_caption = subparsers.add_parser("select-caption", help="Select caption for scene")
    p_select_caption.add_argument("scene", help="Scene name")
    p_select_caption.add_argument("iteration", help="Iteration to select")

    # status-captions
    subparsers.add_parser("status-captions", help="Show caption status for all scenes")

    # Stage 13: Assembly
    # trim-scene
    p_trim = subparsers.add_parser("trim-scene", help="Set trim duration/start for a scene during assembly")
    p_trim.add_argument("scene", help="Scene name (e.g. 01-scene)")
    p_trim.add_argument("duration", type=float, nargs="?", default=None,
                        help="Keep this many seconds (omit to clear all trims)")
    p_trim.add_argument("--start", type=float, default=None,
                        help="Skip this many seconds from the beginning")

    # swap-scenes
    p_swap = subparsers.add_parser("swap-scenes", help="Swap assembly order of two scenes")
    p_swap.add_argument("scene_a", help="First scene (e.g. 07-scene)")
    p_swap.add_argument("scene_b", help="Second scene (e.g. 08-scene)")

    # scene-order
    p_order = subparsers.add_parser("scene-order", help="Show, set, or reset scene assembly order")
    p_order.add_argument("scenes", nargs="*", help="Scene numbers in desired order (e.g. 06 05 01 02). Unlisted scenes are skipped.")
    p_order.add_argument("--reset", action="store_true", help="Reset to default numeric order")

    # title-duration
    p_tdur = subparsers.add_parser("title-duration", help="Set title card duration for assembly")
    p_tdur.add_argument("type", choices=["intro", "outro"], help="Title type")
    p_tdur.add_argument("duration", type=float, help="Duration in seconds")

    # assemble
    p_assemble = subparsers.add_parser("assemble", help="Assemble final video from scene clips")
    p_assemble.add_argument("--no-captions", action="store_true",
                           help="Disable caption overlay")
    p_assemble.add_argument("--clean", action="store_true",
                           help="Clean output without debug scene numbers")

    # status-assembly
    subparsers.add_parser("status-assembly", help="Show assembly status")

    args = parser.parse_args()

    # Override LAOZHANG_KEY from story_pipeline.json if set (per-project token)
    _sp_file = args.story_path / "story_pipeline.json"
    if _sp_file.exists():
        try:
            _sp = json.loads(_sp_file.read_text())
            _lk = _sp.get("laozhang_key")
            if _lk:
                os.environ["LAOZHANG_KEY"] = _lk
        except (json.JSONDecodeError, OSError):
            pass

    commands = {
        "init": cmd_init,
        "new-episode": cmd_new_episode,
        "init-template": cmd_init_template,
        "analyze-refs": cmd_analyze_refs,
        "run": cmd_run,
        "new": cmd_new,
        "refine": cmd_refine,
        "reiterate": cmd_reiterate,
        "chat": cmd_chat,
        "rate": cmd_rate,
        "select": cmd_select,
        "status": cmd_status,
        "migrate": cmd_migrate,
        "extract-frames": cmd_extract_frames,
        "refine-frame": cmd_refine_frame,
        "select-frame": cmd_select_frame,
        "rate-frame": cmd_rate_frame,
        "status-frames": cmd_status_frames,
        # Stage 11: Titles
        "generate-title": cmd_generate_title,
        "refine-title": cmd_refine_title,
        "select-title": cmd_select_title,
        "status-titles": cmd_status_titles,
        "new-video": cmd_new_video,
        "refine-video": cmd_refine_video,
        "reiterate-video": cmd_reiterate_video,
        "select-video": cmd_select_video,
        "rate-video": cmd_rate_video,
        "status-videos": cmd_status_videos,
        # Stage 12: Captions
        "generate-captions": cmd_generate_captions,
        "new-caption": cmd_new_caption,
        "refine-caption": cmd_refine_caption,
        "select-caption": cmd_select_caption,
        "status-captions": cmd_status_captions,
        # Stage 13: Assembly
        "trim-scene": cmd_trim_scene,
        "swap-scenes": cmd_swap_scenes,
        "scene-order": cmd_scene_order,
        "title-duration": cmd_title_duration,
        "assemble": cmd_assemble,
        "status-assembly": cmd_status_assembly,
    }

    handler = commands.get(args.command)
    if not handler:
        print(f"Unknown command: {args.command}")
        return 1

    try:
        return handler(args.story_path, args)
    except Exception as e:
        print(f"Error: {e}")
        import traceback
        traceback.print_exc()
        return 1


if __name__ == "__main__":
    sys.exit(main())
