#!/usr/bin/env python3
"""Starsend Review Server — review tool + Production Console backend.

Serves production-console.html (Production Console) and review.html (legacy frame review, deprecated).
Provides API endpoints for frame review, bible, budget, board, dailies,
batch launching, stale detection, and casting (for Recoil Writing Room).

Usage:
    python editors/review_server.py --project tartarus
    python editors/review_server.py --project tartarus --port 8430

Endpoints — Review:
    GET  /                              — Redirect to /console
    GET  /console                       — Serve production-console.html (Production Console)
    GET  /review                        — Serve review.html (legacy frame review)
    GET  /api/episodes                  — List available episodes
    GET  /api/frames/{episode}          — List generated frames for an episode
    GET  /api/plan/{episode}             — Return shot plan or generation log
    GET  /api/cost/{episode}            — Return cost_log.json
    GET  /api/previs/{episode}          — List previs frames
    GET  /api/execution/{episode}       — Return execution plan state
    POST /api/accept/{episode}/{shot_id} — Mark frame as accepted
    POST /api/reject/{episode}/{shot_id} — Mark frame as rejected
    POST /api/promote/{episode}/{shot_id} — Promote frame to output/refs/
    POST /api/approve-previs            — Approve previs frame

Endpoints — Console:
    GET   /api/bible                    — GlobalBible JSON
    PATCH /api/bible/character/{id}     — Manual trait override
    GET   /api/budget                   — Season-level cost aggregation
    GET   /api/board                    — All episodes with 5-state counts
    GET   /api/board/{episode}          — Scene-level drill-down
    POST  /api/launch-batch             — PreFlightChecker + cost estimate
    POST  /api/launch-batch/confirm     — Start generation
    GET   /api/dailies                  — Priority queue
    POST  /api/dailies/approve          — Approve shot
    POST  /api/dailies/reject           — Reject + re-queue
    GET   /api/dailies/filmstrip/{ep}/{shot} — Prev/current/next frame paths
    POST  /api/dailies/override         — Save manual_prompt_override
    POST  /api/dailies/reroute          — Change shot model
    POST  /api/dailies/abandon          — Remove shot from production
    POST  /api/dailies/select-take      — Write selected_take_id
    POST  /api/dailies/unlock          — Undo approval (previs_approved → previs_generated)
    POST  /api/set-previz-hero         — Set a take as the hero frame for a shot
    POST  /api/generate-previz         — Generate single-shot previz, append to takes[]
    GET   /api/stale-check/{episode}    — Compare source_hash
    POST  /api/dailies/batch-override   — Apply override to multiple shots
    GET   /api/studio-budget            — Cross-project budget aggregation

Endpoints — Review Queue (Phase 3):
    GET   /api/review-queue?project=<name>  — JSON list of pending review queue entries
    POST  /api/review-queue/resolve         — Resolve entry (approve/reject/retry)

Endpoints — Keyframe Pipeline (Layer 2 + Layer 3):
    POST  /api/smart-prompt             — Flash text call to build NBP-optimized keyframe prompt
    POST  /api/generate-keyframe        — Generate keyframe via NBP (async, $0.134)
    POST  /api/lock-keyframe            — Lock keyframe + set anchor role
    POST  /api/extract-frame            — Generate first/last frame from locked keyframe ($0.134)
    POST  /api/confirm-frame-pair       — Confirm frame pair → video_pending

Endpoints — Casting (consumed by Recoil Writing Room, ADR-C12):
    GET   /api/project/{name}/casting/characters         — Characters + casting state + refs
    GET   /api/project/{name}/casting/expressions[/{id}] — Expression library
    GET   /api/project/{name}/casting/locations           — Location ref index
    POST  /api/project/{name}/casting/generate-grid      — NBP 3x3 concept grid
    POST  /api/project/{name}/casting/select-hero        — Pick hero from grid
    POST  /api/project/{name}/casting/generate-turnaround — 4-angle turnaround from hero
    POST  /api/project/{name}/casting/approve-ref        — Approve/reject turnaround angle
    POST  /api/project/{name}/casting/generate-expressions — Grayscale expression library
    POST  /api/project/{name}/casting/generate-location  — Location moodboard via Flash 3.1
    POST  /api/project/{name}/casting/select-location-hero — Pick hero location ref
Endpoints — Wardrobe Intent Gate:
    POST  /api/project/{name}/wardrobe-intent/propose-philosophy  — Generate 3 series philosophy options
    POST  /api/project/{name}/wardrobe-intent/approve-philosophy  — Save director's pick
    POST  /api/project/{name}/wardrobe-intent/propose-theses      — Generate 3 thesis options per character
    POST  /api/project/{name}/wardrobe-intent/approve-thesis      — Save director's pick per character
    POST  /api/project/{name}/wardrobe-intent/rewrite-phases      — Preview rewritten descriptions (no write)
    POST  /api/project/{name}/wardrobe-intent/apply-rewrite       — Commit preview to bible

Endpoints — Screen Test:
    GET   /api/project/{name}/screen-test/{character}                 — Phase grid state + bible data
    POST  /api/project/{name}/screen-test/{character}                 — Generate all empty/rejected phases
    POST  /api/project/{name}/screen-test/{character}/{phase}/reroll  — Re-roll one phase
    POST  /api/project/{name}/screen-test/{character}/{phase}/verdict — Lock/hold/reject a phase
    POST  /api/project/{name}/screen-test/{character}/set-anchor      — Set style anchor phase

ADR-R13: Filmmaker Console.
"""

import hashlib
import http.server
import json
import os
import shutil
import sys
import time
from pathlib import Path
import threading
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import unquote, urlparse, parse_qs

PORT = 8430
HOST = "127.0.0.1"

# ── Project root detection ──────────────────────────────────────────
# Walk up from this file to find the starsend root (contains output/)
EDITORS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = EDITORS_DIR.parent

# Ensure project root is on sys.path so lib/ imports work
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from recoil.core.model_profiles import get_segment_duration_bounds
from recoil.core.paths import projects_root, project_output_dir, STATE_NAMESPACE
from recoil.execution.execution_store import InvalidTransitionError
from recoil.pipeline._lib.take_keys import TakeNumberMissingError, read_take_number
from recoil.pipeline.core.cost import read_cost_from_result, read_cost_from_record_safe

try:
    from editors.inspector_api import (
        get_inspector_data,
        get_inspector_notes,
        save_inspector_notes,
    )

    INSPECTOR_AVAILABLE = True
except ImportError:
    INSPECTOR_AVAILABLE = False

_DEPRECATED_SURFACE = True  # This server is deprecated in favor of Console v2.
# 17 hardcoded output/refs/ URL constructions remain — minimal migration
# for promote-write paths only. Full migration deferred to Console v2 cutover.

# ── Default project (optional seed — frontend drives project selection) ─
DEFAULT_PROJECT: str = (
    None  # Fallback when no ?project= in request. Auto-detected if --project omitted.
)
_PROJECT_OUTPUT = None  # Set by _init_project()

# Legacy model name translation — catches old frontend requests during partial updates
LEGACY_MODEL_MAP = {
    "kling-3.0": "kling-v3",
    "kling-3.0-fal": "kling-v3",
    "kling-v3-fal": "kling-v3",
    "kling-o3-fal": "kling-o3",
}

# ── Review Queue Frontend (Phase 3) ─────────────────────────────────
# Served at /editors/review-queue.js — injected into the Dailies tab.
# Contains loadReviewQueue(), resolveReviewItem(), CSS, and DOM builder.
REVIEW_QUEUE_FRONTEND_JS = r"""
(() => {
  /* ── Review Queue CSS ────────────────────────────────────────────── */
  const style = document.createElement('style');
  style.textContent = `
    .review-queue-section {
      border-bottom: 1px solid var(--border-default);
      padding: 12px 16px;
      background: var(--bg-secondary);
    }
    .review-queue-section.empty {
      display: none;
    }
    .rq-header {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 10px;
    }
    .rq-title {
      font-family: var(--font-mono);
      font-size: 11px;
      font-weight: 600;
      color: var(--text-secondary);
      letter-spacing: 1px;
      text-transform: uppercase;
    }
    .rq-badge {
      background: var(--accent-red);
      color: #fff;
      font-family: var(--font-mono);
      font-size: 10px;
      font-weight: 600;
      border-radius: 10px;
      padding: 1px 7px;
      min-width: 18px;
      text-align: center;
    }
    .rq-items {
      display: flex;
      flex-direction: column;
      gap: 6px;
    }
    .rq-card {
      display: flex;
      align-items: center;
      justify-content: space-between;
      background: var(--bg-tertiary);
      border: 1px solid var(--border-dim);
      border-radius: var(--radius-md);
      padding: 8px 12px;
      transition: border-color var(--transition-fast);
    }
    .rq-card:hover {
      border-color: var(--border-bright);
    }
    .rq-card.stale {
      border-left: 3px solid var(--accent-amber);
    }
    .rq-card-info {
      display: flex;
      flex-direction: column;
      gap: 2px;
      min-width: 0;
      flex: 1;
    }
    .rq-card-row {
      display: flex;
      align-items: center;
      gap: 8px;
      flex-wrap: wrap;
    }
    .rq-shot-id {
      font-family: var(--font-mono);
      font-size: 12px;
      font-weight: 700;
      color: var(--text-primary);
    }
    .rq-reason {
      font-family: var(--font-sans);
      font-size: 11px;
      color: var(--text-label);
    }
    .rq-failure-mode {
      font-family: var(--font-mono);
      font-size: 10px;
      color: var(--accent-amber);
    }
    .rq-stale-tag {
      font-family: var(--font-mono);
      font-size: 9px;
      color: var(--accent-amber);
      font-weight: 600;
      letter-spacing: 0.5px;
    }
    .rq-meta {
      font-family: var(--font-mono);
      font-size: 10px;
      color: var(--text-dim);
    }
    .rq-cost {
      font-family: var(--font-mono);
      font-size: 10px;
      color: var(--text-dim);
    }
    .rq-actions {
      display: flex;
      gap: 6px;
      flex-shrink: 0;
      margin-left: 12px;
    }
    .rq-btn {
      font-family: var(--font-mono);
      font-size: 11px;
      font-weight: 600;
      border: none;
      border-radius: var(--radius-sm);
      padding: 4px 10px;
      cursor: pointer;
      transition: opacity var(--transition-fast);
      letter-spacing: 0.5px;
    }
    .rq-btn:hover { opacity: 0.85; }
    .rq-btn:active { opacity: 0.7; }
    .rq-btn:disabled { opacity: 0.4; cursor: not-allowed; }
    .rq-btn-approve { background: var(--accent-green); color: #fff; }
    .rq-btn-retry   { background: #1565c0; color: #fff; }
    .rq-btn-reject  { background: #616161; color: #fff; }
    .rq-empty {
      font-family: var(--font-mono);
      font-size: 11px;
      color: var(--text-dim);
      padding: 4px 0;
    }
  `;
  document.head.appendChild(style);

  /* ── State ───────────────────────────────────────────────────────── */
  const RQ_POLL_MS = 30000;
  let rqTimer = null;
  let rqEntries = [];

  /* ── loadReviewQueue ─────────────────────────────────────────────── */
  async function loadReviewQueue() {
    const proj = ConsoleApp.state?.project;
    if (!proj) return;
    try {
      const data = await ConsoleApp.starsendGet('/api/review-queue');
      if (data.error) return;
      rqEntries = (data.entries || []).filter(e => e.status === 'pending');
      renderReviewQueue();
    } catch (e) { /* silent */ }
  }

  /* ── resolveReviewItem ───────────────────────────────────────────── */
  async function resolveReviewItem(itemId, resolution) {
    const proj = ConsoleApp.state?.project;
    if (!proj) return;
    // Disable buttons on the card while resolving
    const card = document.querySelector(`.rq-card[data-rqid="${itemId}"]`);
    if (card) card.querySelectorAll('.rq-btn').forEach(b => b.disabled = true);
    try {
      await ConsoleApp.starsendPost('/api/review-queue/resolve', {
        rq_id: itemId,
        resolution: resolution,
        project: proj,
      });
      await loadReviewQueue();
    } catch (e) { /* silent */ }
  }

  /* ── Render ──────────────────────────────────────────────────────── */
  function ensureContainer() {
    let container = document.getElementById('review-queue-section');
    if (container) return container;
    const dailies = document.getElementById('panel-dailies');
    if (!dailies) return null;
    container = document.createElement('div');
    container.id = 'review-queue-section';
    container.className = 'review-queue-section empty';
    dailies.insertBefore(container, dailies.firstChild);
    return container;
  }

  function renderReviewQueue() {
    const container = ensureContainer();
    if (!container) return;

    if (!rqEntries.length) {
      container.classList.add('empty');
      container.innerHTML = '';
      return;
    }
    container.classList.remove('empty');

    const cards = rqEntries.map(e => {
      const staleClass = e.stale ? ' stale' : '';
      const staleTag = e.stale ? '<span class="rq-stale-tag">STALE</span>' : '';
      const cost = typeof e.total_cost_usd === 'number' ? `$${e.total_cost_usd.toFixed(3)}` : '';
      const ts = e.created_at ? _formatTimestamp(e.created_at) : '';
      const ep = e.episode_id || '';

      return `<div class="rq-card${staleClass}" data-rqid="${_esc(e.rq_id)}">
        <div class="rq-card-info">
          <div class="rq-card-row">
            <span class="rq-shot-id">${_esc(e.shot_id || '?')}</span>
            <span class="rq-reason">${_esc(e.reason || '')}</span>
            ${staleTag}
          </div>
          <div class="rq-card-row">
            <span class="rq-failure-mode">${_esc(e.failure_mode || '')}</span>
            <span class="rq-cost">${cost}</span>
            ${ep ? `<span class="rq-meta">${_esc(ep)}</span>` : ''}
            <span class="rq-meta">${ts}</span>
          </div>
        </div>
        <div class="rq-actions">
          <button class="rq-btn rq-btn-approve" onclick="resolveReviewItem('${_esc(e.rq_id)}','approved')">APPROVE</button>
          <button class="rq-btn rq-btn-retry" onclick="resolveReviewItem('${_esc(e.rq_id)}','retry')">RETRY</button>
          <button class="rq-btn rq-btn-reject" onclick="resolveReviewItem('${_esc(e.rq_id)}','rejected')">REJECT</button>
        </div>
      </div>`;
    }).join('');

    container.innerHTML = `
      <div class="rq-header">
        <span class="rq-title">Review Queue</span>
        <span class="rq-badge">${rqEntries.length}</span>
      </div>
      <div class="rq-items">${cards}</div>`;
  }

  function _formatTimestamp(iso) {
    try {
      const d = new Date(iso);
      const now = new Date();
      const diffMs = now - d;
      const diffH = Math.floor(diffMs / 3600000);
      if (diffH < 1) return `${Math.floor(diffMs / 60000)}m ago`;
      if (diffH < 24) return `${diffH}h ago`;
      const diffD = Math.floor(diffH / 24);
      return `${diffD}d ago`;
    } catch { return ''; }
  }

  function _esc(s) {
    return ConsoleApp.escapeHTML ? ConsoleApp.escapeHTML(String(s || '')) : String(s || '');
  }

  /* ── Polling / Tab hooks ─────────────────────────────────────────── */
  function startPolling() {
    loadReviewQueue();
    if (rqTimer) clearInterval(rqTimer);
    rqTimer = setInterval(loadReviewQueue, RQ_POLL_MS);
  }

  function stopPolling() {
    if (rqTimer) { clearInterval(rqTimer); rqTimer = null; }
  }

  document.addEventListener('DOMContentLoaded', () => {
    ConsoleApp.on?.('tabChanged', (data) => {
      if (data?.tab === 'dailies') {
        startPolling();
      } else {
        stopPolling();
      }
    });
  });

  /* ── Expose globally ─────────────────────────────────────────────── */
  window.loadReviewQueue = loadReviewQueue;
  window.resolveReviewItem = resolveReviewItem;
})();
"""


def _init_project(project: str):
    """Initialize default project and ensure directories exist. Called once from main()."""
    global DEFAULT_PROJECT, _PROJECT_OUTPUT

    DEFAULT_PROJECT = project
    pp = _paths_for_project(project)
    _PROJECT_OUTPUT = pp["output_dir"]
    pp["output_dir"].mkdir(parents=True, exist_ok=True)
    pp["refs_dir"].mkdir(parents=True, exist_ok=True)
    pp["plans_dir"].mkdir(parents=True, exist_ok=True)


# ── Dynamic project path resolution ───────────────────────────────


def _paths_for_project(project: str) -> dict:
    """Return a dict of paths for any project (not just the startup one).

    This is the Single Source of Truth for the project's filesystem schema.
    All API handlers should use these paths — never the module-level globals.
    """
    proj = project.lower()
    out = project_output_dir(proj)
    state_dir = projects_root() / proj / "state" / "visual"
    refs = out / "refs"
    return {
        "project": proj,
        "project_dir": projects_root() / proj,
        "output_dir": out,
        "frames_dir": out / "frames",
        "previs_dir": out / "previs",
        "video_dir": out / "video",
        "bundles_dir": out / "bundles",
        "refs_dir": refs,
        "character_refs_dir": refs / "characters",
        "location_refs_dir": refs / "locations",
        "plans_dir": state_dir / "plans",
        "bible_path": state_dir / "global_bible.json",
        "casting_state_path": state_dir / "casting_state.json",
        "state_dir": state_dir,
    }


def _get_project_aspect_ratio(project):
    pp = _paths_for_project(project)
    config_path = pp["project_dir"] / "project_config.json"
    if config_path.exists():
        try:
            cfg = json.loads(config_path.read_text(encoding="utf-8"))
            return str(cfg.get("aspect_ratio", "9:16"))
        except (json.JSONDecodeError, OSError):
            pass
    from recoil.core.paths import get_config

    return str(get_config().get("production_aspect_ratio", "9:16"))


def _api_assets_list_sync(project=None, *, project_root: "Path | None" = None) -> dict:
    """Phase 1 rewrite: thin wrapper over ref_resolver.

    Returns a dict of ``{"characters": {...}, "locations": {...}, "props": {...}}``
    shaped for the Production Console. Per-asset entries preserve the
    bible payload (display_name, visual_description, etc.) and add an
    ``id`` key plus a ``refs`` list with ``{filename, path, hero}`` entries.

    The whole point of the Phase 1 refactor is that this endpoint and
    the pipeline-side callers see the SAME view of canonical refs. All
    enumeration flows through recoil.core.ref_resolver (the canonical resolver;
    pipeline._lib.ref_resolver shim was tombstoned 2026-05-24). The ref_resolver
    monopoly property test enforces this.

    Used by both the HTTP handler (_api_assets_list) and the
    ui/pipeline parity test (test_ui_pipeline_parity.py).

    Two call modes:
      - HTTP handler:    _api_assets_list_sync(project="tartarus")
      - Test (no bible): _api_assets_list_sync(project_root=tmp_path)

    When ``project_root`` is supplied, paths are derived from it directly
    (no projects_root() lookup) and assets are discovered from the canonical
    filesystem when no bible exists. This is the mode the parity test
    uses to enforce the no-drift gate on a synthetic project tree.
    """
    from recoil.core import ref_resolver
    from recoil.pipeline._lib.taxonomy import slugify_asset_id

    if project_root is not None:
        project_root = Path(project_root)
        output_dir = project_root / "output"
        state_dir = project_root / "state" / "visual"
        # No starsend fallback — fail loudly if state/visual is missing.
        # state/starsend is deprecated; stale stubs there would silently corrupt results.
        if not state_dir.exists():
            raise FileNotFoundError(
                f"state/visual not found at {state_dir}. "
                f"state/starsend is deprecated — run the starsend→engine migration "
                f"if the project still has data there."
            )
    else:
        pp = _paths_for_project(project)
        output_dir = pp["output_dir"]
        state_dir = pp["state_dir"]

    bible_path = state_dir / "client_bible.json"
    if not bible_path.exists():
        bible_path = state_dir / "global_bible.json"

    if bible_path.exists():
        try:
            bible = json.loads(bible_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            bible = {}
    else:
        bible = {}

    refs_root = output_dir / "refs"
    result: dict = {"characters": {}, "locations": {}, "props": {}}

    # Discover assets from the canonical filesystem when the bible is
    # empty (e.g. a fresh test fixture). The HTTP handler keeps the
    # bible-driven path because production projects always have a bible
    # and need its display_name/visual_description metadata.
    canonical_root = refs_root / "_canonical"
    discovered: dict[str, set[str]] = {
        "characters": set(),
        "locations": set(),
        "props": set(),
    }
    if canonical_root.exists():
        for atype in ("characters", "locations", "props"):
            type_dir = canonical_root / atype
            if type_dir.exists():
                for child in type_dir.iterdir():
                    if child.is_dir():
                        discovered[atype].add(child.name)

    # Walk the bible — it's the source of truth for what assets exist.
    # Ref enumeration is delegated to ref_resolver so this view matches
    # the pipeline's view exactly.
    for asset_type, resolver_fn in (
        ("characters", ref_resolver.resolve_character_refs),
        ("locations", ref_resolver.resolve_location_refs),
        ("props", ref_resolver.resolve_prop_refs),
    ):
        bible_section = bible.get(asset_type, {})
        # Union of bible-declared assets and canonical-discovered assets.
        # Bible takes precedence for metadata; canonical-only assets get
        # an empty entry shell.
        # M-D: dedupe bible-internal case collisions first (e.g. both "SADIE"
        # and "sadie" keys in the same section). First-seen casing wins.
        raw_bible_ids = list(bible_section.keys())
        seen_upper: dict[str, str] = {}
        deduped_bible_ids: list[str] = []
        for bid in raw_bible_ids:
            up = bid.upper()
            if up not in seen_upper:
                seen_upper[up] = bid
                deduped_bible_ids.append(bid)
        bible_ids = set(deduped_bible_ids)
        # Dedupe discovered IDs that collide with bible IDs case-insensitively.
        # Bible casing wins (e.g. "SADIE"); disk-only slugs still appear.
        bible_upper_map = seen_upper
        deduped_discovered = {
            did
            for did in discovered.get(asset_type, set())
            if did.upper() not in bible_upper_map
        }
        all_ids = bible_ids | deduped_discovered
        for asset_id in all_ids:
            asset_data = bible_section.get(asset_id, {})
            entry = dict(asset_data) if isinstance(asset_data, dict) else {}
            entry["id"] = asset_id
            entry["refs"] = []

            slug = slugify_asset_id(asset_id)
            refs_map = resolver_fn(refs_root, asset_id) or {}

            # Build a unique list of (key, path) pairs — hero first so the
            # UI can flag it as the lead ref.
            seen_paths: set[Path] = set()
            ordered: list[tuple[str, Path]] = []
            hero_path = refs_map.get("hero")
            if isinstance(hero_path, Path):
                ordered.append(("hero", hero_path))
                seen_paths.add(hero_path)
            for key, val in refs_map.items():
                if key == "hero":
                    continue
                if isinstance(val, Path):
                    if val not in seen_paths:
                        ordered.append((key, val))
                        seen_paths.add(val)
                elif isinstance(val, list):
                    for v in val:
                        if isinstance(v, Path) and v not in seen_paths:
                            ordered.append((key, v))
                            seen_paths.add(v)

            for key, img_path in ordered:
                # Derive a serving path relative to output/ — we know the
                # resolver never returns paths outside refs_root.
                try:
                    rel_from_refs = img_path.relative_to(refs_root)
                    serving_path = f"/refs/{rel_from_refs.as_posix()}"
                except ValueError:
                    serving_path = str(img_path)
                entry["refs"].append(
                    {
                        "filename": img_path.name,
                        "path": serving_path,
                        "hero": (key == "hero"),
                        "key": key,
                    }
                )

            result[asset_type][asset_id] = entry

    return result


def to_serving_path(abs_path: Path, pp: dict) -> str:
    """Convert an absolute path to a frontend-friendly 'output/...' relative path.

    Strictly enforces project output boundary. Allows shared expression library.
    Raises ValueError if path is outside project output and not a shared asset.
    """
    abs_str = str(abs_path)
    proj_out = str(pp["output_dir"])
    if abs_str.startswith(proj_out):
        return "output/" + abs_str[len(proj_out) :].lstrip("/")
    # Allow universal shared assets (expression library)
    expressions_dir = str(PROJECT_ROOT / "assets" / "expressions")
    if abs_str.startswith(expressions_dir):
        return "assets/expressions/" + abs_str[len(expressions_dir) :].lstrip("/")
    raise ValueError(
        f"Path {abs_str} outside project output ({proj_out}) and not a shared asset"
    )


# ── Per-project store cache ────────────────────────────────────────
_stores: dict = {}
_stores_lock = threading.Lock()


def _get_store(project: str = None) -> "ExecutionStore | None":
    """Get or create an ExecutionStore for the given project.

    Caches stores per project name to avoid re-opening connections.
    Thread-safe via _stores_lock.

    Project is REQUIRED — every API call must pass the active project
    from the ?project= query parameter. DEFAULT_PROJECT is only used
    as a last resort for backwards compatibility with old callers.
    """
    if not project:
        if DEFAULT_PROJECT:
            print(
                f"  [WARN] _get_store() called without project, falling back to DEFAULT_PROJECT={DEFAULT_PROJECT}"
            )
            import traceback

            traceback.print_stack(limit=4)
        else:
            print(
                "  [ERROR] _get_store() called without project and no DEFAULT_PROJECT set"
            )
            return None
    proj = (project or DEFAULT_PROJECT).lower()
    if proj in _stores:
        return _stores[proj]
    with _stores_lock:
        # Double-check after acquiring lock
        if proj not in _stores:
            try:
                from recoil.execution.execution_store import ExecutionStore

                _stores[proj] = ExecutionStore(project=proj)
            except Exception as e:
                print(f"  [WARN] Could not initialize ExecutionStore for {proj}: {e}")
                return None
        return _stores[proj]


def _get_runner(project: str, episode: int) -> "StepRunner":
    """Get or create a StepRunner for the given project+episode."""
    from recoil.execution.step_runner import StepRunner
    from recoil.execution.step_types import ProjectPaths

    store = _get_store(project)
    paths = ProjectPaths.for_episode(project, episode)
    return StepRunner(store=store, paths=paths)


# ── Generation tracker + thread pool (module-level singletons) ────
class GenerationTracker:
    """Prevents duplicate generation submissions. Fast UX-layer rejection.

    The ExecutionStore enforces correctness (InvalidTransitionError on races).
    This tracker provides fast 409 responses without hitting the store.
    Locks auto-expire after TIMEOUT_SECS to prevent stuck state from crashed threads.
    """

    TIMEOUT_SECS = 300  # 5 minutes

    def __init__(self):
        self._active: dict[str, float] = {}  # shot_id -> start_time
        self._lock = threading.Lock()

    def try_start(self, shot_id: str) -> bool:
        """Atomically try to claim a shot for generation. Returns False if already active."""
        import time

        now = time.time()
        with self._lock:
            # Auto-expire stale locks
            if shot_id in self._active:
                if now - self._active[shot_id] > self.TIMEOUT_SECS:
                    pass  # Expired — allow re-claim
                else:
                    return False
            self._active[shot_id] = now
            return True

    def finish(self, shot_id: str) -> None:
        """Release a shot after generation completes (success or failure)."""
        with self._lock:
            self._active.pop(shot_id, None)

    def is_active(self, shot_id: str) -> bool:
        """Check if a shot is currently being generated."""
        import time

        with self._lock:
            if shot_id not in self._active:
                return False
            if time.time() - self._active[shot_id] > self.TIMEOUT_SECS:
                self._active.pop(shot_id, None)
                return False
            return True


_gen_tracker = GenerationTracker()
_thread_pool = ThreadPoolExecutor(max_workers=4)

# ── Task Registry (ephemeral in-memory task tracking) ─────────────
_task_registry = {}  # task_id -> { task_id, entity_id, action, status, started, result, error }
_task_lock = threading.Lock()


def _submit_task(entity_id, action, fn, *args, **kwargs):
    """Submit a callable to the thread pool with task tracking."""
    import uuid as _uuid

    task_id = _uuid.uuid4().hex[:8]
    with _task_lock:
        _task_registry[task_id] = {
            "task_id": task_id,
            "entity_id": entity_id,
            "action": action,
            "status": "running",
            "started": time.time(),
            "result": None,
            "error": None,
        }

    def _wrapper():
        try:
            result = fn(*args, **kwargs)
            with _task_lock:
                _task_registry[task_id]["status"] = "complete"
                _task_registry[task_id]["result"] = result
        except Exception as e:
            import traceback

            traceback.print_exc()
            with _task_lock:
                _task_registry[task_id]["status"] = "failed"
                _task_registry[task_id]["error"] = str(e)

    _thread_pool.submit(_wrapper)
    return task_id


def _prune_task_registry():
    """Remove tasks older than 10 minutes, keeping at most 50."""
    cutoff = time.time() - 600
    with _task_lock:
        expired = [
            tid
            for tid, t in _task_registry.items()
            if t["status"] in ("complete", "failed") and t["started"] < cutoff
        ]
        for tid in expired:
            del _task_registry[tid]
        if len(_task_registry) > 50:
            sorted_tasks = sorted(_task_registry.items(), key=lambda x: x[1]["started"])
            for tid, _ in sorted_tasks[: len(_task_registry) - 50]:
                if _task_registry[tid]["status"] in ("complete", "failed"):
                    del _task_registry[tid]


IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
VIDEO_EXTS = {".mp4", ".webm", ".mov"}
MEDIA_EXTS = IMAGE_EXTS | VIDEO_EXTS
MIME_TYPES = {
    ".html": "text/html; charset=utf-8",
    ".css": "text/css; charset=utf-8",
    ".js": "application/javascript; charset=utf-8",
    ".json": "application/json; charset=utf-8",
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".webp": "image/webp",
    ".gif": "image/gif",
    ".svg": "image/svg+xml",
    ".ico": "image/x-icon",
    ".mp4": "video/mp4",
    ".webm": "video/webm",
    ".mov": "video/quicktime",
}


def get_episode_dirs(frames_dir=None):
    """Scan output/frames/ for ep_* directories, return sorted list."""
    fdir = frames_dir
    if not fdir or not fdir.is_dir():
        return []
    dirs = []
    for d in sorted(fdir.iterdir()):
        if d.is_dir() and d.name.startswith("ep_"):
            dirs.append(d.name)
    return dirs


def get_episode_number(ep_dir_name):
    """Extract episode number from directory name like 'ep_001'."""
    try:
        return int(ep_dir_name.replace("ep_", ""))
    except ValueError:
        return 0


def scan_frames(ep_dir, frames_dir=None, output_dir=None):
    """Recursively scan an episode directory for image files.

    Returns list of dicts with relative paths (relative to output/).
    """
    fdir = frames_dir
    odir = output_dir
    ep_path = fdir / ep_dir
    if not ep_path.is_dir():
        return []

    frames = []
    for root, _dirs, files in os.walk(ep_path):
        for f in sorted(files):
            fpath = Path(root) / f
            if fpath.suffix.lower() in MEDIA_EXTS:
                # Path relative to output/ for serving
                rel = fpath.relative_to(odir)
                # Determine subdirectory type (grids, panels, scene_refs, or root)
                rel_to_ep = fpath.relative_to(ep_path)
                subdir = (
                    str(rel_to_ep.parent) if str(rel_to_ep.parent) != "." else "root"
                )
                media_type = "video" if fpath.suffix.lower() in VIDEO_EXTS else "image"
                frames.append(
                    {
                        "filename": f,
                        "path": f"output/{rel}",
                        "subdir": subdir,
                        "shot_name": fpath.stem,
                        "size_bytes": fpath.stat().st_size,
                        "media_type": media_type,
                    }
                )
    return frames


def load_shot_data(ep_dir, frames_dir=None, plans_dir=None, project=None):
    """Load shot data for an episode (generation log or plan).

    Checks two locations in order:
    1. Generation log: output/frames/ep_NNN/log.json (take tracking, costs)
    2. Plan: state/visual/plans/ep_NNN_plan.json
       (shot routing, prompts, 5 consumer groups — from render extraction pipeline)

    Returns whichever exists first, or None.
    """
    # Auto-resolve paths from project (explicit > DEFAULT_PROJECT)
    if frames_dir is None or plans_dir is None:
        pp = _paths_for_project(project or DEFAULT_PROJECT)
        if frames_dir is None:
            frames_dir = pp["frames_dir"]
        if plans_dir is None:
            plans_dir = pp["plans_dir"]
    fdir = frames_dir
    pdir = plans_dir

    # 1. Generation log (post-generation tracking)
    if fdir:
        gen_path = fdir / ep_dir / "log.json"
        if gen_path.exists():
            try:
                with open(gen_path) as f:
                    return json.load(f)
            except (json.JSONDecodeError, IOError):
                pass

    # 2. Plan (pre-generation — from ingest pipeline Stage 2)
    # ep_dir may be "001" or "ep_001" depending on caller
    if pdir:
        plan_name = ep_dir if ep_dir.startswith("ep_") else f"ep_{ep_dir}"
        plan_path = pdir / f"{plan_name}_plan.json"
        if plan_path.exists():
            try:
                with open(plan_path) as f:
                    return json.load(f)
            except (json.JSONDecodeError, IOError):
                pass

    return None


def save_log(ep_dir, log_data, frames_dir=None, project=None):
    """Write generation log.json for an episode."""
    if frames_dir is None:
        frames_dir = _paths_for_project(project or DEFAULT_PROJECT)["frames_dir"]
    fdir = frames_dir
    log_path = fdir / ep_dir / "log.json"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with open(log_path, "w") as f:
        json.dump(log_data, f, indent=2)


def load_cost_log(ep_dir, frames_dir=None, project=None):
    """Load cost_log.json for an episode, return dict or None."""
    if frames_dir is None:
        frames_dir = _paths_for_project(project or DEFAULT_PROJECT)["frames_dir"]
    fdir = frames_dir
    cost_path = fdir / ep_dir / "cost_log.json"
    if not cost_path.exists():
        return None
    try:
        with open(cost_path) as f:
            return json.load(f)
    except (json.JSONDecodeError, IOError):
        return None


# Phase 2: shared fs_watcher + SSE for live console updates
from recoil.pipeline._lib.fs_watcher import FsWatcher, EventBroker
from recoil.pipeline._lib.fs_watcher.transports.sse import sse_stream

_broker = EventBroker()
_watcher = FsWatcher(roots=[projects_root()], broker=_broker)
_watcher_started = False
_watcher_start_lock = threading.Lock()


def _ensure_watcher_started():
    """Start the FsWatcher lazily on first access.

    Double-checked locking: the outer check is a fast path when the watcher
    is already started. On first call, concurrent handler threads serialize
    on _watcher_start_lock so only one calls _watcher.start(). The inner
    check under the lock prevents a second start after the first one sets
    the flag.
    """
    global _watcher_started
    if _watcher_started:
        return
    with _watcher_start_lock:
        if not _watcher_started:
            _watcher.start()
            _watcher_started = True


class ReviewHandler(http.server.BaseHTTPRequestHandler):
    """HTTP request handler for the Starsend Review UI."""

    timeout = 300  # 5 min socket timeout — enrichment calls can take 30+ seconds

    def log_message(self, format, *args):
        print(f"  {args[0]}")

    def _cors_headers(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS")
        self.send_header(
            "Access-Control-Allow-Headers", "Content-Type, X-File-Name, X-Location-Id"
        )

    def _json_response(self, data, status=200):
        body = json.dumps(data, indent=2).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self._cors_headers()
        self.send_header("Content-Length", len(body))
        self.end_headers()
        self.wfile.write(body)

    def _handle_ai_rewrite(self, body):
        """Call Gemini 2.5 Pro to generate 2 rewrite proposals for selected text."""
        import urllib.request

        api_key = os.environ.get("GOOGLE_API_KEY")
        if not api_key:
            self._json_response({"error": "GOOGLE_API_KEY not set"}, 500)
            return

        selected_text = body.get("selected_text", "")
        line_type = body.get("line_type", "action")
        character = body.get("character")
        scene_context = body.get("scene_context", "")
        project = body.get("project", "")

        system_prompt = (
            f"You are an expert script doctor for the vertical microdrama '{project}'.\n"
            f"Provide EXACTLY TWO rewrite proposals for the selected {line_type} text.\n"
            f"Rules:\n"
            f"1. Preserve the line type. If the original is DIALOGUE, write DIALOGUE. If ACTION, write ACTION.\n"
            f"2. Proposal 1: subtle polish (tighter, more natural).\n"
            f"3. Proposal 2: stronger swing (more subtext, punchier, more stylized).\n"
            f"4. Keep word counts similar to the original. Vertical microdramas require fast pacing.\n"
            f'5. Output ONLY valid JSON: {{"proposals": ["rewrite 1", "rewrite 2"]}}\n'
        )

        user_content = json.dumps(
            {
                "scene_context": scene_context[:2000],
                "selected_text": selected_text,
                "line_type": line_type,
                "character": character,
            }
        )

        try:
            url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent?key={api_key}"
            req_data = json.dumps(
                {
                    "systemInstruction": {"parts": [{"text": system_prompt}]},
                    "contents": [{"role": "user", "parts": [{"text": user_content}]}],
                    "generationConfig": {
                        "maxOutputTokens": 300,
                        "responseMimeType": "application/json",
                        "responseSchema": {
                            "type": "OBJECT",
                            "properties": {
                                "proposals": {
                                    "type": "ARRAY",
                                    "items": {"type": "STRING"},
                                }
                            },
                            "required": ["proposals"],
                        },
                    },
                }
            ).encode("utf-8")

            req = urllib.request.Request(
                url,
                headers={"content-type": "application/json"},
                data=req_data,
            )

            content_text = ""
            with urllib.request.urlopen(req, timeout=30) as resp:
                result = json.loads(resp.read().decode("utf-8"))
                content_text = result["candidates"][0]["content"]["parts"][0]["text"]
                parsed = json.loads(content_text)
                self._json_response(parsed)
        except json.JSONDecodeError:
            try:
                import re

                m = re.search(r'\{[^}]*"proposals"[^}]*\}', content_text)
                if m:
                    self._json_response(json.loads(m.group()))
                else:
                    self._json_response(
                        {
                            "error": "AI returned invalid JSON",
                            "raw": content_text[:500],
                        },
                        500,
                    )
            except Exception:
                self._json_response({"error": "AI returned invalid JSON"}, 500)
        except Exception as e:
            self._json_response({"error": str(e)}, 500)

    def _serve_file(self, filepath, content_type=None):
        """Serve a file from disk."""
        # Path traversal guard
        try:
            filepath = Path(filepath).resolve()
            # Verify resolved path is within allowed directories
            allowed_roots = [
                EDITORS_DIR.resolve(),
                projects_root().resolve(),
                PROJECT_ROOT.resolve(),
            ]
            if not any(filepath.is_relative_to(root) for root in allowed_roots):
                self.send_error(403, "Forbidden")
                return
        except (ValueError, OSError):
            self.send_error(400, "Bad path")
            return
        if not filepath.exists() or not filepath.is_file():
            self.send_error(404, f"Not found: {filepath.name}")
            return
        if content_type is None:
            content_type = MIME_TYPES.get(
                filepath.suffix.lower(), "application/octet-stream"
            )
        content = filepath.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", content_type)
        self._cors_headers()
        self.send_header("Content-Length", len(content))
        # Cache images for 5 minutes, HTML/JS no-cache
        if filepath.suffix.lower() in MEDIA_EXTS:
            self.send_header("Cache-Control", "max-age=300")
        else:
            self.send_header("Cache-Control", "no-cache")
        self.end_headers()
        self.wfile.write(content)

    def do_OPTIONS(self):
        self.send_response(204)
        self._cors_headers()
        self.end_headers()

    def _read_body(self):
        """Read and parse JSON request body."""
        content_len = int(self.headers.get("Content-Length", 0))
        if content_len > 0:
            try:
                return json.loads(self.rfile.read(content_len))
            except json.JSONDecodeError:
                return None
        return {}

    def do_GET(self):
        parsed = urlparse(self.path)
        path = unquote(parsed.path)

        # Phase 2: Server-Sent Events stream for filesystem changes
        if parsed.path == "/api/events":
            last_event_id = self.headers.get("Last-Event-ID")
            self.send_response(200)
            self.send_header("Content-Type", "text/event-stream; charset=utf-8")
            self.send_header("Cache-Control", "no-cache")
            self.send_header("Connection", "keep-alive")
            self.send_header(
                "X-Accel-Buffering", "no"
            )  # disable nginx buffering if proxied
            self._cors_headers()
            self.end_headers()
            try:
                for frame in sse_stream(_broker, last_event_id):
                    try:
                        self.wfile.write(frame)
                        self.wfile.flush()
                    except (BrokenPipeError, ConnectionResetError):
                        return  # client disconnected cleanly
            except Exception:
                import traceback

                traceback.print_exc()
            return

        # ── Root redirect to console ─────────────────────────────────
        if path in ("/", "/index.html"):
            self.send_response(302)
            self.send_header("Location", "/console")
            self.end_headers()
            return

        # ── Production Console ───────────────────────────────────────
        if path in ("/console", "/console.html"):
            self._serve_file(EDITORS_DIR / "production-console.html")
            return

        # ── Legacy frame review (deprecated) ─────────────────────────
        if path in ("/review", "/review.html"):
            self._serve_file(EDITORS_DIR / "review.html")
            return

        # ── Manual Workbench ──────────────────────────────────────
        if path in ("/manual", "/manual/"):
            self._serve_file(EDITORS_DIR / "manual-workbench.html")
            return

        # ── Mobile Console (PWA) ───────────────────────────────────
        if path == "/m" or path == "/m/":
            self._serve_file(EDITORS_DIR / "mobile" / "mobile-console.html")
            return

        if path.startswith("/m/"):
            rel = path[len("/m/") :]
            fpath = EDITORS_DIR / "mobile" / rel
            if rel == "sw.js":
                # Service workers must never be cached
                if fpath.exists():
                    content = fpath.read_bytes()
                    self.send_response(200)
                    self.send_header("Content-Type", "application/javascript")
                    self._cors_headers()
                    self.send_header("Content-Length", len(content))
                    self.send_header(
                        "Cache-Control", "no-cache, no-store, must-revalidate"
                    )
                    self.send_header("Service-Worker-Allowed", "/")
                    self.end_headers()
                    self.wfile.write(content)
                else:
                    self.send_error(404, "sw.js not found")
                return
            self._serve_file(fpath)
            return

        # ── Client Review Routes ──────────────────────────────────────
        # GET /review/<token> — Serve client review SPA
        if path.startswith("/review/") and not path.startswith("/review/api/"):
            token = path[len("/review/") :]
            if not token or ".." in token:
                self._json_response({"error": "Invalid token"}, 400)
                return
            # Find project by review_token in project_config.json
            projects_root = _paths_for_project(DEFAULT_PROJECT)["project_dir"].parent
            found_project = None
            for pdir in projects_root.iterdir():
                if not pdir.is_dir():
                    continue
                cfg_path = pdir / "project_config.json"
                if cfg_path.exists():
                    try:
                        cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
                        if not isinstance(cfg, dict):
                            continue
                        if cfg.get("review_token") == token:
                            found_project = pdir.name
                            break
                    except (json.JSONDecodeError, OSError):
                        continue
            if not found_project:
                self._json_response({"error": "Invalid review link"}, 404)
                return
            # Serve the client review HTML
            html_path = Path(__file__).parent / "client_review.html"
            if html_path.exists():
                self.send_response(200)
                self.send_header("Content-Type", "text/html; charset=utf-8")
                self.end_headers()
                self.wfile.write(html_path.read_bytes())
                return
            self._json_response({"error": "Client review page not found"}, 404)
            return

        # ── Task Registry API ──────────────────────────────────────
        if path == "/api/tasks":
            _prune_task_registry()
            with _task_lock:
                tasks = dict(_task_registry)
            self._json_response({"tasks": tasks})
            return

        if path.startswith("/api/tasks/"):
            task_id = path.split("/api/tasks/")[1].strip("/")
            with _task_lock:
                task = _task_registry.get(task_id)
            if task:
                self._json_response(task)
            else:
                self._json_response({"error": f"Task not found: {task_id}"}, 404)
            return

        # ── API routes ──────────────────────────────────────────────
        # Extract ?project= from query string for all API routes
        qs = parse_qs(parsed.query)
        _qs_project = qs.get("project", [None])[0]

        def _proj():
            """Resolve project: query param > startup default."""
            global _PROJECT_OUTPUT
            p = _qs_project or DEFAULT_PROJECT
            if p:
                _PROJECT_OUTPUT = _paths_for_project(p)["output_dir"]
            return p

        def _ppaths():
            """Resolve project paths from query param."""
            return _paths_for_project(_proj())

        if path == "/api/health":
            self._json_response({"status": "ok", "ts": time.time()})
            return

        # GET /api/client/review/<token> — Client review data (sanitized)
        if path.startswith("/api/client/review/"):
            token = path[len("/api/client/review/") :]
            if not token or ".." in token:
                self._json_response({"error": "Invalid token"}, 400)
                return
            projects_root = _paths_for_project(DEFAULT_PROJECT)["project_dir"].parent
            found_project = None
            for pdir in projects_root.iterdir():
                if not pdir.is_dir():
                    continue
                cfg_path = pdir / "project_config.json"
                if cfg_path.exists():
                    try:
                        cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
                        if not isinstance(cfg, dict):
                            continue
                        if cfg.get("review_token") == token:
                            found_project = pdir.name
                            break
                    except (json.JSONDecodeError, OSError):
                        continue
            if not found_project:
                self._json_response({"error": "Invalid review link"}, 404)
                return

            store = _get_store(found_project)
            if store is None:
                self._json_response({"error": "Project data unavailable"}, 503)
                return

            ar = _get_project_aspect_ratio(found_project)
            css_ar = ar.replace(":", " / ") if ":" in ar else ar

            client_shots = []
            all_shots = store.get_all_shots() if hasattr(store, "get_all_shots") else []
            for shot in all_shots:
                if isinstance(shot, dict) and shot.get("status") in (
                    "client_review",
                    "video_complete",
                ):
                    gate = shot.get("gate_results") or {}
                    video_url = None
                    # Find the approved video
                    hero = gate.get("hero_frame") or shot.get("output_path")
                    if hero and str(hero).endswith((".mp4", ".webm")):
                        video_url = (
                            f"/output/{hero}?project={found_project}"
                            if not str(hero).startswith("/")
                            else f"{hero}?project={found_project}"
                        )
                    # Check takes for videos
                    if not video_url:
                        for take in reversed(shot.get("takes") or []):
                            tp = take.get("output_path", "")
                            if tp and tp.endswith((".mp4", ".webm")):
                                video_url = (
                                    f"/output/{tp}?project={found_project}"
                                    if not tp.startswith("/")
                                    else f"{tp}?project={found_project}"
                                )
                                break
                    if video_url:
                        client_shots.append(
                            {
                                "id": shot.get("shot_id", shot.get("id", "")),
                                "video_url": video_url,
                                "description": shot.get("description", ""),
                                "episode": shot.get("episode", ""),
                                "order": shot.get("order", 0),
                                "feedback": shot.get("client_feedback", None),
                            }
                        )

            client_shots.sort(
                key=lambda s: (s.get("episode") or "", s.get("order") or 0)
            )

            self._json_response(
                {
                    "project_name": found_project.replace("-", " ")
                    .replace("_", " ")
                    .title(),
                    "aspect_ratio": ar,
                    "aspect_ratio_css": css_ar,
                    "shots": client_shots,
                    "total": len(client_shots),
                }
            )
            return

        if path == "/api/projects":
            self._api_projects()
            return

        # GET /api/project-config — project config with aspect ratio
        if path == "/api/project-config":
            project = _proj()
            pp = _paths_for_project(project)
            config_path = pp["project_dir"] / "project_config.json"
            config = {}
            if config_path.exists():
                try:
                    config = json.loads(config_path.read_text(encoding="utf-8"))
                except (json.JSONDecodeError, OSError):
                    pass
            ar = str(config.get("aspect_ratio", "9:16"))
            # Convert "9:16" format to CSS-compatible "9 / 16" format
            css_ar = ar.replace(":", " / ") if ":" in ar else ar
            self._json_response(
                {
                    "aspect_ratio": ar,
                    "aspect_ratio_css": css_ar,
                    "project": project,
                }
            )
            return

        if path == "/api/config/model-capabilities":
            from recoil.core.paths import CONFIG_PATH

            config_path = CONFIG_PATH
            try:
                config = json.loads(config_path.read_text(encoding="utf-8"))
                self._json_response(config.get("model_capabilities", {}))
            except (json.JSONDecodeError, OSError) as exc:
                self._json_response({"error": f"Failed to load config: {exc}"}, 500)
            return

        # ── Coverage Passes ─────────────────────────────────────────────
        if path.startswith("/api/coverage-passes/"):
            ep_str = path.split("/api/coverage-passes/")[1].strip("/")
            self._api_get_coverage_passes(ep_str, _ppaths())
            return

        if path.startswith("/api/coverage-prompts/"):
            # GET /api/coverage-prompts/EP001_SH02 → returns enriched coverage prompts
            shot_id = path.split("/")[-1]
            self._api_coverage_prompts(shot_id, project=_proj())
            return

        if path == "/api/episodes":
            self._api_episodes(_proj())
            return

        if path.startswith("/api/manifest/"):
            ep_dir = path.split("/api/manifest/")[1].strip("/")
            self._api_plan(ep_dir, _ppaths())
            return

        if path == "/api/ref-image":
            # Serve a ref image thumbnail by relative path
            qs = parse_qs(urlparse(self.path).query)
            ref_path = qs.get("path", [""])[0]
            if not ref_path:
                self._json_response({"error": "Missing path param"}, 400)
                return
            proj = _proj()
            proj_root = projects_root() / proj
            # Resolve against project root, then starsend root
            for base in (proj_root, PROJECT_ROOT):
                candidate = base / ref_path
                if candidate.is_file() and candidate.suffix.lower() in (
                    ".png",
                    ".jpg",
                    ".jpeg",
                    ".webp",
                ):
                    mime = {
                        ".png": "image/png",
                        ".jpg": "image/jpeg",
                        ".jpeg": "image/jpeg",
                        ".webp": "image/webp",
                    }.get(candidate.suffix.lower(), "image/png")
                    try:
                        data = candidate.read_bytes()
                        self.send_response(200)
                        self.send_header("Content-Type", mime)
                        self.send_header("Content-Length", str(len(data)))
                        self.send_header("Cache-Control", "public, max-age=3600")
                        self.end_headers()
                        self.wfile.write(data)
                    except IOError:
                        self._json_response({"error": "Read failed"}, 500)
                    return
            self._json_response({"error": "Ref not found"}, 404)
            return

        if path.startswith("/api/frames/"):
            ep_dir = path.split("/api/frames/")[1].strip("/")
            self._api_frames(ep_dir, _ppaths())
            return

        if path.startswith("/api/plan/"):
            ep_dir = path.split("/api/plan/")[1].strip("/")
            self._api_plan(ep_dir, _ppaths())
            return

        if path.startswith("/api/cost/"):
            ep_dir = path.split("/api/cost/")[1].strip("/")
            self._api_cost(ep_dir, _ppaths())
            return

        # ── Previs API routes ────────────────────────────────────────
        if path.startswith("/api/previs/"):
            ep_dir = path.split("/api/previs/")[1].strip("/")
            self._api_previs(ep_dir, _ppaths())
            return

        # /api/execution/ — REMOVED: legacy JSON endpoint, replaced by SQLite-backed /api/board
        if path.startswith("/api/execution/"):
            self._json_response(
                {"error": "Deprecated. Use /api/board/{episode} instead."}, 410
            )
            return

        # ── Console API routes ──────────────────────────────────────
        if path == "/api/bible":
            self._api_bible(_ppaths())
            return

        if path == "/api/budget":
            self._api_budget(_proj())
            return

        if path == "/api/board":
            self._api_board(_proj(), _ppaths())
            return

        if path.startswith("/api/board/"):
            ep_id = path.split("/api/board/")[1].strip("/")
            self._api_board_episode(ep_id, _proj())
            return

        if path.startswith("/api/dailies/filmstrip/"):
            parts = path.split("/api/dailies/filmstrip/")[1].strip("/").split("/")
            if len(parts) == 2:
                self._api_dailies_filmstrip(parts[0], parts[1], project=_proj())
            else:
                self._json_response(
                    {"error": "Expected /api/dailies/filmstrip/{episode}/{shot_id}"},
                    400,
                )
            return

        if path == "/api/dailies":
            self._api_dailies(_proj(), _ppaths())
            return

        if path == "/api/dailies/videos":
            self._api_dailies_videos(_proj(), _ppaths())
            return

        if path == "/api/studio-budget":
            self._api_studio_budget()
            return

        # ── Assets API ────────────────────────────────────────────────
        if path == "/api/assets":
            self._api_assets_list(_proj())
            return

        # ── Manual Workbench GET ────────────────────────────────────
        if path.startswith("/api/manual/shots/"):
            ep_id = path.split("/api/manual/shots/")[1].strip("/")
            self._api_manual_shots(ep_id, project=_proj())
            return

        if path.startswith("/api/stale-check/"):
            ep_id = path.split("/api/stale-check/")[1].strip("/")
            self._api_stale_check(ep_id, project=_proj())
            return

        # ── Project-scoped content routes ─────────────────────────
        # GET /api/project/{name}/episodes/{ep}/content
        # GET /api/project/{name}/episodes/{ep}/annotations
        # GET /api/project/{name}/fountain/{filename}
        # GET /api/project/{name}/visual-bible
        # GET /api/project/{name}/corpus-summary
        if path.startswith("/api/project/"):
            parts = path[len("/api/project/") :].split("/", 4)
            project_name = parts[0]
            project_dir = projects_root() / project_name

            if not project_dir.is_dir():
                self._json_response(
                    {"error": f"Project not found: {project_name}"}, 404
                )
                return

            if len(parts) >= 4 and parts[1] == "episodes":
                ep_id = parts[2]  # e.g. "ep_001"
                action = parts[3] if len(parts) > 3 else ""

                if action == "content":
                    # Serve episode markdown content as JSON (apiFetch expects JSON)
                    ep_file = project_dir / "episodes" / f"{ep_id}.md"
                    if ep_file.is_file():
                        content = ep_file.read_text(encoding="utf-8")
                        self._json_response({"content": content})
                    else:
                        self._json_response(
                            {"error": f"Episode not found: {ep_id}"}, 404
                        )
                    return

                if action == "annotations":
                    # Serve annotations JSON sidecar
                    ann_file = project_dir / "episodes" / f"{ep_id}.annotations.json"
                    if ann_file.is_file():
                        self._serve_file(ann_file, "application/json")
                    else:
                        self._json_response({"annotations": [], "edits": []})
                    return

            if len(parts) >= 3 and parts[1] == "fountain":
                filename = parts[2]
                ft_path = project_dir / filename
                if ft_path.is_file() and ft_path.suffix == ".fountain":
                    content = ft_path.read_text(encoding="utf-8")
                    self._json_response({"content": content})
                else:
                    self._json_response(
                        {"error": f"Fountain file not found: {filename}"}, 404
                    )
                return

            if len(parts) >= 2 and parts[1] == "visual-bible":
                vb_path = project_dir / "visual_bible.md"
                if vb_path.is_file():
                    content = vb_path.read_text(encoding="utf-8")
                    self._json_response({"content": content})
                else:
                    self._json_response({"error": "No visual bible found"}, 404)
                return

            # ── Casting GET routes ────────────────────────────────
            if len(parts) >= 3 and parts[1] == "casting":
                casting_action = parts[2]

                if casting_action == "characters":
                    self._api_casting_characters(project_name, project_dir)
                    return

                if casting_action == "expressions":
                    char_id = parts[3] if len(parts) > 3 else None
                    self._api_casting_expressions(project_name, project_dir, char_id)
                    return

                if casting_action == "locations":
                    self._api_casting_locations(project_name, project_dir)
                    return

                # GET /api/project/{name}/casting/explorations/{char_id}
                if casting_action == "explorations" and len(parts) >= 4:
                    char_id = parts[3]
                    self._api_casting_explorations(project_name, project_dir, char_id)
                    return

                # GET /api/project/{name}/casting/grid-session/{id}
                if casting_action == "grid-session" and len(parts) >= 4:
                    session_id = parts[3]
                    self._api_grid_session_get(project_name, project_dir, session_id)
                    return

                # GET /api/project/{name}/casting/bin/{character_id}/{asset_type}
                if casting_action == "bin" and len(parts) >= 4:
                    bin_char = parts[3]
                    bin_asset = parts[4] if len(parts) > 4 else "wardrobe"
                    self._api_bin_get(project_name, project_dir, bin_char, bin_asset)
                    return

                # GET /api/project/{name}/casting/continuity-session/{id}
                if casting_action == "continuity-session" and len(parts) >= 4:
                    session_id = parts[3]
                    self._api_continuity_session_get(
                        project_name, project_dir, session_id
                    )
                    return

            # ── Screen Test GET routes ────────────────────────────
            if len(parts) >= 3 and parts[1] == "screen-test":
                character = parts[2]
                self._api_screen_test_get(project_name, project_dir, character)
                return

            self._json_response({"error": f"Unknown project route: {path}"}, 404)
            return

        # ── Review Queue frontend JS (served from Python string) ─────
        if path == "/editors/review-queue.js":
            payload = REVIEW_QUEUE_FRONTEND_JS.encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/javascript; charset=utf-8")
            self.send_header("Content-Length", str(len(payload)))
            self.send_header("Cache-Control", "no-cache")
            self.end_headers()
            self.wfile.write(payload)
            return

        # ── Static files from editors/ ──────────────────────────────
        if path.startswith("/editors/"):
            rel = path[len("/editors/") :]
            self._serve_file(EDITORS_DIR / rel)
            return

        # ── Ref images (asset references) ────────────────────────────
        if path.startswith("/refs/"):
            rel = path[len("/refs/") :]
            refs_root = _ppaths()["output_dir"] / "refs"
            proj_ref = (refs_root / rel).resolve()
            if not proj_ref.is_relative_to(refs_root.resolve()):
                self._json_response({"error": "Invalid path"}, 403)
                return
            self._serve_file(proj_ref)  # 404 if missing — no cross-project fallback
            return

        # ── Thumbnail serving ────────────────────────────────────────
        if path.startswith("/thumb/"):
            rel = path[len("/thumb/") :]
            pp = _paths_for_project(_qs_project or DEFAULT_PROJECT)
            # Resolve source image within project output
            source_path = (pp["output_dir"] / rel).resolve()
            if not source_path.is_relative_to(pp["output_dir"].resolve()):
                self._json_response({"error": "Invalid path"}, 403)
                return
            if not source_path.is_file():
                self.send_error(404, f"Source not found: {rel}")
                return

            # Check for cached thumbnail in _thumbs/ sibling dir
            thumbs_dir = source_path.parent / "_thumbs"
            thumb_name = f"{source_path.stem}_thumb.jpg"
            thumb_path = thumbs_dir / thumb_name

            needs_generate = True
            if thumb_path.is_file():
                try:
                    if thumb_path.stat().st_mtime >= source_path.stat().st_mtime:
                        needs_generate = False
                except OSError:
                    pass

            if needs_generate:
                try:
                    from recoil.pipeline._lib.ref_image_ops import generate_thumbnail

                    generate_thumbnail(source_path, thumb_dir=thumbs_dir)
                except Exception as e:
                    self._json_response(
                        {"error": f"Thumbnail generation failed: {e}"}, 500
                    )
                    return

            self._serve_file(thumb_path)
            return

        # ── Asset files (assets/<class>/<subject>/...) ────────────────
        if path.startswith("/assets/"):
            rel = path[len("/assets/") :]
            pp = _paths_for_project(_qs_project or DEFAULT_PROJECT)
            assets_base = (pp["project_dir"] / "assets").resolve()
            file_path = (assets_base / rel).resolve()
            if not str(file_path).startswith(str(assets_base) + "/"):
                self.send_error(403, "Forbidden")
                return
            self._serve_file(file_path)
            return

        # ── Image/output files ──────────────────────────────────────
        if path.startswith("/output/"):
            rel = path[len("/output/") :]
            pp = _paths_for_project(_qs_project or DEFAULT_PROJECT)
            output_base = (pp["output_dir"]).resolve()
            file_path = (output_base / rel).resolve()
            if not str(file_path).startswith(str(output_base) + "/"):
                self.send_error(403, "Forbidden")
                return
            self._serve_file(file_path)
            return

        # ── Pipeline Inspector ──────────────────────────────────────────
        if path in ("/inspector", "/inspector/"):
            dist_index = EDITORS_DIR / "inspector" / "dist" / "index.html"
            if dist_index.exists():
                self._serve_file(dist_index)
            else:
                self._json_response(
                    {
                        "error": "Inspector not built. Run: cd editors/inspector && npm run build"
                    },
                    404,
                )
            return

        if path.startswith("/inspector/assets/") or (
            path.startswith("/inspector/")
            and path.rsplit(".", 1)[-1] in ("js", "css", "svg", "png", "woff", "woff2")
        ):
            rel = path[len("/inspector/") :]
            self._serve_file(EDITORS_DIR / "inspector" / "dist" / rel)
            return

        if path.startswith("/api/inspector-data/"):
            ep_str = path.split("/api/inspector-data/")[1].strip("/")
            try:
                ep_num = int(ep_str)
            except (ValueError, TypeError):
                self._json_response({"error": f"Invalid episode: {ep_str}"}, 400)
                return
            if not INSPECTOR_AVAILABLE:
                self._json_response({"error": "Inspector API not available"}, 503)
                return
            try:
                pp = _ppaths()
                bible = {}
                if pp["bible_path"].exists():
                    try:
                        bible = json.loads(pp["bible_path"].read_text(encoding="utf-8"))
                    except (json.JSONDecodeError, OSError):
                        pass
                from recoil.core.paths import CONFIG_PATH

                config_path = CONFIG_PATH
                project_config = {}
                if config_path.exists():
                    try:
                        project_config = json.loads(
                            config_path.read_text(encoding="utf-8")
                        )
                    except (json.JSONDecodeError, OSError):
                        pass
                data = get_inspector_data(ep_num, pp, bible, project_config)
                self._json_response(data)
            except Exception as exc:
                self._json_response({"error": f"Inspector data error: {exc}"}, 500)
            return

        if path.startswith("/api/inspector-notes/"):
            ep_str = path.split("/api/inspector-notes/")[1].strip("/")
            if not INSPECTOR_AVAILABLE:
                self._json_response({"error": "Inspector API not available"}, 503)
                return
            try:
                pp = _ppaths()
                notes = get_inspector_notes(pp)
                self._json_response(notes)
            except Exception as exc:
                self._json_response({"error": f"Inspector notes error: {exc}"}, 500)
            return

        # ── Review Queue (Phase 3) ─────────────────────────────────
        if path == "/api/review-queue":
            rq_project = qs.get("project", [_qs_project or DEFAULT_PROJECT])[0]
            if not rq_project:
                self._json_response({"error": "project parameter required"}, 400)
                return
            try:
                from recoil.pipeline._lib import review_queue as rq

                pp = _paths_for_project(rq_project)
                queue_path = (
                    pp["project_dir"] / "state" / "visual" / "review_queue.jsonl"
                )
                entries = rq.list_pending(queue_path=queue_path)
                self._json_response({"entries": entries, "count": len(entries)})
            except Exception as exc:
                self._json_response({"error": f"Review queue error: {exc}"}, 500)
            return

        self._json_response({"error": f"GET {path} not found"}, 404)

    def do_PATCH(self):
        parsed = urlparse(self.path)
        path = unquote(parsed.path)
        qs = parse_qs(parsed.query)
        _qs_project = qs.get("project", [None])[0]
        pp = _paths_for_project(_qs_project or DEFAULT_PROJECT)

        body = self._read_body()
        if body is None:
            self._json_response({"error": "Invalid JSON"}, 400)
            return

        # PATCH /api/bible/character/{char_id}
        if path.startswith("/api/bible/character/"):
            char_id = path.split("/api/bible/character/")[1].strip("/")
            self._api_bible_character_patch(char_id, body, pp)
            return

        # PATCH /api/bible/aesthetic-directives
        if path == "/api/bible/aesthetic-directives":
            self._api_bible_aesthetic_directives_patch(body, pp)
            return

        self._json_response({"error": f"PATCH {path} not found"}, 404)

    def do_POST(self):
        parsed = urlparse(self.path)
        path = unquote(parsed.path)
        qs = parse_qs(parsed.query)
        _qs_project = qs.get("project", [None])[0]

        def _proj():
            global _PROJECT_OUTPUT
            p = _qs_project or DEFAULT_PROJECT
            if p:
                _PROJECT_OUTPUT = _paths_for_project(p)["output_dir"]
            return p

        def _ppaths():
            """Resolve project paths from query param."""
            return _paths_for_project(_proj())

        # Handle raw binary upload BEFORE JSON parsing
        if "/casting/upload-location-ref" in path:
            if path.startswith("/api/project/"):
                parts = path[len("/api/project/") :].split("/", 4)
                project_name = parts[0]
                project_dir = projects_root() / project_name
                self._api_upload_location_ref(project_name, project_dir)
                return

        if "/casting/upload-turnaround" in path:
            if path.startswith("/api/project/"):
                parts = path[len("/api/project/") :].split("/", 4)
                project_name = parts[0]
                project_dir = projects_root() / project_name
                self._api_upload_turnaround(project_name, project_dir)
                return

        # Parse body if present
        body = self._read_body()
        if body is None:
            self._json_response({"error": "Invalid JSON"}, 400)
            return

        # POST /api/reveal-in-finder — Open file's parent in Finder
        if path == "/api/reveal-in-finder":
            rel_path = body.get("path", "")
            if not rel_path:
                self._json_response({"error": "Missing path"}, 400)
                return
            pp = _paths_for_project(_proj())
            abs_path = pp["project_dir"] / rel_path
            if not abs_path.exists():
                self._json_response(
                    {"error": "File not found", "path": str(abs_path)}, 404
                )
                return
            import subprocess

            subprocess.Popen(["open", "-R", str(abs_path)])
            self._json_response({"ok": True, "revealed": str(abs_path)})
            return

        # POST /api/client/feedback/<shot_id> — Client submits feedback
        if path.startswith("/api/client/feedback/"):
            shot_id = path[len("/api/client/feedback/") :]
            if not shot_id:
                self._json_response({"error": "Missing shot_id"}, 400)
                return
            # Require token in body for auth
            token = body.get("token")
            if not token:
                self._json_response({"error": "Missing token"}, 401)
                return
            # Find project by token
            projects_root = _paths_for_project(DEFAULT_PROJECT)["project_dir"].parent
            found_project = None
            for pdir in projects_root.iterdir():
                if not pdir.is_dir():
                    continue
                cfg_path = pdir / "project_config.json"
                if cfg_path.exists():
                    try:
                        cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
                        if not isinstance(cfg, dict):
                            continue
                        if cfg.get("review_token") == token:
                            found_project = pdir.name
                            break
                    except (json.JSONDecodeError, OSError):
                        continue
            if not found_project:
                self._json_response({"error": "Invalid token"}, 403)
                return

            store = _get_store(found_project)
            if store is None:
                self._json_response({"error": "Project data unavailable"}, 503)
                return

            status = body.get("status")  # "approved" or "revision"
            notes = body.get("notes", "")
            if status not in ("approved", "revision"):
                self._json_response(
                    {"error": "status must be 'approved' or 'revision'"}, 400
                )
                return

            shot = store.get_shot(shot_id)
            if shot is None:
                self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
                return

            from datetime import datetime as _dt_cls

            store.update_shot(
                shot_id,
                client_feedback={
                    "status": status,
                    "notes": notes,
                    "submitted_at": _dt_cls.now().isoformat(),
                },
            )

            self._json_response({"ok": True, "shot_id": shot_id})
            return

        # ── POST /api/ai/rewrite ──────────────────────────────────────
        if path == "/api/ai/rewrite":
            self._handle_ai_rewrite(body)
            return

        # ── POST /api/project/{name}/episodes/{ep}/annotations ─────
        if path.startswith("/api/project/"):
            parts = path[len("/api/project/") :].split("/", 4)
            project_name = parts[0]
            project_dir = projects_root() / project_name

            if len(parts) >= 4 and parts[1] == "episodes" and parts[3] == "annotations":
                ep_id = parts[2]
                ann_file = project_dir / "episodes" / f"{ep_id}.annotations.json"
                ann_file.parent.mkdir(parents=True, exist_ok=True)
                with open(ann_file, "w", encoding="utf-8") as f:
                    json.dump(body, f, indent=2, ensure_ascii=False)
                self._json_response(
                    {
                        "status": "saved",
                        "path": str(ann_file.relative_to(projects_root())),
                    }
                )
                return

            if len(parts) >= 4 and parts[1] == "episodes" and parts[3] == "content":
                ep_id = parts[2]
                ep_file = project_dir / "episodes" / f"{ep_id}.md"
                ep_file.parent.mkdir(parents=True, exist_ok=True)
                content = body.get("content", "")
                ep_file.write_text(content, encoding="utf-8")
                self._json_response(
                    {
                        "status": "saved",
                        "path": str(ep_file.relative_to(projects_root())),
                    }
                )
                return

            # ── Casting POST routes ───────────────────────────────
            if len(parts) >= 3 and parts[1] == "casting":
                casting_action = parts[2]

                if casting_action == "generate-grid":
                    self._api_casting_generate_grid(project_name, project_dir, body)
                    return

                if casting_action == "select-hero":
                    self._api_casting_select_hero(project_name, project_dir, body)
                    return

                if casting_action == "generate-turnaround":
                    self._api_casting_generate_turnaround(
                        project_name, project_dir, body
                    )
                    return

                if casting_action == "assign-turnaround":
                    self._api_assign_turnaround(project_name, project_dir, body)
                    return

                if casting_action == "approve-ref":
                    self._api_casting_approve_ref(project_name, project_dir, body)
                    return

                if casting_action == "generate-expressions":
                    self._api_casting_generate_expressions(
                        project_name, project_dir, body
                    )
                    return

                if casting_action == "generate-location":
                    self._api_casting_generate_location(project_name, project_dir, body)
                    return

                if casting_action == "select-location-hero":
                    self._api_casting_select_location_hero(
                        project_name, project_dir, body
                    )
                    return

                if casting_action == "bible-synced":
                    self._api_casting_bible_synced(project_name, project_dir, body)
                    return

                if casting_action == "update-location-moodboard":
                    self._api_update_location_moodboard(project_name, project_dir, body)
                    return

                if casting_action == "delete-location-ref":
                    self._api_delete_location_ref(project_name, project_dir, body)
                    return

                if casting_action == "promote-grid":
                    self._api_casting_promote_grid(project_name, project_dir, body)
                    return

                # POST /api/project/{name}/casting/grid-session (create)
                # POST /api/project/{name}/casting/grid-session/{id}/action
                # POST /api/project/{name}/casting/grid-session/{id}/reroll
                # POST /api/project/{name}/casting/grid-session/{id}/lock-hero
                if casting_action == "grid-session":
                    if len(parts) >= 4:
                        session_id = parts[3]
                        sub_action = parts[4] if len(parts) > 4 else None

                        if sub_action == "action":
                            self._api_grid_session_action(
                                project_name, project_dir, session_id, body
                            )
                        elif sub_action == "reroll":
                            self._api_grid_session_reroll(
                                project_name, project_dir, session_id, body
                            )
                        elif sub_action == "lock-hero":
                            self._api_grid_session_lock_hero(
                                project_name, project_dir, session_id, body
                            )
                        elif sub_action == "beauty-pass":
                            self._api_grid_session_beauty_pass(
                                project_name, project_dir, session_id, body
                            )
                        elif sub_action == "unlock":
                            self._api_grid_session_unlock(
                                project_name, project_dir, session_id
                            )
                        elif sub_action == "update-overrides":
                            self._api_grid_session_update_overrides(
                                project_name, project_dir, session_id, body
                            )
                        else:
                            self._json_response(
                                {"error": f"Unknown grid-session action: {sub_action}"},
                                404,
                            )
                    else:
                        # No session_id — create new session
                        self._api_grid_session_create(project_name, project_dir, body)
                    return

                # POST /api/project/{name}/casting/generate-phases
                if casting_action == "generate-phases":
                    self._api_generate_phases(project_name, project_dir, body)
                    return

                # POST /api/project/{name}/casting/reroll-grid
                if casting_action == "reroll-grid":
                    self._api_reroll_grid(project_name, project_dir, body)
                    return

                # POST /api/project/{name}/casting/reroll-phase
                if casting_action == "reroll-phase":
                    self._api_reroll_phase(project_name, project_dir, body)
                    return

                # POST /api/project/{name}/casting/bin-assign
                if casting_action == "bin-assign":
                    self._api_bin_assign(project_name, project_dir, body)
                    return

            # ── Wardrobe Intent Gate POST routes ─────────────────
            if len(parts) >= 3 and parts[1] == "wardrobe-intent":
                wi_action = parts[2]
                try:
                    if wi_action == "propose-philosophy":
                        self._api_wi_propose_philosophy(project_name, project_dir, body)
                        return
                    if wi_action == "approve-philosophy":
                        self._api_wi_approve_philosophy(project_name, project_dir, body)
                        return
                    if wi_action == "propose-theses":
                        self._api_wi_propose_theses(project_name, project_dir, body)
                        return
                    if wi_action == "approve-thesis":
                        self._api_wi_approve_thesis(project_name, project_dir, body)
                        return
                    if wi_action == "rewrite-phases":
                        self._api_wi_rewrite_phases(project_name, project_dir, body)
                        return
                    if wi_action == "apply-rewrite":
                        self._api_wi_apply_rewrite(project_name, project_dir, body)
                        return
                except Exception as exc:
                    import traceback

                    traceback.print_exc()
                    self._json_response({"error": f"Server error: {exc}"}, 500)
                    return

            # ── Bible Sync POST routes ─────────────────────────
            if len(parts) >= 3 and parts[1] == "bible":
                if parts[2] == "propose-visual-sync":
                    self._api_propose_visual_sync(project_name, project_dir, body)
                    return

            # ── Screen Test POST routes ──────────────────────────
            if len(parts) >= 3 and parts[1] == "screen-test":
                character = parts[2]

                # POST /api/project/{name}/screen-test/{character}/{phase}/reroll
                if len(parts) >= 5 and parts[4] == "reroll":
                    phase_id = parts[3]
                    self._api_screen_test_reroll(
                        project_name, project_dir, character, phase_id, body
                    )
                    return

                # POST /api/project/{name}/screen-test/{character}/{phase}/verdict
                if len(parts) >= 5 and parts[4] == "verdict":
                    phase_id = parts[3]
                    self._api_screen_test_verdict(
                        project_name, project_dir, character, phase_id, body
                    )
                    return

                # POST /api/project/{name}/screen-test/{character}/{phase}/bible-synced
                if len(parts) >= 5 and parts[4] == "bible-synced":
                    phase_id = parts[3]
                    self._api_screen_test_bible_synced(
                        project_name, project_dir, character, phase_id
                    )
                    return

                # POST /api/project/{name}/screen-test/{character}/set-anchor
                if len(parts) >= 4 and parts[3] == "set-anchor":
                    self._api_screen_test_set_anchor(
                        project_name, project_dir, character, body
                    )
                    return

                # POST /api/project/{name}/screen-test/{character} (generate all)
                self._api_screen_test_generate(
                    project_name, project_dir, character, body
                )
                return

            self._json_response({"error": f"Unknown POST route: {path}"}, 404)
            return

        # ── POST /api/accept/{episode}/{shot_id} ────────────────────
        if path.startswith("/api/accept/"):
            parts = path.split("/api/accept/")[1].strip("/").split("/")
            if len(parts) != 2:
                self._json_response(
                    {"error": "Expected /api/accept/{episode}/{shot_id}"}, 400
                )
                return
            self._api_set_status(parts[0], parts[1], "accepted", project=_proj())
            return

        # ── POST /api/reject/{episode}/{shot_id} ────────────────────
        if path.startswith("/api/reject/"):
            parts = path.split("/api/reject/")[1].strip("/").split("/")
            if len(parts) != 2:
                self._json_response(
                    {"error": "Expected /api/reject/{episode}/{shot_id}"}, 400
                )
                return
            self._api_set_status(parts[0], parts[1], "rejected", project=_proj())
            return

        # ── POST /api/promote/{episode}/{shot_id} ───────────────────
        if path.startswith("/api/promote/"):
            parts = path.split("/api/promote/")[1].strip("/").split("/")
            if len(parts) != 2:
                self._json_response(
                    {"error": "Expected /api/promote/{episode}/{shot_id}"}, 400
                )
                return
            self._api_promote(parts[0], parts[1], project=_proj())
            return

        # ── POST /api/approve-previs ─────────────────────────────────
        if path == "/api/approve-previs":
            self._api_approve_previs(body, project=_proj())
            return

        # ── Console POST endpoints ──────────────────────────────────
        if path == "/api/launch-batch":
            self._api_launch_batch(body, project=_proj())
            return

        if path == "/api/launch-batch/confirm":
            self._api_launch_batch_confirm(body, project=_proj())
            return

        if path == "/api/dailies/approve":
            self._api_dailies_approve(body, project=_proj())
            return

        if path == "/api/dailies/reject":
            self._api_dailies_reject(body, project=_proj())
            return

        if path == "/api/dailies/override":
            self._api_dailies_override(body, project=_proj())
            return

        if path == "/api/dailies/reroute":
            self._api_dailies_reroute(body, project=_proj())
            return

        if path == "/api/dailies/abandon":
            self._api_dailies_abandon(body, project=_proj())
            return

        if path == "/api/dailies/select-take":
            self._api_dailies_select_take(body, project=_proj())
            return

        if path == "/api/set-previz-hero":
            self._api_set_previz_hero(body, project=_proj())
            return

        if path == "/api/generate-previz":
            self._api_generate_previz(body, project=_proj())
            return

        if path == "/api/compose-shot":
            self._api_compose_shot(body, project=_proj())
            return

        if path == "/api/commit-compose":
            self._api_commit_compose(body, project=_proj())
            return

        if path == "/api/delete-shot":
            self._api_delete_shot(body, project=_proj())
            return

        # ── Keyframe pipeline (Layer 2 + Layer 3) ─────────────────────
        if path == "/api/smart-prompt":
            self._api_smart_prompt(body, project=_proj())
            return

        if path == "/api/generate-keyframe":
            self._api_generate_keyframe(body, project=_proj())
            return

        if path == "/api/lock-keyframe":
            self._api_lock_keyframe(body, project=_proj())
            return

        if path == "/api/extract-frame":
            self._api_extract_frame(body, project=_proj())
            return

        if path == "/api/coverage-options":
            self._api_coverage_options(body, project=_proj())
            return

        if path == "/api/add-coverage":
            self._api_add_coverage(body, project=_proj())
            return

        if path == "/api/generate-coverage":
            self._api_generate_coverage_multi(body, project=_proj())
            return

        if path == "/api/generate-sequence":
            self._api_generate_sequence(body, project=_proj())
            return

        if path == "/api/save-pass":
            self._api_save_pass(body, _ppaths())
            return

        if path == "/api/merge-passes":
            self._api_merge_passes(body, _ppaths())
            return

        if path == "/api/split-pass":
            self._api_split_pass(body, _ppaths())
            return

        if path == "/api/create-pass":
            self._api_create_pass(body, _ppaths())
            return

        if path == "/api/reset-pass-to-plan":
            self._api_reset_pass_to_plan(body, _ppaths())
            return

        if path == "/api/promote-coverage":
            self._api_promote_coverage(body, project=_proj())
            return

        if path == "/api/assign-video-frames":
            self._api_assign_video_frames(body, project=_proj())
            return

        if path == "/api/confirm-frame-pair":
            self._api_confirm_frame_pair(body, project=_proj())
            return

        if path == "/api/generate-video":
            self._api_generate_video(body, project=_proj())
            return

        if path == "/api/dailies/approve-video":
            self._api_dailies_approve_video(body, project=_proj())
            return

        if path == "/api/dailies/unapprove-video":
            self._api_dailies_unapprove_video(body, project=_proj())
            return

        if path == "/api/dailies/unlock":
            self._api_dailies_unlock(body, project=_proj())
            return

        if path == "/api/dailies/mark-seen":
            self._api_dailies_mark_seen(body, project=_proj())
            return

        if path == "/api/dailies/undo-reject":
            self._api_dailies_undo_reject(body, project=_proj())
            return

        if path == "/api/dailies/keep-take":
            self._api_dailies_take_action(body, "keep", project=_proj())
            return

        if path == "/api/dailies/reject-take":
            self._api_dailies_take_action(body, "reject", project=_proj())
            return

        if path == "/api/dailies/restore-take":
            self._api_dailies_take_action(body, "restore", project=_proj())
            return

        if path == "/api/dailies/unkept-take":
            self._api_dailies_take_action(body, "unkept", project=_proj())
            return

        if path == "/api/dailies/bin-clip":
            self._api_dailies_bin_clip(body, project=_proj())
            return

        if path == "/api/dailies/unbin-clip":
            self._api_dailies_unbin_clip(body, project=_proj())
            return

        # ── Batch override (multi-select) ────────────────────────────
        if path == "/api/dailies/batch-override":
            self._api_dailies_batch_override(body, project=_proj())
            return

        # ── Reveal in Finder ──────────────────────────────────────────
        if path == "/api/reveal-in-finder":
            self._api_reveal_in_finder(body)
            return

        # ── Manual Workbench endpoints ────────────────────────────────
        # /api/enhance-prompt RETIRED 2026-06-09 (enrichment superseded by prose_author)
        if path == "/api/manual/escalate":
            self._api_manual_escalate(body, project=_proj())
            return

        if path == "/api/manual/export":
            self._api_manual_export(body, project=_proj())
            return

        if path == "/api/manual/reimport":
            self._api_manual_reimport(body, project=_proj())
            return

        if path == "/api/manual/resolve":
            self._api_manual_resolve(body, project=_proj())
            return

        # ── Assets POST endpoints ────────────────────────────────────
        if path == "/api/asset/import":
            self._api_asset_import(body, project=_proj())
            return

        if path == "/api/asset/turnarounds":
            self._api_asset_turnarounds(body, project=_proj())
            return

        if path == "/api/asset/delete":
            self._api_asset_delete(body, project=_proj())
            return

        if path == "/api/asset/set-hero":
            self._api_asset_set_hero(body, project=_proj())
            return

        if path == "/api/asset/generate-views":
            self._api_asset_generate_views(body, project=_proj())
            return

        if path == "/api/compile-prompt-preview":
            self._api_compile_prompt_preview(body, project=_proj())
            return

        # ── Pipeline Inspector notes (POST) ──────────────────────────
        if path.startswith("/api/inspector-notes/"):
            ep_str = path.split("/api/inspector-notes/")[1].strip("/")
            if not INSPECTOR_AVAILABLE:
                self._json_response({"error": "Inspector API not available"}, 503)
                return
            try:
                pp = _ppaths()
                save_inspector_notes(pp, body)
                self._json_response({"status": "ok"})
            except Exception as exc:
                self._json_response(
                    {"error": f"Inspector notes save error: {exc}"}, 500
                )
            return

        # ── Review Queue resolve (Phase 3) ────────────────────────────
        if path == "/api/review-queue/resolve":
            rq_id = body.get("rq_id")
            resolution = body.get("resolution")
            if not rq_id or not resolution:
                self._json_response({"error": "rq_id and resolution required"}, 400)
                return
            if resolution not in ("approved", "rejected", "retry"):
                self._json_response(
                    {"error": "resolution must be approved/rejected/retry"}, 400
                )
                return
            rq_project = body.get("project") or _qs_project or DEFAULT_PROJECT
            if not rq_project:
                self._json_response({"error": "project required"}, 400)
                return
            try:
                from recoil.pipeline._lib import review_queue as rq

                pp = _paths_for_project(rq_project)
                queue_path = (
                    pp["project_dir"] / "state" / "visual" / "review_queue.jsonl"
                )
                result = rq.resolve(
                    queue_path=queue_path,
                    rq_id=rq_id,
                    resolution=resolution,
                    notes=body.get("notes", ""),
                )
                self._json_response({"ok": True, "resolved": result})
            except Exception as exc:
                self._json_response(
                    {"error": f"Review queue resolve error: {exc}"}, 500
                )
            return

        self._json_response({"error": f"POST {path} not found"}, 404)

    # ── API implementations ─────────────────────────────────────────

    def _api_projects(self):
        """List available projects (scans projects_root() from constants)."""
        projects = []
        if projects_root().exists():
            projects = sorted(
                [
                    d.name
                    for d in projects_root().iterdir()
                    if d.is_dir()
                    and not d.name.startswith((".", "_"))
                    and (
                        (d / "episodes").is_dir()
                        or (d / "treatment.md").is_file()
                        or list(d.glob("*.fountain"))
                    )
                ]
            )
        if not projects:
            projects = [DEFAULT_PROJECT]
        self._json_response({"projects": projects, "active": DEFAULT_PROJECT})

    def _api_episodes(self, project=None):
        """List available episodes — merges Starsend output with Recoil episode scripts."""
        project = project or DEFAULT_PROJECT
        import re

        # Resolve project-specific paths
        pp = _paths_for_project(project)

        # 1. Episodes from Starsend output (have plans/logs/frames)
        ep_dirs = get_episode_dirs(frames_dir=pp["frames_dir"])
        seen = set()
        episodes = []
        for ep_dir in ep_dirs:
            ep_num = get_episode_number(ep_dir)
            shot_data = load_shot_data(
                ep_dir, frames_dir=pp["frames_dir"], plans_dir=pp["plans_dir"]
            )
            cost_log = load_cost_log(ep_dir, frames_dir=pp["frames_dir"])
            frames = scan_frames(
                ep_dir, frames_dir=pp["frames_dir"], output_dir=pp["output_dir"]
            )

            info = {
                "dir": ep_dir,
                "number": ep_num,
                "frame_count": len(frames),
                "total_cost": shot_data.get("total_cost", 0) if shot_data else 0,
                "total_calls": shot_data.get("total_calls", 0) if shot_data else 0,
                "has_plan": shot_data is not None,
                "has_cost_log": cost_log is not None,
            }
            episodes.append(info)
            seen.add(ep_dir)

        # 2. Episodes from project scripts (may not have output yet)
        project_episodes_dir = projects_root() / project / "episodes"
        if project_episodes_dir.is_dir():
            for f in sorted(project_episodes_dir.glob("ep_*.md")):
                m = re.match(r"ep_(\d+)\.md$", f.name)
                if m:
                    ep_dir = f"ep_{m.group(1)}"
                    if ep_dir not in seen:
                        episodes.append(
                            {
                                "dir": ep_dir,
                                "number": int(m.group(1)),
                                "frame_count": 0,
                                "total_cost": 0,
                                "total_calls": 0,
                                "has_plan": False,
                                "has_cost_log": False,
                            }
                        )
                        seen.add(ep_dir)

        episodes.sort(key=lambda e: e["number"])
        self._json_response({"episodes": episodes})

    def _api_frames(self, ep_dir, pp=None):
        """List all generated frames for an episode."""
        fdir = pp["frames_dir"]
        odir = pp["output_dir"]
        pdir = pp["plans_dir"]
        if not (fdir / ep_dir).is_dir():
            self._json_response(
                {"error": f"Episode directory not found: {ep_dir}"}, 404
            )
            return

        frames = scan_frames(ep_dir, frames_dir=fdir, output_dir=odir)
        shot_data = load_shot_data(ep_dir, frames_dir=fdir, plans_dir=pdir)

        # Enrich frames with plan/log data (status, tier, etc.)
        records_by_shot = {}
        if shot_data and "records" in shot_data:
            for rec in shot_data["records"]:
                shot_name = rec.get("shot_name", "")
                if shot_name not in records_by_shot:
                    records_by_shot[shot_name] = []
                records_by_shot[shot_name].append(rec)

        # Check review_status from log
        review_status = {}
        if shot_data and "review_status" in shot_data:
            review_status = shot_data["review_status"]

        for frame in frames:
            shot_name = frame["shot_name"]
            # Strip common suffixes to match plan shot names
            base_name = shot_name
            for suffix in ("_grid", "_panel", "_final", "_pro", "_flash"):
                if base_name.endswith(suffix):
                    base_name = base_name[: len(base_name) - len(suffix)]
                    break

            # Add plan/log info
            if base_name in records_by_shot:
                recs = records_by_shot[base_name]
                frame["tier"] = recs[0].get("tier", "unknown")
                frame["pass_types"] = list({r.get("pass_type", "") for r in recs})
                frame["grid_type"] = next(
                    (r.get("grid_type") for r in recs if r.get("grid_type")), None
                )
                frame["model"] = recs[0].get("model", "")
            elif shot_name in records_by_shot:
                recs = records_by_shot[shot_name]
                frame["tier"] = recs[0].get("tier", "unknown")
                frame["pass_types"] = list({r.get("pass_type", "") for r in recs})
                frame["grid_type"] = next(
                    (r.get("grid_type") for r in recs if r.get("grid_type")), None
                )
                frame["model"] = recs[0].get("model", "")
            else:
                frame["tier"] = "unknown"
                frame["pass_types"] = []
                frame["grid_type"] = None
                frame["model"] = ""

            # Review status
            frame["status"] = review_status.get(shot_name, "pending")

        self._json_response({"episode": ep_dir, "frames": frames})

    def _api_plan(self, ep_dir, pp=None):
        """Return plan for an episode, merged with any generation log data."""
        fdir = pp["frames_dir"]
        pdir = pp["plans_dir"]

        # Always load plan first (has shots), then merge log data on top
        plan_name = ep_dir if ep_dir.startswith("ep_") else f"ep_{ep_dir}"
        plan_data = None
        if pdir:
            plan_path = pdir / f"{plan_name}_plan.json"
            if plan_path.exists():
                try:
                    with open(plan_path) as f:
                        plan_data = json.load(f)
                except (json.JSONDecodeError, IOError):
                    pass

        # Merge generation log (prompt overrides, etc.) on top of plan
        if fdir:
            log_path = fdir / ep_dir / "log.json"
            if log_path.exists():
                try:
                    with open(log_path) as f:
                        log_data = json.load(f)
                    if plan_data:
                        # Merge log keys into plan without clobbering shots
                        for k, v in log_data.items():
                            if k != "shots":
                                plan_data[k] = v
                    else:
                        plan_data = log_data
                except (json.JSONDecodeError, IOError):
                    pass

        if plan_data is None:
            self._json_response({"error": f"No plan or log found for {ep_dir}"}, 404)
            return
        self._json_response(plan_data)

    def _api_cost(self, ep_dir, pp=None):
        """Return cost_log.json for an episode."""
        fdir = pp["frames_dir"]
        cost_log = load_cost_log(ep_dir, frames_dir=fdir)
        if cost_log is None:
            self._json_response({"error": f"No cost_log.json for {ep_dir}"}, 404)
            return
        self._json_response(cost_log)

    def _api_set_status(self, ep_dir, shot_id, status, project=None):
        """Set review status for a shot (accepted/rejected)."""
        log = load_shot_data(ep_dir, project=project)
        if log is None:
            # Create a minimal log if none exists
            log = {"episode": get_episode_number(ep_dir), "review_status": {}}

        if "review_status" not in log:
            log["review_status"] = {}

        log["review_status"][shot_id] = status
        save_log(ep_dir, log, project=project)

        self._json_response(
            {
                "shot_id": shot_id,
                "status": status,
                "episode": ep_dir,
            }
        )

    def _api_promote(self, ep_dir, shot_id, project=None):
        """Promote a generated frame to output/refs/.

        Searches for the image file matching shot_id in the episode directory
        and copies it to output/refs/.
        """
        pp = _paths_for_project(project or DEFAULT_PROJECT)
        ep_path = pp["frames_dir"] / ep_dir
        if not ep_path.is_dir():
            self._json_response({"error": f"Episode not found: {ep_dir}"}, 404)
            return

        # Find the image file matching shot_id
        source_file = None
        for root, _dirs, files in os.walk(ep_path):
            for f in files:
                fpath = Path(root) / f
                if fpath.suffix.lower() in MEDIA_EXTS and fpath.stem == shot_id:
                    source_file = fpath
                    break
            if source_file:
                break

        if source_file is None:
            self._json_response({"error": f"No media found for shot: {shot_id}"}, 404)
            return

        # Copy to output/refs/
        pp["refs_dir"].mkdir(parents=True, exist_ok=True)
        dest = pp["refs_dir"] / source_file.name
        shutil.copy2(source_file, dest)

        # Update generation log
        log = load_shot_data(ep_dir, project=project)
        if log is None:
            log = {"episode": get_episode_number(ep_dir)}
        if "promoted" not in log:
            log["promoted"] = []
        promoted_entry = {
            "shot_id": shot_id,
            "source": str(source_file.relative_to(PROJECT_ROOT)),
            "dest": str(dest.relative_to(PROJECT_ROOT)),
        }
        # Avoid duplicate entries
        if promoted_entry not in log["promoted"]:
            log["promoted"].append(promoted_entry)
        save_log(ep_dir, log, project=project)

        self._json_response(
            {
                "shot_id": shot_id,
                "promoted_to": str(dest.relative_to(PROJECT_ROOT)),
                "episode": ep_dir,
            }
        )

    # ── Previs API implementations ─────────────────────────────────

    def _api_previs(self, ep_dir, pp=None):
        """List previs frames with statuses for an episode."""
        pvis_dir = pp["previs_dir"]
        fdir = pp["frames_dir"]
        ep_num = get_episode_number(ep_dir)
        previs_path = pvis_dir / ep_dir

        if not previs_path.is_dir():
            self._json_response({"error": f"No previs directory for {ep_dir}"}, 404)
            return

        # Scan for previs frames
        frames = []
        for f in sorted(previs_path.iterdir()):
            if f.suffix.lower() in IMAGE_EXTS:
                frames.append(
                    {
                        "filename": f.name,
                        "path": f"output/previs/{ep_dir}/{f.name}",
                        "shot_name": f.stem,
                        "size_bytes": f.stat().st_size,
                    }
                )

        # Load previs summary if available
        summary_path = previs_path / "previs_summary.json"
        summary = None
        if summary_path.exists():
            try:
                with open(summary_path) as sf:
                    summary = json.load(sf)
            except (json.JSONDecodeError, IOError):
                pass

        # Load approval status from Layer 1 generation log
        approvals = {}
        log_path = fdir / ep_dir / "log.json"
        if log_path.exists():
            try:
                with open(log_path) as lf:
                    log_data = json.load(lf)
                    approvals = log_data.get("human_approvals", {})
            except (json.JSONDecodeError, IOError):
                pass

        # Enrich frames with approval status
        for frame in frames:
            shot_name = frame["shot_name"]
            # Try to match approval by shot number
            for key, approval in approvals.items():
                if key in shot_name or shot_name.endswith(key):
                    frame["approved"] = approval.get("previs_approved", False)
                    break
            else:
                frame["approved"] = False

        self._json_response(
            {
                "episode": ep_dir,
                "frames": frames,
                "summary": summary,
                "total_frames": len(frames),
            }
        )

    def _api_approve_previs(self, body, project=None):
        """Approve previs frame — promotes approval to Layer 1 generation log.

        Body: {"episode": "ep_001", "shot_id": "1"}
        """
        ep_dir = body.get("episode", "")
        shot_id = body.get("shot_id", "")

        if not ep_dir or not shot_id:
            self._json_response(
                {"error": "Missing 'episode' or 'shot_id' in request body"},
                400,
            )
            return

        # Write approval to Layer 1 generation log
        log = load_shot_data(ep_dir, project=project)
        if log is None:
            log = {"episode": get_episode_number(ep_dir)}

        approvals = log.setdefault("human_approvals", {})
        approvals.setdefault(shot_id, {})
        approvals[shot_id]["previs_approved"] = True
        approvals[shot_id]["previs_approved_at"] = __import__("time").time()
        approvals[shot_id]["previs_approved_by"] = "director"

        save_log(ep_dir, log, project=project)

        self._json_response(
            {
                "episode": ep_dir,
                "shot_id": shot_id,
                "previs_approved": True,
            }
        )

    # ── Console API implementations ──────────────────────────────────

    def _api_bible(self, pp=None):
        """Return the GlobalBible JSON."""
        bp = pp["bible_path"]
        if not bp.exists():
            self._json_response(
                {"error": "GlobalBible not found. Run Stage 1 first."}, 404
            )
            return
        try:
            bible = json.loads(bp.read_text(encoding="utf-8"))
            self._json_response(bible)
        except (json.JSONDecodeError, IOError) as e:
            self._json_response({"error": f"Could not read GlobalBible: {e}"}, 500)

    def _api_bible_character_patch(self, char_id, body, pp):
        """Manually override character traits in the GlobalBible."""
        bible_path = pp["bible_path"]
        if not bible_path.exists():
            self._json_response({"error": "GlobalBible not found"}, 404)
            return
        try:
            bible = json.loads(bible_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, IOError):
            self._json_response({"error": "Could not read GlobalBible"}, 500)
            return

        characters = bible.get("characters", {})
        if char_id not in characters:
            self._json_response({"error": f"Character '{char_id}' not found"}, 404)
            return

        # Merge overrides
        for key, value in body.items():
            characters[char_id][key] = value
        characters[char_id]["_manual_override"] = True
        characters[char_id]["_override_at"] = time.time()

        bible["characters"] = characters
        bible_path.write_text(json.dumps(bible, indent=2), encoding="utf-8")

        self._json_response({"char_id": char_id, "updated": list(body.keys())})

    def _api_bible_aesthetic_directives_patch(self, body, pp):
        """PATCH /api/bible/aesthetic-directives — update show-level aesthetic directives."""
        bible_path = pp["bible_path"]
        if not bible_path or not bible_path.exists():
            self._json_response({"error": "GlobalBible not found"}, 404)
            return
        try:
            bible = json.loads(bible_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, IOError):
            self._json_response({"error": "Could not read GlobalBible"}, 500)
            return

        directives = bible.get("aesthetic_directives", {})
        for key, value in body.items():
            directives[key] = value
        bible["aesthetic_directives"] = directives
        bible_path.write_text(
            json.dumps(bible, indent=2, ensure_ascii=False), encoding="utf-8"
        )

        self._json_response(
            {"updated": list(body.keys()), "aesthetic_directives": directives}
        )

    def _api_budget(self, project=None):
        """Season-level cost aggregation from SQLite."""
        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return
        self._json_response(store.budget_summary())

    def _api_board(self, project=None, pp=None):
        """All episodes with 5-state counts for the Board tab."""
        fdir = pp["frames_dir"]
        pdir = pp["plans_dir"]
        store = _get_store(project)
        if store is None:
            # Fallback: scan episode directories
            ep_dirs = get_episode_dirs(frames_dir=fdir)
            episodes = []
            for ep_dir in ep_dirs:
                ep_num = get_episode_number(ep_dir)
                shot_data = load_shot_data(ep_dir, frames_dir=fdir, plans_dir=pdir)
                episodes.append(
                    {
                        "episode_id": f"EP{ep_num:03d}",
                        "dir": ep_dir,
                        "total_shots": shot_data.get("total_shots", 0)
                        if shot_data
                        else 0,
                        "total_cost": shot_data.get("total_cost", 0)
                        if shot_data
                        else 0,
                        "has_plan": shot_data is not None,
                    }
                )
            self._json_response({"episodes": episodes, "season_total_cost": 0})
            return

        budget = store.budget_summary()
        self._json_response(budget)

    def _api_board_episode(self, episode_id, project=None):
        """Scene-level drill-down for an episode."""
        pp = _paths_for_project(project or DEFAULT_PROJECT)
        pdir = pp["plans_dir"]
        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        # Normalize episode_id (accept ep_001, EP001, or just "1")
        if episode_id.startswith("ep_"):
            ep_num = get_episode_number(episode_id)
        else:
            ep_num = int(episode_id.replace("EP", ""))
        episode_id = f"EP{ep_num:03d}"

        include_cov = (
            parse_qs(urlparse(self.path).query).get("coverage", ["true"])[0] == "true"
        )
        shots = store.get_shots_by_episode(episode_id, include_coverage=include_cov)
        summary = store.summary(episode_id)

        # Enrich shots with scene data from plan
        plan_path = pdir / f"ep_{ep_num:03d}_plan.json"
        plan_lookup = {}
        if plan_path.exists():
            try:
                plan = json.loads(plan_path.read_text(encoding="utf-8"))
                for ps in plan.get("shots", []):
                    asset = ps.get("asset_data", {})
                    prompt = ps.get("prompt_data", {})
                    plan_lookup[ps["shot_id"]] = {
                        "scene_id": asset.get("location_id", ""),
                        "shot_type": prompt.get("shot_type", ""),
                        "origin": ps.get("origin", "script_derived"),
                    }
            except (json.JSONDecodeError, IOError):
                pass

        for shot in shots:
            extra = plan_lookup.get(shot["shot_id"], {})
            shot["scene_id"] = extra.get("scene_id", "")
            shot["shot_type"] = extra.get("shot_type", "")
            shot["origin"] = extra.get("origin", "script_derived")

            # Populate hero_frame in gate_results when missing —
            # STA_ batch keyframes and keyframe_path aren't written as hero_frame
            gate = shot.get("gate_results") or {}
            if not gate.get("hero_frame"):
                _is_img = lambda p: p and not p.endswith((".mp4", ".webm"))
                fallback = ""
                # 1. keyframe_path from gate (image only)
                kp = gate.get("keyframe_path", "")
                if _is_img(kp):
                    fallback = kp
                # 2. output_path (image only — skip video files)
                if not fallback:
                    op = shot.get("output_path", "")
                    if _is_img(op):
                        fallback = op
                # 3. Last non-rejected take (image only)
                if not fallback:
                    for t in reversed(shot.get("takes") or []):
                        fp = t.get("file_path", "")
                        if _is_img(fp) and not t.get("rejected"):
                            fallback = fp
                            break
                if fallback:
                    gate["hero_frame"] = fallback
                    shot["gate_results"] = gate

        self._json_response(
            {
                "episode_id": episode_id,
                "summary": summary,
                "shots": shots,
            }
        )

    def _api_launch_batch(self, body, project=None):
        """Run PreFlightChecker + return cost estimate (no generation yet)."""
        episode_id = body.get("episode_id")
        if not episode_id:
            self._json_response({"error": "Missing episode_id"}, 400)
            return

        # Load plan or generation log
        ep_dir = f"ep_{int(episode_id.replace('EP', '')):03d}"
        plan = load_shot_data(ep_dir, project=project)
        if plan is None:
            # Try plan by episode ID
            pp = _paths_for_project(project or DEFAULT_PROJECT)
            plan_path = pp["plans_dir"] / f"{episode_id}.json"
            if plan_path.exists():
                try:
                    plan = json.loads(plan_path.read_text(encoding="utf-8"))
                except (json.JSONDecodeError, IOError):
                    pass

        if plan is None:
            self._json_response(
                {"error": f"No plan or log found for {episode_id}"}, 404
            )
            return

        # Run PreFlightChecker
        try:
            from recoil.pipeline._lib.preflight import PreFlightChecker

            checker = PreFlightChecker()
            warnings = checker.validate_batch(plan, project=project)
            cost_estimate = checker.estimate_cost(plan)

            self._json_response(
                {
                    "episode_id": episode_id,
                    "warnings": [
                        {
                            "shot_id": w.shot_id,
                            "severity": w.severity,
                            "check": w.check,
                            "message": w.message,
                        }
                        for w in warnings
                    ],
                    "cost_estimate": {
                        "total": cost_estimate.total,
                        "previs": cost_estimate.previs,
                        "keyframes": cost_estimate.keyframes,
                        "video": cost_estimate.video,
                        "qc": cost_estimate.qc,
                        "per_shot": cost_estimate.per_shot,
                    },
                    "errors": len([w for w in warnings if w.severity == "error"]),
                    "can_launch": all(w.severity != "error" for w in warnings),
                }
            )
        except ImportError:
            self._json_response({"error": "PreFlightChecker not available"}, 503)

    def _api_launch_batch_confirm(self, body, project=None):
        """Actually start generation after preflight approval."""
        episode_id = body.get("episode_id")
        if not episode_id:
            self._json_response({"error": "Missing episode_id"}, 400)
            return

        # Queue the batch for processing
        # In production, this would call the pipeline orchestrator.
        # For now, mark all previs_pending shots as previs_generating.
        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shots = store.get_shots_by_episode(episode_id)

        # Guard against double-queue: reject if shots already generating
        already_generating = [s for s in shots if s["status"] == "previs_generating"]
        if already_generating:
            self._json_response(
                {
                    "error": f"Batch already in progress — {len(already_generating)} shots generating",
                },
                409,
            )
            return

        queued = 0
        for shot in shots:
            if shot["status"] == "previs_pending":
                store.update_shot(shot["shot_id"], status="previs_generating")
                queued += 1

        self._json_response(
            {
                "episode_id": episode_id,
                "queued": queued,
                "message": f"Queued {queued} shots for generation",
            }
        )

    def _api_dailies(self, project=None, pp=None):
        """Priority queue of all action-required items across episodes."""
        store = _get_store(project)
        if store is None:
            self._json_response({"items": [], "total": 0}, 200)
            return

        items = []

        # P0: DEFERRED — shots that passed but need mandatory human review
        deferred_count = 0
        if hasattr(store, "shots_dir") and store.shots_dir.exists():
            for shot_file in store.shots_dir.glob("*.json"):
                try:
                    import json as _json_d

                    _shot_data = _json_d.loads(shot_file.read_text())
                    if _shot_data.get("deferred") and _shot_data.get("status") not in (
                        "failed",
                        "abandoned",
                    ):
                        deferred_count += 1
                        items.append(
                            {
                                "priority": 0,
                                "shot_id": _shot_data["shot_id"],
                                "episode_id": _shot_data.get("episode_id", ""),
                                "status": _shot_data.get("status", ""),
                                "deferred": True,
                                "deferred_reason": _shot_data.get(
                                    "deferred_reason", ""
                                ),
                                "output_path": _shot_data.get("output_path", ""),
                                "takes": _shot_data.get("takes", []),
                                "actions": ["approve", "reject"],
                            }
                        )
                except Exception:
                    pass

        # P1: DEAD (failed) + Gate 2 semantic fails
        dead_shots = store.get_shots_by_status("failed")
        for shot in dead_shots:
            items.append(
                {
                    "priority": 1,
                    "shot_id": shot["shot_id"],
                    "episode_id": shot["episode_id"],
                    "status": shot["status"],
                    "error": shot.get("error_message", ""),
                    "pipeline": shot.get("pipeline", ""),
                    "model": shot.get("model", ""),
                    "actions": ["override", "reroute", "abandon"],
                }
            )

        # P2: Gate 3 video drift (video_ready but needs review)
        drift_shots = store.get_shots_by_status("video_ready")
        for shot in drift_shots:
            items.append(
                {
                    "priority": 2,
                    "shot_id": shot["shot_id"],
                    "episode_id": shot["episode_id"],
                    "status": shot["status"],
                    "output_path": shot.get("output_path", ""),
                    "takes": shot.get("takes", []),
                    "actions": ["approve", "reject"],
                }
            )

        # P3: Previs / keyframe approvals (skip shots with no output)
        approval_shots = store.get_shots_by_status(
            "previs_generated", "keyframe_generated"
        )
        for shot in approval_shots:
            takes = shot.get("takes", [])
            output = shot.get("output_path", "")
            # Skip shots with no output — nothing to review
            if not output and not takes:
                continue
            # Load prompt from log overrides or shot data
            prompt = ""
            if takes:
                prompt = takes[-1].get("prompt", "")
            items.append(
                {
                    "priority": 3,
                    "shot_id": shot["shot_id"],
                    "episode_id": shot["episode_id"],
                    "status": shot["status"],
                    "output_path": output,
                    "prompt": prompt,
                    "takes": takes,
                    "actions": ["approve", "reject"],
                }
            )

        # P4: Take selection (multiple takes available)
        take_shots = store.get_shots_by_status("video_complete")
        for shot in take_shots:
            takes = shot.get("takes", [])
            if len(takes) > 1:
                items.append(
                    {
                        "priority": 4,
                        "shot_id": shot["shot_id"],
                        "episode_id": shot["episode_id"],
                        "status": shot["status"],
                        "takes": takes,
                        "actions": ["select-take"],
                    }
                )

        # P5: Approved/locked shots (for bin review + unlock)
        approved_shots = store.get_shots_by_status(
            "previs_approved", "keyframe_approved"
        )
        for shot in approved_shots:
            takes = shot.get("takes", [])
            prompt = ""
            if takes:
                prompt = takes[-1].get("prompt", "")
            items.append(
                {
                    "priority": 5,
                    "shot_id": shot["shot_id"],
                    "episode_id": shot["episode_id"],
                    "status": "approved",
                    "output_path": shot.get("output_path", ""),
                    "prompt": prompt,
                    "takes": takes,
                    "actions": ["unlock"],
                }
            )

        # Sort by priority
        items.sort(key=lambda x: (x["priority"], x["episode_id"], x["shot_id"]))

        needs_action = sum(1 for i in items if i["priority"] <= 4)
        self._json_response(
            {
                "items": items,
                "total": len(items),
                "needs_action": needs_action,
                "deferred_count": deferred_count,
            }
        )

    def _api_dailies_videos(self, project=None, pp=None):
        """Return only video clips for the video-only dailies tab."""
        store = _get_store(project)
        if store is None:
            self._json_response({"clips": [], "total": 0}, 200)
            return

        binned_set = self._load_video_bin(project)

        clips = []
        video_shots = store.get_shots_by_status("video_complete", "video_pending")

        for shot in video_shots:
            gate = shot.get("gate_results", {}) or {}
            poster = gate.get("hero_frame") or gate.get("first_frame") or ""
            prompt = shot.get("prompt", "")
            takes = shot.get("takes", [])
            if takes and not prompt:
                prompt = takes[-1].get("prompt", "")
            model = shot.get("model", shot.get("pipeline", ""))

            # Find source keyframe — priority: hero_frame (the promoted anchor),
            # then keyframe takes, then approved previz, then STA_ batch files
            source_frame = ""
            # 1. Hero frame from gate_results — this is what the user promoted
            #    and what the video was actually generated from
            if gate.get("hero_frame"):
                source_frame = gate["hero_frame"]
            # 2. Keyframe takes in store
            if not source_frame:
                kf_takes = [
                    t
                    for t in takes
                    if t.get("layer") == "keyframe" and not t.get("rejected")
                ]
                if kf_takes:
                    source_frame = kf_takes[-1].get("file_path", "")
            # 3. Approved previz take
            if not source_frame:
                approved_takes = [
                    t for t in takes if t.get("approved") and not t.get("layer")
                ]
                if approved_takes:
                    source_frame = approved_takes[-1].get("file_path", "")
            # 4. STA_ batch files (legacy)
            if not source_frame and pp:
                parts = shot["shot_id"].split("_SH")
                if len(parts) == 2:
                    ep_num = parts[0].replace("EP", "").lstrip("0") or "0"
                    ep_part = f"ep_{int(ep_num):03d}"
                    frames_dir = pp["frames_dir"] / ep_part
                    if frames_dir.exists():
                        sta_pattern = f"STA_*{shot['shot_id']}.*"
                        sta_files = sorted(frames_dir.glob(sta_pattern))
                        if sta_files:
                            source_frame = str(
                                sta_files[-1].relative_to(pp["project_dir"])
                            )
            # 5. Poster fallback
            if not source_frame:
                source_frame = poster

            # Primary: read from takes[] in store — include all video files
            _VIDEO_PIPELINES = {
                "video",
                "i2v",
                "t2v",
                "multi_shot",
                "coverage",
                "action",
                "choreography",
                "constraint",
                "sequence",
            }
            video_takes = [
                t
                for t in takes
                if t.get("pipeline") in _VIDEO_PIPELINES
                or t.get("layer") == "video"
                or (t.get("file_path", "").endswith(".mp4"))
            ]

            if video_takes:
                for t in video_takes:
                    file_path = t.get("file_path", "")
                    try:
                        take_n = read_take_number(t)
                    except TakeNumberMissingError:
                        # `take_num` is a separate legacy alias not modelled by read_take_number.
                        take_n = t.get("take_num", 0)
                    t_pipeline = t.get("pipeline", "")
                    if not t_pipeline or t_pipeline == "video":
                        t_pipeline = "t2v" if "_t2v" in file_path else "i2v"
                    clips.append(
                        {
                            "shot_id": shot["shot_id"],
                            "episode_id": shot["episode_id"],
                            "video_path": file_path,
                            "poster": poster,
                            "source_frame": source_frame if t_pipeline != "t2v" else "",
                            "model": t.get("model", model),
                            "prompt": t.get("prompt_used", t.get("prompt", prompt)),
                            "pipeline": t_pipeline,
                            "status": shot["status"],
                            "approved": shot.get("video_approved", False),
                            "rejected": shot.get("video_rejected", False),
                            "cost": read_cost_from_record_safe(t) or t.get("cost", 0),
                            "take_num": take_n,
                            "binned": file_path in binned_set,
                            "deferred": shot.get("deferred", False),
                            "deferred_reason": shot.get("deferred_reason", ""),
                            "is_coverage": t.get(
                                "is_coverage", shot.get("is_coverage", False)
                            ),
                            "framing": t.get("framing", ""),
                        }
                    )
            else:
                # Fallback: scan disk for legacy shots without takes[]
                import re as _re_sh

                parts = shot["shot_id"].split("_SH")
                if len(parts) == 2:
                    ep_num = parts[0].replace("EP", "").lstrip("0") or "0"
                    ep_part = f"ep_{int(ep_num):03d}"
                    sh_match = _re_sh.match(r"(\d+)(.*)", parts[1])
                    if sh_match:
                        sh_num = int(sh_match.group(1))
                        sh_suffix = sh_match.group(2).lower()
                        base_name = f"shot_{sh_num:03d}{sh_suffix}"
                    else:
                        base_name = f"shot_{parts[1].lower()}"
                    video_dir = pp["video_dir"] / ep_part
                else:
                    video_dir = pp["video_dir"]
                    base_name = shot["shot_id"]

                video_files = []
                if video_dir.exists():
                    candidates = []
                    for ext in VIDEO_EXTS:
                        candidates.extend(video_dir.glob(f"{base_name}*{ext}"))
                        # Also scan for {SHOT_ID}* pattern (e.g. EP001_SH02_t2v.mp4)
                        candidates.extend(video_dir.glob(f"{shot['shot_id']}*{ext}"))
                    # Deduplicate by path
                    seen = set()
                    deduped = []
                    for c in candidates:
                        if c not in seen:
                            seen.add(c)
                            deduped.append(c)
                    deduped.sort(key=lambda p: p.name)
                    for vf in deduped:
                        stem = vf.stem
                        rest_base = (
                            stem[len(base_name) :]
                            if stem.startswith(base_name)
                            else None
                        )
                        rest_shot = (
                            stem[len(shot["shot_id"]) :]
                            if stem.startswith(shot["shot_id"])
                            else None
                        )
                        if (
                            rest_base is not None
                            and (rest_base == "" or rest_base.startswith("_"))
                        ) or (
                            rest_shot is not None
                            and (rest_shot == "" or rest_shot.startswith("_"))
                        ):
                            video_files.append(vf)

                if video_files:
                    for vf in video_files:
                        rel = str(vf.relative_to(pp["project_dir"]))
                        import re as _re

                        m = _re.search(r"_take(\d+)\.mp4$", vf.name)
                        take_n = int(m.group(1)) if m else 0
                        vf_pipeline = "t2v" if "_t2v" in vf.name else "i2v"
                        clips.append(
                            {
                                "shot_id": shot["shot_id"],
                                "episode_id": shot["episode_id"],
                                "video_path": rel,
                                "poster": poster,
                                "source_frame": source_frame
                                if vf_pipeline != "t2v"
                                else "",
                                "model": model or "",
                                "prompt": prompt,
                                "pipeline": vf_pipeline,
                                "status": shot["status"],
                                "approved": shot.get("video_approved", False),
                                "rejected": shot.get("video_rejected", False),
                                "cost": shot.get("cost_incurred", 0),
                                "take_num": take_n,
                                "binned": rel in binned_set,
                                "deferred": shot.get("deferred", False),
                                "deferred_reason": shot.get("deferred_reason", ""),
                                "is_coverage": shot.get("is_coverage", False),
                                "framing": "",
                            }
                        )
                else:
                    video_path = gate.get("video_path", "")
                    if video_path:
                        gp = "t2v" if "_t2v" in video_path else "i2v"
                        clips.append(
                            {
                                "shot_id": shot["shot_id"],
                                "episode_id": shot["episode_id"],
                                "video_path": video_path,
                                "poster": poster,
                                "source_frame": source_frame if gp != "t2v" else "",
                                "model": model or "",
                                "prompt": prompt,
                                "pipeline": gp,
                                "status": shot["status"],
                                "approved": shot.get("video_approved", False),
                                "rejected": shot.get("video_rejected", False),
                                "cost": shot.get("cost_incurred", 0),
                                "take_num": 1,
                                "binned": video_path in binned_set,
                                "deferred": shot.get("deferred", False),
                                "deferred_reason": shot.get("deferred_reason", ""),
                            }
                        )

        # ── Catch-all: pick up ANY .mp4 in video dirs not already tracked ──
        tracked_paths = {c["video_path"] for c in clips}
        if pp:
            for ep_dir in (
                sorted(pp["video_dir"].iterdir()) if pp["video_dir"].exists() else []
            ):
                if not ep_dir.is_dir():
                    continue
                for vf in sorted(ep_dir.glob("*.mp4")):
                    rel = str(vf.relative_to(pp["project_dir"]))
                    if rel in tracked_paths:
                        continue
                    import re as _re_catch

                    m = _re_catch.search(r"_take(\d+)\.mp4$", vf.name)
                    take_n = int(m.group(1)) if m else 0
                    clips.append(
                        {
                            "shot_id": vf.stem.split("_take")[0].split("_t2v")[0],
                            "episode_id": ep_dir.name.upper()
                            .replace("EP_", "EP")
                            .replace("EP0", "EP00")[:5]
                            or "EP001",
                            "video_path": rel,
                            "poster": "",
                            "source_frame": "",
                            "model": "unknown",
                            "prompt": "",
                            "pipeline": "untracked",
                            "status": "video_complete",
                            "approved": False,
                            "rejected": False,
                            "cost": 0,
                            "take_num": take_n,
                            "binned": rel in binned_set,
                            "is_coverage": "coverage" in vf.name,
                            "framing": "",
                        }
                    )

        clips.sort(
            key=lambda x: (
                x.get("episode_id", ""),
                x.get("shot_id", ""),
                x.get("take_num", 0),
            ),
            reverse=True,
        )
        binned_count = sum(1 for c in clips if c.get("binned"))
        deferred_count = sum(1 for c in clips if c.get("deferred"))
        self._json_response(
            {
                "clips": clips,
                "total": len(clips),
                "binned_count": binned_count,
                "deferred_count": deferred_count,
            }
        )

    def _api_dailies_approve_video(self, body, project=None):
        """Mark a video clip as approved."""
        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return
        store = _get_store(project)
        if store is None:
            self._json_response({"error": "Store not available"}, 503)
            return
        store.update_shot(shot_id, video_approved=True, video_rejected=False)
        self._json_response({"ok": True, "shot_id": shot_id})

    def _api_dailies_unapprove_video(self, body, project=None):
        """Remove video approval."""
        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return
        store = _get_store(project)
        if store is None:
            self._json_response({"error": "Store not available"}, 503)
            return
        store.update_shot(shot_id, video_approved=False)
        self._json_response({"ok": True, "shot_id": shot_id})

    # ── Video clip bin ──────────────────────────────────────────────

    @staticmethod
    def _video_bin_path(project):
        pp = _paths_for_project(project or DEFAULT_PROJECT)
        return pp["state_dir"] / "video_bin.json"

    @staticmethod
    def _load_video_bin(project):
        bp = ReviewHandler._video_bin_path(project)
        if bp.exists():
            try:
                return set(json.loads(bp.read_text(encoding="utf-8")))
            except (json.JSONDecodeError, OSError):
                pass
        return set()

    @staticmethod
    def _save_video_bin(project, binned: set):
        bp = ReviewHandler._video_bin_path(project)
        bp.parent.mkdir(parents=True, exist_ok=True)
        bp.write_text(json.dumps(sorted(binned)), encoding="utf-8")

    def _api_dailies_bin_clip(self, body, project=None):
        """Bin a video clip — hide from main view."""
        video_path = body.get("video_path")
        if not video_path:
            self._json_response({"error": "Missing video_path"}, 400)
            return
        binned = self._load_video_bin(project)
        binned.add(video_path)
        self._save_video_bin(project, binned)
        self._json_response({"ok": True, "binned": len(binned)})

    def _api_dailies_unbin_clip(self, body, project=None):
        """Restore a clip from the bin."""
        video_path = body.get("video_path")
        if not video_path:
            self._json_response({"error": "Missing video_path"}, 400)
            return
        binned = self._load_video_bin(project)
        binned.discard(video_path)
        self._save_video_bin(project, binned)
        self._json_response({"ok": True, "binned": len(binned)})

    def _api_dailies_approve(self, body, project=None):
        """Approve a previs/keyframe/video shot."""
        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        # Advance status based on current state — workspace-friendly:
        # allow approval from any status that has content to approve.
        status_map = {
            "previs_generated": "previs_approved",
            "previs_approved": "previs_approved",
            "keyframe_generated": "keyframe_approved",
            "keyframe_approved": "keyframe_approved",
            "video_pending": "previs_approved",  # re-approve previz = revert to previs_approved
            "video_complete": "approved",
            "video_ready": "video_complete",
        }
        cur = shot["status"]
        new_status = status_map.get(cur)
        if new_status is None:
            self._json_response(
                {"error": f"Cannot approve shot in status '{cur}'"},
                400,
            )
            return
        if new_status == cur:
            self._json_response(
                {"shot_id": shot_id, "new_status": cur, "already": True}
            )
            return

        try:
            ep_num = int(shot["episode_id"].replace("EP", ""))
            runner = _get_runner(project, ep_num)
            runner.transition(shot_id, new_status, reason=f"Console approve from {cur}")
        except InvalidTransitionError as e:
            self._json_response({"error": str(e), "status": 409}, 409)
            return

        # Also write approval to Layer 1 generation log
        ep_dir = f"ep_{ep_num:03d}"
        log = load_shot_data(ep_dir, project=project) or {"episode": ep_num}
        approvals = log.setdefault("human_approvals", {})
        approvals.setdefault(shot_id, {})
        stage = shot["status"].split("_")[0]  # previs, keyframe, video
        approvals[shot_id][f"{stage}_approved"] = True
        approvals[shot_id][f"{stage}_approved_at"] = time.time()
        save_log(ep_dir, log, project=project)

        # ── Copy approved video to NLE-ready export folder ──
        approved_path = None
        if new_status == "approved":
            gate = shot.get("gate_results", {})
            video_rel = gate.get("video_path", "")
            if video_rel:
                import shutil

                video_abs = (
                    pp["project_dir"] / video_rel
                    if not Path(video_rel).is_absolute()
                    else Path(video_rel)
                )
                if video_abs.is_file():
                    approved_dir = pp["output_dir"] / "approved" / ep_dir
                    approved_dir.mkdir(parents=True, exist_ok=True)
                    dest = approved_dir / f"{shot_id}{video_abs.suffix}"
                    shutil.copy2(str(video_abs), str(dest))
                    approved_path = str(dest)
                    print(f"  [APPROVE] {shot_id}: copied to {dest}")
                else:
                    print(f"  [APPROVE] {shot_id}: video file not found at {video_abs}")

        self._json_response(
            {
                "shot_id": shot_id,
                "new_status": new_status,
                "approved_path": approved_path,
            }
        )

    def _api_dailies_reject(self, body, project=None):
        """Reject and re-queue a shot, with structured rejection tags that
        compute prompt override actions for the next regeneration pass."""
        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        # Mark as rejected (undo-reject restores to *_generated)
        status_map = {
            "previs_generated": "previs_rejected",
            "keyframe_generated": "keyframe_rejected",
            "video_complete": "video_rejected",
        }
        new_status = status_map.get(shot["status"])
        if new_status is None:
            self._json_response(
                {
                    "error": f"Cannot reject shot in status '{shot['status']}'",
                    "status": 400,
                },
                400,
            )
            return

        # Store rejection metadata with structured override actions
        tags = body.get("tags", [])
        notes = body.get("notes", "")
        rejection_data = {}
        if tags or notes:
            # Compute prompt override actions based on structured tags
            from recoil.pipeline._lib.rejection import compute_rejection_overrides

            spoiler_word = body.get("spoiler_word", "")
            rejection_overrides = compute_rejection_overrides(
                tags, spoiler_word=spoiler_word
            )

            rejection_data = {
                "last_rejection": {
                    "tags": tags,
                    "notes": notes,
                    "at": time.time(),
                    "from_status": shot["status"],
                    "overrides": rejection_overrides,
                }
            }

        try:
            ep_num = int(shot["episode_id"].replace("EP", ""))
            runner = _get_runner(project, ep_num)
            runner.transition(
                shot_id, new_status, reason=f"Console reject: {','.join(tags)}"
            )
            if rejection_data:
                store.update_shot(shot_id, gate_results=rejection_data)
        except InvalidTransitionError as e:
            self._json_response({"error": str(e), "status": 409}, 409)
            return

        # Append to JSONL analytics log
        if tags or notes:
            project = project or DEFAULT_PROJECT
            if project:
                log_path = (
                    projects_root()
                    / project
                    / "state"
                    / STATE_NAMESPACE
                    / "rejection_log.jsonl"
                )
                log_path.parent.mkdir(parents=True, exist_ok=True)
                # Count previous rejections for this shot from existing JSONL
                attempt_count = 0
                if log_path.exists():
                    try:
                        with open(log_path, "r", encoding="utf-8") as f:
                            for line in f:
                                try:
                                    entry = json.loads(line)
                                    if entry.get("shot_id") == shot_id:
                                        attempt_count += 1
                                except json.JSONDecodeError:
                                    pass
                    except OSError:
                        pass
                log_entry = {
                    "timestamp": time.time(),
                    "episode_id": shot.get("episode_id", ""),
                    "shot_id": shot_id,
                    "tags": tags,
                    "attempt_count": attempt_count + 1,
                    "from_status": shot["status"],
                }
                try:
                    with open(log_path, "a", encoding="utf-8") as f:
                        f.write(json.dumps(log_entry) + "\n")
                except OSError:
                    pass  # Non-fatal: analytics logging should not block rejection

        self._json_response(
            {"shot_id": shot_id, "new_status": new_status, "tags": tags}
        )

    def _api_dailies_filmstrip(self, episode_id, shot_id, project=None):
        """Return prev/current/next shot frames for filmstrip continuity view."""
        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        # Get all shots for the episode, sorted by shot_id
        include_cov = (
            parse_qs(urlparse(self.path).query).get("coverage", ["true"])[0] == "true"
        )
        shots = store.get_shots_by_episode(episode_id, include_coverage=include_cov)
        if not shots:
            self._json_response({"error": f"No shots found for {episode_id}"}, 404)
            return

        # Find the current shot's index
        current_idx = None
        for i, s in enumerate(shots):
            if s["shot_id"] == shot_id:
                current_idx = i
                break

        if current_idx is None:
            self._json_response(
                {"error": f"Shot {shot_id} not found in {episode_id}"}, 404
            )
            return

        def shot_frame(shot_dict):
            """Extract frame summary from a shot record."""
            takes = shot_dict.get("takes", [])
            # Pick the latest non-rejected take with a file_path
            frame_path = shot_dict.get("output_path", "")
            if takes:
                for t in reversed(takes):
                    if t.get("file_path") and not t.get("rejected"):
                        frame_path = t["file_path"]
                        break
            return {
                "shot_id": shot_dict["shot_id"],
                "status": shot_dict.get("status", ""),
                "frame_path": frame_path,
                "take_count": len(takes),
            }

        result = {
            "episode_id": episode_id,
            "current": shot_frame(shots[current_idx]),
            "prev": shot_frame(shots[current_idx - 1]) if current_idx > 0 else None,
            "next": shot_frame(shots[current_idx + 1])
            if current_idx < len(shots) - 1
            else None,
        }

        self._json_response(result)

    def _api_dailies_override(self, body, project=None):
        """Save manual_prompt_override to Layer 1 generation log."""
        shot_id = body.get("shot_id")
        prompt = body.get("prompt")
        if not shot_id or not prompt:
            self._json_response({"error": "Missing shot_id or prompt"}, 400)
            return

        store = _get_store(project)
        if store:
            shot = store.get_shot(shot_id)
            if shot:
                ep_num = int(shot["episode_id"].replace("EP", ""))
                ep_dir = f"ep_{ep_num:03d}"
                log = load_shot_data(ep_dir, project=project) or {"episode": ep_num}
                overrides = log.setdefault("manual_prompt_overrides", {})
                overrides[shot_id] = {
                    "prompt": prompt,
                    "override_at": time.time(),
                }
                save_log(ep_dir, log, project=project)

                # Re-queue the shot
                store.force_reset_status(
                    shot_id,
                    "previs_pending",
                    reason="dailies override — prompt changed by user",
                )

                self._json_response({"shot_id": shot_id, "overridden": True})
                return

        self._json_response({"error": "Could not save override"}, 500)

    def _api_dailies_reroute(self, body, project=None):
        """Change shot model/pipeline."""
        shot_id = body.get("shot_id")
        model = body.get("model")
        pipeline = body.get("pipeline")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        updates = {}
        if model:
            model = LEGACY_MODEL_MAP.get(model, model)
            updates["model"] = model
        if pipeline:
            updates["pipeline"] = pipeline

        if updates:
            store.update_shot(shot_id, **updates)
        store.force_reset_status(
            shot_id,
            "previs_pending",
            reason="dailies reroute — model/pipeline changed by user",
        )
        self._json_response(
            {
                "shot_id": shot_id,
                "rerouted": True,
                "status": "previs_pending",
                **updates,
            }
        )

    def _api_dailies_abandon(self, body, project=None):
        """Remove shot from active production (mark as abandoned)."""
        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        store.force_reset_status(
            shot_id, "abandoned", reason="user abandoned from dailies"
        )
        self._json_response({"shot_id": shot_id, "abandoned": True})

    def _api_dailies_select_take(self, body, project=None):
        """Write selected_take_id to Layer 1 generation log."""
        shot_id = body.get("shot_id")
        take_id = body.get("take_id")
        if not shot_id or not take_id:
            self._json_response({"error": "Missing shot_id or take_id"}, 400)
            return

        store = _get_store(project)
        if store:
            shot = store.get_shot(shot_id)
            if shot:
                # Mark selected take as approved in ExecutionStore, deselect others
                takes = shot.get("takes", [])
                for t in takes:
                    if t.get("take_id") == take_id:
                        t["approved"] = True
                        t.pop("rejected", None)
                    else:
                        t.pop("approved", None)
                store.update_shot(shot_id, takes=takes)

                # Also persist to Layer 1 generation log
                ep_num = int(shot["episode_id"].replace("EP", ""))
                ep_dir = f"ep_{ep_num:03d}"
                log = load_shot_data(ep_dir, project=project) or {"episode": ep_num}
                selections = log.setdefault("selected_takes", {})
                selections[shot_id] = {
                    "take_id": take_id,
                    "selected_at": time.time(),
                }
                save_log(ep_dir, log, project=project)
                self._json_response({"shot_id": shot_id, "take_id": take_id})
                return

        self._json_response({"error": "Could not save take selection"}, 500)

    def _api_set_previz_hero(self, body, project=None):
        """Set a specific take as the hero for a shot.

        Body: {"shot_id": "EP001_SH02", "take_index": 0}
        Sets gate_results.hero_frame and output_path to the take's file_path.
        """
        shot_id = body.get("shot_id")
        take_index = body.get("take_index")
        if not shot_id or take_index is None:
            self._json_response({"error": "Missing shot_id or take_index"}, 400)
            return

        store = _get_store(project)
        if not store:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if not shot:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        takes = shot.get("takes", [])
        if take_index < 0 or take_index >= len(takes):
            self._json_response({"error": f"Invalid take_index: {take_index}"}, 400)
            return

        take = takes[take_index]
        file_path = take.get("file_path")
        if not file_path:
            self._json_response({"error": "Take has no file_path"}, 400)
            return

        # Set hero_frame in gate_results and update output_path
        store.update_shot(
            shot_id,
            gate_results={"hero_frame": file_path},
            output_path=file_path,
        )

        self._json_response(
            {
                "shot_id": shot_id,
                "hero_frame": file_path,
                "take_index": take_index,
            }
        )

    def _api_compile_prompt_preview(self, body, project=None):
        """Compile and return annotated prompt layers + formatter outputs.

        Delegates to api.routes.prompt_inspector logic.
        Body: {"shot_id": "EP001_SC001_001", "mode": ..., "bypasses": [...], ...}
        """
        from recoil.pipeline.api.routes.prompt_inspector import (
            _find_shot_in_plans,
            _load_bible,
            _load_project_config,
        )

        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        pp = _paths_for_project(project or DEFAULT_PROJECT)
        plans_dir = pp["plans_dir"]

        # Load shot from plan
        shot_data, episode = _find_shot_in_plans(plans_dir, shot_id)
        if not shot_data:
            self._json_response(
                {"error": f"Shot not found in any plan: {shot_id}"}, 404
            )
            return

        # Load and apply saved overrides
        from recoil.pipeline.api.routes.overrides import _load_overrides

        all_ov = _load_overrides(plans_dir, episode)
        saved_overrides = all_ov.get(shot_id, {})
        if saved_overrides:
            from recoil.pipeline._lib.previz_context import apply_overrides

            shot_data = apply_overrides(shot_data, saved_overrides)

        # Load bible + project config
        bible = _load_bible(pp["bible_path"])
        project_config = _load_project_config(pp["project_dir"])

        # Apply optional overrides from request
        warnings = []
        mode = body.get("mode")
        bypasses_list = body.get("bypasses")
        override_shot_type = body.get("shot_type")

        valid_preset_modes = {"standard", "stylized", "raw", "passthrough", "custom"}
        if mode or bypasses_list:
            overrides = shot_data.setdefault("prompt_overrides", {})
            if mode and mode in valid_preset_modes:
                overrides["mode"] = mode
            if bypasses_list and mode == "custom":
                overrides["bypass"] = bypasses_list

        if override_shot_type:
            pd = shot_data.setdefault("prompt_data", {})
            original_type = pd.get("shot_type", "MS")
            pd["shot_type"] = override_shot_type
            if original_type != override_shot_type:
                warnings.append(
                    f"Shot type overridden: {original_type} -> {override_shot_type}"
                )

        # Director notes override
        draft_director_notes = body.get("director_notes")
        if draft_director_notes is not None:
            shot_data["director_notes"] = draft_director_notes.strip()

        # Build annotated sections
        from recoil.pipeline._lib.prompt_engine import (
            build_prompt_sections_from_plan,
            build_prompt_from_plan,
            build_video_prompt_from_plan,
            build_kling_i2v_prompt,
            build_kling_t2v_prompt,
            _resolve_bypasses,
            _OVERRIDE_PRESETS,
        )

        sections = build_prompt_sections_from_plan(
            shot_data,
            bible,
            project_config,
            episode=episode,
        )

        compiled_prompt = build_prompt_from_plan(
            shot_data,
            bible,
            project_config,
            episode=episode,
        )

        # Formatter outputs
        formatters = {}
        try:
            formatters["video"] = build_video_prompt_from_plan(
                shot_data,
                bible,
                project_config,
                episode=episode,
            )
        except Exception as e:
            formatters["video"] = f"[error: {e}]"
            warnings.append(f"video formatter failed: {e}")

        try:
            formatters["kling_i2v"] = build_kling_i2v_prompt(shot_data)
        except Exception as e:
            formatters["kling_i2v"] = f"[error: {e}]"
            warnings.append(f"kling_i2v formatter failed: {e}")

        try:
            formatters["kling_t2v"] = build_kling_t2v_prompt(shot_data)
        except Exception as e:
            formatters["kling_t2v"] = f"[error: {e}]"
            warnings.append(f"kling_t2v formatter failed: {e}")

        # Formatter overrides (manual edits saved per-shot)
        formatter_overrides = {}
        for fmt_key in ("video", "kling_i2v", "kling_t2v"):
            ov_key = f"formatter_{fmt_key}"
            ov_val = saved_overrides.get(ov_key)
            if ov_val is not None:
                formatter_overrides[fmt_key] = ov_val

        # Blocking data
        blocking = shot_data.get("blocking_metadata")

        # Bypass metadata
        active_bypasses = sorted(_resolve_bypasses(shot_data))
        bypass_mode = shot_data.get("prompt_overrides", {}).get("mode", "standard")
        available_presets = {k: sorted(v) for k, v in _OVERRIDE_PRESETS.items()}

        # Word count
        word_count = len(compiled_prompt.split()) if compiled_prompt else 0

        # Formatter limits
        from recoil.core.prompt_config import load_constants

        formatter_limits = load_constants().get("formatter_limits", {})

        # Ref metadata for inspector (from casting_state)
        refs_meta = []
        try:
            from recoil.pipeline._lib.taxonomy import default_weight

            asset_data = shot_data.get("asset_data", {})
            state_path = (
                pp["project_dir"] / "state" / STATE_NAMESPACE / "casting_state.json"
            )
            if state_path.exists():
                _casting = json.loads(state_path.read_text(encoding="utf-8"))
                cast_chars = _casting.get("characters", {})
                for char_entry in asset_data.get("characters", []):
                    cid = (
                        char_entry.get("char_id", "")
                        if isinstance(char_entry, dict)
                        else str(char_entry)
                    )
                    cs = cast_chars.get(cid.upper(), {})
                    hp = cs.get("hero_path")
                    if hp:
                        refs_meta.append(
                            {
                                "path": hp,
                                "slot": "subject",
                                "type": "identity",
                                "weight": default_weight("identity"),
                                "auto": True,
                                "reason": f"Character hero: {cid}",
                            }
                        )
                loc_id = asset_data.get("location_id", "")
                if loc_id:
                    _locs = _casting.get("locations", {})
                    ls = _locs.get(loc_id, {})
                    if not ls:
                        for k, v in _locs.items():
                            if k.lower() == loc_id.lower():
                                ls = v
                                break
                    picks = ls.get("moodboard_picks", [])
                    from recoil.pipeline._lib.taxonomy import slugify_asset_id

                    loc_slug = slugify_asset_id(loc_id)
                    if picks:
                        for fn in picks:
                            refs_meta.append(
                                {
                                    "path": f"output/refs/locations/{loc_slug}/{fn}",
                                    "slot": "environment",
                                    "type": "loc",
                                    "weight": default_weight("loc"),
                                    "auto": True,
                                    "reason": f"Curated moodboard: {loc_id}",
                                }
                            )
                    elif ls.get("hero_path"):
                        refs_meta.append(
                            {
                                "path": ls["hero_path"],
                                "slot": "environment",
                                "type": "loc",
                                "weight": default_weight("loc"),
                                "auto": True,
                                "reason": f"Location hero: {loc_id}",
                            }
                        )
                cast_props = _casting.get("props", {})
                for prop_entry in asset_data.get("props", []):
                    pid = (
                        prop_entry.get("prop_id", "")
                        if isinstance(prop_entry, dict)
                        else str(prop_entry)
                    )
                    ps = cast_props.get(pid, {})
                    pp_path = ps.get("hero_path") or ps.get("ref_path")
                    if pp_path:
                        refs_meta.append(
                            {
                                "path": pp_path,
                                "slot": "prop",
                                "type": "prop",
                                "weight": default_weight("prop"),
                                "auto": True,
                                "reason": f"Prop ref: {pid}",
                            }
                        )
        except Exception as e:
            logger.warning("Failed to extract ref metadata for inspector: %s", e)

        self._json_response(
            {
                "shot_id": shot_id,
                "compiled_prompt": compiled_prompt,
                "layers": sections,
                "formatters": formatters,
                "formatter_overrides": formatter_overrides,
                "formatter_limits": formatter_limits,
                "blocking": blocking,
                "bypass_mode": bypass_mode,
                "active_bypasses": active_bypasses,
                "available_presets": available_presets,
                "word_count": word_count,
                "warnings": warnings,
                "refs": refs_meta,
            }
        )

    def _api_generate_previz(self, body, project=None):
        """Generate a single previz frame for a shot (async via background thread).

        Body: {"shot_id": "EP001_SH01", "prompt_override": "optional new prompt"}
        Returns immediately with status "generating". Background thread appends
        result to takes[], updates status to previs_generated when done.
        Poll /api/board/{ep} to detect completion.
        """
        import re
        import threading

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        prompt_override = body.get("prompt_override")

        # Previz allows concurrent generations — use unique key per request
        import uuid as _uuid

        gen_key = f"{shot_id}_previz_{_uuid.uuid4().hex[:8]}"
        _gen_tracker.try_start(gen_key)

        # Register with task registry for polling
        _task_id = _uuid.uuid4().hex[:8]
        with _task_lock:
            _task_registry[_task_id] = {
                "task_id": _task_id,
                "entity_id": shot_id,
                "action": "previz",
                "status": "running",
                "started": time.time(),
                "result": None,
                "error": None,
            }

        ep_match = re.match(r"EP(\d+)_SH(\d+)([A-Za-z]?)", shot_id)
        if not ep_match:
            _gen_tracker.finish(gen_key)
            self._json_response({"error": f"Invalid shot_id format: {shot_id}"}, 400)
            return
        ep_num = int(ep_match.group(1))
        shot_num = int(ep_match.group(2))
        shot_suffix = ep_match.group(3).lower()
        shot_label = f"{shot_num:03d}{shot_suffix}"

        # Upsert: auto-create shot in ExecutionStore if not present
        shot = store.get_shot(shot_id)
        if shot is None:
            episode_id = f"EP{ep_num:03d}"
            store.insert_shot(
                {
                    "shot_id": shot_id,
                    "episode_id": episode_id,
                    "pipeline": "still",
                    "status": "previs_pending",
                }
            )
            shot = store.get_shot(shot_id)

        # Load plan to get shot data for prompt building
        ep_dir = f"ep_{ep_num:03d}"
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
        shot_data = None
        if plan_path.exists():
            try:
                plan = json.loads(plan_path.read_text(encoding="utf-8"))
                for s in plan.get("shots", []):
                    if s.get("shot_id") == shot_id:
                        shot_data = s
                        break
            except (json.JSONDecodeError, IOError):
                pass

        # Load bible for prompt enrichment
        bible = None
        bible_path = pp["bible_path"]
        if bible_path.exists():
            try:
                bible = json.loads(bible_path.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, IOError):
                pass

        # Load all shots for sequence context
        all_shots = []
        if plan_path.exists():
            try:
                plan_full = json.loads(plan_path.read_text(encoding="utf-8"))
                all_shots = plan_full.get("shots", [])
            except (json.JSONDecodeError, IOError):
                pass

        # Build context — always use full context when shot_data exists.
        # prompt_override replaces the directive text but keeps all ref images.
        use_full_context = shot_data is not None
        context_parts = None

        print(
            f"  [DEBUG] {shot_id}: shot_data={'YES' if shot_data else 'NO'}, prompt_override={'YES' if prompt_override else 'NO'}, use_full_context={use_full_context}"
        )

        # ── Moodboard-to-text: convert location image to text for 3+ char shots ──
        if use_full_context and shot_data:
            from recoil.core.paths import get_config as _get_starsend_config

            _ss_cfg = _get_starsend_config()
            if _ss_cfg.get("enable_moodboard_to_text", True):
                chars = shot_data.get("asset_data", {}).get("characters", [])
                if len(chars) >= 3:
                    _loc_id = shot_data.get("asset_data", {}).get("location_id", "")
                    if _loc_id:
                        try:
                            from recoil.pipeline._lib.previz_context import (
                                resolve_location_refs,
                            )
                            from recoil.pipeline._lib.keyframe_context import (
                                describe_moodboard,
                            )

                            _loc_refs = resolve_location_refs(
                                _loc_id, project=project, max_refs=1
                            )
                            if _loc_refs:
                                _mb_result = describe_moodboard(
                                    _loc_refs[0][0],
                                    _loc_id,
                                    _loc_refs[0][1],
                                    project=project,
                                )
                                if _mb_result.get("success"):
                                    shot_data["_moodboard_text"] = _mb_result["text"]
                                    print(
                                        f"  [MOODBOARD] {shot_id}: location '{_loc_id}' converted to text ({len(_mb_result['text'])} chars)"
                                    )
                        except Exception as e:
                            print(f"  [MOODBOARD] {shot_id}: failed — {e}")

            # ── Scene visual locks: load existing locks for this scene ──
            if _ss_cfg.get("enable_scene_visual_locks", True):
                _scene_index = shot_data.get("scene_index", 0)
                try:
                    from recoil.pipeline._lib.keyframe_context import load_scene_locks

                    _scene_locks = load_scene_locks(ep_num, _scene_index, project)
                    if _scene_locks:
                        shot_data["_scene_visual_locks"] = _scene_locks
                        print(f"  [DNA] {shot_id}: scene {_scene_index} locks loaded")
                except Exception as e:
                    print(f"  [DNA] {shot_id}: failed to load scene locks — {e}")

            # ── Scene shots: populate _scene_shots for spatial continuity ──
            try:
                from recoil.pipeline._lib.previz_context import inject_scene_context

                inject_scene_context(shot_data, all_shots)
                print(
                    f"  [SPATIAL] {shot_id}: scene context injected ({len(shot_data.get('_scene_shots', []))} shots)"
                )
            except Exception as e:
                print(f"  [SPATIAL] {shot_id}: failed to inject scene context — {e}")

        _previz_inputs_snapshot = None
        if use_full_context:
            # Full-context mode: Flash writes its own prompt
            try:
                from recoil.pipeline._lib.previz_context import build_previz_context

                context_parts = build_previz_context(
                    shot=shot_data,
                    all_shots=all_shots,
                    bible=bible,
                    episode=ep_num,
                    project=project,
                )
                n_img = sum(1 for d, _, _ in context_parts if d is not None)
                print(
                    f"  [DEBUG] {shot_id}: context built — {len(context_parts)} parts, {n_img} images"
                )

                # Build previz inputs snapshot
                try:
                    from recoil.pipeline._lib.take_inputs import (
                        build_previz_inputs_snapshot,
                    )
                    from recoil.core.paths import get_config as _get_previz_cfg

                    _previz_model = "gemini-3.1-flash-image-preview"
                    _previz_ar = "9:16"
                    _previz_temp = _get_previz_cfg().get("previz_temperature", 0.4)
                    _previz_inputs_snapshot = build_previz_inputs_snapshot(
                        context_parts=context_parts,
                        shot=shot_data,
                        bible=bible or {},
                        generation_params={
                            "temperature": _previz_temp,
                            "aspect_ratio": _previz_ar,
                            "model": _previz_model,
                        },
                    )
                except Exception as _snap_err:
                    print(
                        f"  [DEBUG] {shot_id}: inputs snapshot build failed — {_snap_err}"
                    )
                    _previz_inputs_snapshot = None

                # If user edited the prompt, replace the generative directive
                # (last text part) with their override — keeps all ref images
                if prompt_override and context_parts:
                    # Find and replace the last text-only part (the directive)
                    for idx in range(len(context_parts) - 1, -1, -1):
                        data, mime, label = context_parts[idx]
                        if data is None and mime == "text":
                            context_parts[idx] = (
                                None,
                                "text",
                                f"# GENERATE THIS FRAME\n\n{prompt_override}",
                            )
                            print(
                                f"  [DEBUG] {shot_id}: prompt override injected into context slot {idx}"
                            )
                            break
            except ImportError as e:
                print(f"  [DEBUG] {shot_id}: ImportError building context: {e}")
                use_full_context = False

        prompt = None
        if not use_full_context:
            # Legacy path: we build the prompt ourselves
            prompt = prompt_override
            if not prompt and shot_data:
                try:
                    from recoil.pipeline._lib.prompt_engine import build_previs_prompt

                    prompt = build_previs_prompt(shot_data, bible=bible)
                except ImportError:
                    pass
            if not prompt:
                _gen_tracker.finish(gen_key)
                self._json_response(
                    {
                        "error": "No prompt available — provide prompt_override or ensure plan exists"
                    },
                    400,
                )
                return

        # Save prompt override to generation log if provided
        if prompt_override:
            log_data = load_shot_data(ep_dir, frames_dir=pp["frames_dir"]) or {
                "episode": ep_num
            }
            overrides = log_data.setdefault("manual_prompt_overrides", {})
            overrides[shot_id] = {"prompt": prompt_override, "override_at": time.time()}
            save_log(ep_dir, log_data, frames_dir=pp["frames_dir"])

        # Verify tools are importable before dispatching
        try:
            from tools.generate_previs import _generate_flash_frame, PREVIS_COST
        except ImportError:
            _gen_tracker.finish(gen_key)
            self._json_response({"error": "generate_previs tools not available"}, 503)
            return

        # Resolve character refs for legacy path (canonical filesystem resolution)
        legacy_refs = []
        if not use_full_context and shot_data:
            from recoil.pipeline._lib.previz_context import resolve_all_character_refs

            legacy_refs = resolve_all_character_refs(shot_data, project=project)

        # Mark shot as generating — tolerate shots already past previz stage
        # (e.g. video_complete shots getting a new previz take for comparison)
        _prior_status = (store.get_shot(shot_id) or {}).get("status", "")
        try:
            store.update_shot(shot_id, status="previs_generating")
        except InvalidTransitionError:
            # Shot is past previz (e.g. video_complete) — skip status change,
            # we'll still generate the take and append it
            print(
                f"  [INFO] {shot_id}: status {_prior_status} can't transition to previs_generating — generating take without status change"
            )

        # Respond immediately — UI will poll for completion
        self._json_response(
            {"status": "generating", "shot_id": shot_id, "task_id": _task_id}, 202
        )

        # Capture closures for background thread
        _previs_dir = pp["previs_dir"]
        _context_parts = context_parts
        _prompt = prompt
        _legacy_refs = legacy_refs
        _prior_status_for_bg = _prior_status
        _previz_inputs_snap = _previz_inputs_snapshot if use_full_context else None

        def _bg_generate():
            try:
                # Re-check status before generating — abort only if another generation changed it
                _current = store.get_shot(shot_id)
                _cur_status = _current.get("status", "") if _current else ""
                if (
                    _cur_status != "previs_generating"
                    and _cur_status != _prior_status_for_bg
                ):
                    print(
                        f"  [SKIP] {shot_id}: status changed to {_cur_status}, aborting generation"
                    )
                    return

                previs_dir = _previs_dir / ep_dir
                previs_dir.mkdir(parents=True, exist_ok=True)

                if _context_parts:
                    gen_result = _generate_flash_frame(context_parts=_context_parts)
                elif _legacy_refs:
                    gen_result = _generate_flash_frame(
                        prompt=_prompt, ref_images=_legacy_refs
                    )
                else:
                    gen_result = _generate_flash_frame(prompt=_prompt)

                if not gen_result.get("success"):
                    print(
                        f"  [WARN] Generate failed for {shot_id}: {gen_result.get('error')}"
                    )
                    try:
                        store.update_shot(
                            shot_id,
                            status="previs_mechanical_failed",
                            error_message=gen_result.get("error", "Unknown"),
                        )
                    except InvalidTransitionError:
                        print(
                            f"  [WARN] Shot {shot_id}: could not transition to previs_mechanical_failed"
                        )
                    return

                # Determine take number — use timestamp suffix to avoid race conditions
                # when multiple background threads generate for the same shot
                fresh_shot = store.get_shot(shot_id)
                current_takes = fresh_shot.get("takes", []) if fresh_shot else []
                take_num = len(current_takes) + 1
                _take_ts = int(time.time() * 1000) % 100000
                take_uid = f"{take_num}_{_take_ts}"

                output_path = previs_dir / f"shot_{shot_label}_take{take_uid}.png"
                output_path.write_bytes(gen_result["image_data"])
                try:
                    from recoil.pipeline._lib.validation import Validator

                    _v = Validator()
                    _g1 = _v.run_gate_1_image(output_path)
                    gate_1 = {
                        "passed": _g1.passed,
                        "details": _g1.details,
                        "cost": _g1.cost,
                    }
                except Exception as _e:
                    gate_1 = {
                        "passed": True,
                        "reason": f"Gate 1 unavailable: {_e}",
                        "cost": 0.0,
                    }

                _rel_path = to_serving_path(output_path, pp)

                # Capture Flash's authored prompt for the take record
                authored_prompt = gen_result.get("authored_prompt", "")
                display_prompt = authored_prompt or _prompt or "(flash-authored)"

                # ── Spatial compliance check ──────────────────────────
                spatial_compliance = None
                if shot_data and shot_data.get("spatial_data", {}).get("camera_side"):
                    try:
                        from recoil.pipeline._lib.spatial_compliance import (
                            run_spatial_compliance,
                        )
                        from recoil.pipeline._lib.prompt_engine import (
                            build_spatial_continuity_block,
                        )

                        # Build spatial block for failure attribution
                        _spatial_block = build_spatial_continuity_block(
                            shot=shot_data,
                            bible=bible or {},
                            scene_shots=shot_data.get("_scene_shots"),
                        )

                        # Find previous shot in same scene for cross-shot check
                        _prev_compliance = None
                        _prev_camera_side = None
                        if shot_data.get("_scene_shots"):
                            _scene_shots = shot_data["_scene_shots"]
                            _curr_idx = next(
                                (
                                    i
                                    for i, s in enumerate(_scene_shots)
                                    if s.get("shot_id") == shot_id
                                ),
                                -1,
                            )
                            if _curr_idx > 0:
                                _prev_shot_id = _scene_shots[_curr_idx - 1].get(
                                    "shot_id"
                                )
                                if _prev_shot_id:
                                    _prev_shot = store.get_shot(_prev_shot_id)
                                    if _prev_shot:
                                        _prev_takes = _prev_shot.get("takes") or []
                                        # Get the latest approved take's compliance data
                                        for _t in (
                                            reversed(_prev_takes)
                                            if isinstance(_prev_takes, list)
                                            else []
                                        ):
                                            if _t.get("spatial_compliance"):
                                                _prev_compliance = _t[
                                                    "spatial_compliance"
                                                ]
                                                break
                                        _prev_spatial = _scene_shots[_curr_idx - 1].get(
                                            "spatial_data", {}
                                        )
                                        _prev_camera_side = _prev_spatial.get(
                                            "camera_side"
                                        )

                        spatial_compliance = run_spatial_compliance(
                            image_data=gen_result["image_data"],
                            shot=shot_data,
                            bible=bible or {},
                            prev_shot_compliance=_prev_compliance,
                            prev_camera_side=_prev_camera_side,
                            authored_prompt=authored_prompt,
                            spatial_block=_spatial_block,
                        )

                        _sev = spatial_compliance.get("severity", "PASS")
                        _flags = [
                            f["flag"] for f in spatial_compliance.get("flags", [])
                        ]
                        if _sev != "PASS":
                            print(
                                f"  [SPATIAL-CHECK] {shot_id}: {_sev} — {', '.join(_flags)}"
                            )
                        else:
                            print(f"  [SPATIAL-CHECK] {shot_id}: PASS")

                    except Exception as e:
                        print(f"  [SPATIAL-CHECK] {shot_id}: check failed — {e}")

                _total_cost = PREVIS_COST
                if spatial_compliance and not spatial_compliance.get("skipped"):
                    try:
                        from recoil.pipeline._lib.spatial_compliance import (
                            COMPLIANCE_CHECK_COST as _cc_cost,
                        )

                        _total_cost += _cc_cost
                    except ImportError:
                        _total_cost += 0.010  # fallback

                take_record = {
                    "take_id": f"take_{take_uid}",
                    "take_num": take_num,
                    "file_path": _rel_path,
                    "prompt": display_prompt,
                    "authored_prompt": authored_prompt,
                    "cost": _total_cost,
                    "gate_1": gate_1,
                    "spatial_compliance": spatial_compliance,
                    "generated_at": time.time(),
                    "inputs_snapshot": _previz_inputs_snap,
                }

                # Atomic append — avoids read-modify-write race between threads
                # For shots past previz stage, keep current status
                _new_status = "previs_generated"
                try:
                    store.update_shot(
                        shot_id,
                        status=_new_status,
                        output_path=_rel_path,
                        append_take=take_record,
                        cost_incurred=PREVIS_COST,
                    )
                except InvalidTransitionError:
                    # Shot is past previz — just append the take without status change
                    store.update_shot(
                        shot_id,
                        output_path=_rel_path,
                        append_take=take_record,
                        cost_incurred=PREVIS_COST,
                    )

                # Auto-select the new take — you regenerated for a reason
                fresh = store.get_shot(shot_id)
                if fresh:
                    updated_takes = fresh.get("takes", [])
                    for t in updated_takes:
                        if t.get("take_id") == take_record["take_id"]:
                            t["approved"] = True
                        else:
                            t.pop("approved", None)
                    # Always keep hero_frame in sync with the auto-selected take
                    store.update_shot(
                        shot_id,
                        takes=updated_takes,
                        gate_results={"hero_frame": _rel_path},
                    )

                # Also copy to the canonical previs path
                import shutil as _shutil

                also_save = previs_dir / f"shot_{shot_label}.png"
                _shutil.copy2(output_path, also_save)

                if authored_prompt:
                    print(
                        f"  [OK] {shot_id} take {take_num} generated (flash-authored) — ${PREVIS_COST}"
                    )
                    print(f"       Authored prompt: {authored_prompt[:120]}")
                else:
                    print(
                        f"  [OK] {shot_id} take {take_num} generated — ${PREVIS_COST}"
                    )

                # ── Scene DNA extraction: extract visual locks from first scene keyframe ──
                if shot_data:
                    try:
                        from recoil.core.paths import get_config as _get_cfg

                        _cfg = _get_cfg()
                        if _cfg.get("enable_scene_visual_locks", True):
                            _s_idx = shot_data.get("scene_index", 0)
                            from recoil.pipeline._lib.keyframe_context import (
                                load_scene_locks,
                                extract_scene_visual_dna,
                            )

                            _existing = load_scene_locks(ep_num, _s_idx, project)
                            if not _existing:
                                _dna = extract_scene_visual_dna(
                                    gen_result["image_data"],
                                    shot_data,
                                    bible or {},
                                    project,
                                )
                                if _dna.get("success"):
                                    print(
                                        f"  [DNA] Scene {_s_idx} visual locks extracted"
                                    )
                    except Exception as e:
                        print(f"  [DNA] Scene DNA extraction failed: {e}")

                with _task_lock:
                    if _task_id in _task_registry:
                        _task_registry[_task_id]["status"] = "complete"
                        _task_registry[_task_id]["result"] = {"shot_id": shot_id}

            except Exception as exc:
                print(f"  [ERR] Background generate for {shot_id}: {exc}")
                try:
                    store.update_shot(
                        shot_id,
                        status="previs_mechanical_failed",
                        error_message=str(exc),
                    )
                except InvalidTransitionError:
                    print(
                        f"  [WARN] Shot {shot_id}: could not transition to previs_mechanical_failed"
                    )
                with _task_lock:
                    if _task_id in _task_registry:
                        _task_registry[_task_id]["status"] = "failed"
                        _task_registry[_task_id]["error"] = str(exc)
            finally:
                _gen_tracker.finish(gen_key)

        threading.Thread(target=_bg_generate, daemon=True).start()

    # ── Shot Composer (Compose & Delete) ─────────────────────────────

    def _api_compose_shot(self, body, project=None):
        """POST /api/compose-shot — Generate a new shot via LLM (preview only).

        Body: {
            "episode_id": "EP001",
            "after_shot_id": "EP001_SH05",
            "description": "Close-up of Kira's hand trembling..."
        }
        Returns the full ShotRecord dict with computed shot_id, but does NOT insert it.
        """

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        # Validate request
        episode_id = body.get("episode_id")
        after_shot_id = body.get("after_shot_id")
        description = body.get("description", "").strip()

        if not episode_id:
            self._json_response({"error": "Missing episode_id"}, 400)
            return
        if not after_shot_id:
            self._json_response({"error": "Missing after_shot_id"}, 400)
            return
        if not description or len(description) < 5:
            self._json_response(
                {"error": "Description must be at least 5 characters"}, 400
            )
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        # Load plan
        ep_num = int(episode_id.replace("EP", ""))
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
        if not plan_path.exists():
            self._json_response({"error": f"No plan found for {episode_id}"}, 404)
            return

        try:
            plan = json.loads(plan_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, IOError) as e:
            self._json_response({"error": f"Failed to read plan: {e}"}, 500)
            return

        plan_shots = plan.get("shots", [])

        # Find anchor shots
        after_shot_data = None
        before_shot_data = None
        before_shot_id = None

        for i, s in enumerate(plan_shots):
            if s.get("shot_id") == after_shot_id:
                after_shot_data = s
                if i + 1 < len(plan_shots):
                    before_shot_data = plan_shots[i + 1]
                    before_shot_id = before_shot_data["shot_id"]
                break

        if after_shot_data is None:
            self._json_response(
                {"error": f"Shot {after_shot_id} not found in plan"}, 404
            )
            return

        # Load bible for context and enum constraints
        bible = None
        bible_path = pp["bible_path"]
        if bible_path.exists():
            try:
                bible = json.loads(bible_path.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, IOError):
                pass

        if bible is None:
            self._json_response(
                {"error": "Global Bible not found — required for shot composition"}, 404
            )
            return

        # Build dynamic enum lists from bible
        valid_location_ids = list(bible.get("locations", {}).keys())
        valid_char_ids = list(bible.get("characters", {}).keys())
        valid_wardrobe_phases = {}
        for char_id, char_data in bible.get("characters", {}).items():
            phases = [
                p.get("phase_id", "")
                for p in char_data.get("phases", [])
                if p.get("phase_id")
            ]
            if phases:
                valid_wardrobe_phases[char_id] = phases

        # Extract relevant bible entries for anchor characters
        anchor_char_ids = set()
        for shot in [after_shot_data, before_shot_data]:
            if shot:
                for c in shot.get("asset_data", {}).get("characters", []):
                    anchor_char_ids.add(c.get("char_id", ""))
        # Also extract character names mentioned in description
        for char_id in valid_char_ids:
            char_name = (
                bible.get("characters", {}).get(char_id, {}).get("display_name", "")
            )
            if char_name and char_name.lower() in description.lower():
                anchor_char_ids.add(char_id)

        bible_context = {}
        for cid in anchor_char_ids:
            if cid in bible.get("characters", {}):
                char = bible["characters"][cid]
                bible_context[cid] = {
                    "display_name": char.get("display_name"),
                    "visual_description": char.get("visual_description"),
                    "phases": char.get("phases", []),
                }

        # Extract location context from anchors
        anchor_locations = set()
        for shot in [after_shot_data, before_shot_data]:
            if shot:
                loc = shot.get("asset_data", {}).get("location_id", "")
                if loc:
                    anchor_locations.add(loc)

        location_context = {}
        for lid in anchor_locations:
            if lid in bible.get("locations", {}):
                loc = bible["locations"][lid]
                location_context[lid] = {
                    "description": loc.get("description"),
                    "atmosphere": loc.get("atmosphere"),
                }

        # Compute new shot_id
        from orchestrator.execution_plan import compute_insert_id

        new_shot_id = compute_insert_id(episode_id, after_shot_id, before_shot_id)

        # Build LLM prompt
        scene_index = after_shot_data.get("scene_index", 1)
        prompt = f"""You are a storyboard artist for a vertical microdrama series. Generate a complete shot record for a new shot to be inserted into the sequence.

DIRECTOR'S INSTRUCTION:
{description}

SHOT BEFORE (anchor A):
{json.dumps(after_shot_data, indent=2, default=str)}

{"SHOT AFTER (anchor B):" if before_shot_data else "This is the last shot in the sequence."}
{json.dumps(before_shot_data, indent=2, default=str) if before_shot_data else ""}

CHARACTER BIBLE (relevant entries):
{json.dumps(bible_context, indent=2, default=str)}

LOCATION CONTEXT:
{json.dumps(location_context, indent=2, default=str)}

CONSTRAINTS:
- scene_index must be: {scene_index}
- location_id must be one of: {json.dumps(valid_location_ids)}
- char_id must be one of: {json.dumps(valid_char_ids)}
- For each character, wardrobe_phase_id must match their phases in the bible
- shot_type must be one of: WS, LS, FS, MS, MCU, CU, ECU, INSERT
- camera_movement must be one of: static, pan, tilt, push_in, pull_back, tracking, crane, handheld, steadicam, dolly
- Maintain spatial continuity with anchor shots (screen direction, camera side, 180-degree rule)
- target_editorial_duration_s must be 1-30

Generate ONLY the 5 consumer data groups: routing_data, prompt_data, spatial_data, asset_data, audio_data.
Also include source_text (a concise description of the shot based on the director's instruction).
Do NOT generate shot_id or compiled_prompts — those are computed by the backend."""

        # Call Flash for structured output
        try:
            from google import genai
            from google.genai import types as genai_types

            api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get(
                "GOOGLE_API_KEY"
            )
            if not api_key:
                self._json_response({"error": "GEMINI_API_KEY not set"}, 500)
                return

            client = genai.Client(api_key=api_key)

            from recoil.core.model_profiles import get_model as _get_model

            compose_model = _get_model("flash", "text")

            config = genai_types.GenerateContentConfig(
                temperature=0.4,
                max_output_tokens=4096,
                response_mime_type="application/json",
            )

            response = client.models.generate_content(
                model=compose_model,
                contents=[prompt],
                config=config,
            )
            raw_text = response.text if hasattr(response, "text") else str(response)
        except Exception as e:
            self._json_response({"error": f"LLM call failed: {e}"}, 500)
            return

        # Parse and validate LLM response
        try:
            generated = json.loads(raw_text)
        except json.JSONDecodeError:
            # Retry once on parse failure
            try:
                response2 = client.models.generate_content(
                    model=compose_model,
                    contents=[
                        prompt
                        + "\n\nIMPORTANT: Output valid JSON only. No markdown, no code blocks."
                    ],
                    config=config,
                )
                raw_text2 = (
                    response2.text if hasattr(response2, "text") else str(response2)
                )
                generated = json.loads(raw_text2)
            except (json.JSONDecodeError, Exception) as e2:
                self._json_response(
                    {"error": f"LLM returned invalid JSON after retry: {e2}"}, 500
                )
                return

        # Assemble full ShotRecord
        shot_record = {
            "shot_id": new_shot_id,
            "scene_index": scene_index,
            "source_text": generated.get("source_text", description),
            "origin": "composed",
            "routing_data": generated.get("routing_data", {}),
            "prompt_data": generated.get("prompt_data", {}),
            "spatial_data": generated.get("spatial_data", {}),
            "asset_data": generated.get("asset_data", {}),
            "audio_data": generated.get("audio_data", {}),
        }

        # Validate full ShotRecord schema
        from recoil.pipeline._lib.render_schema import ShotRecord as _ShotRecord

        try:
            _ShotRecord(**shot_record)
        except Exception as val_err:
            self._json_response(
                {
                    "error": f"Generated shot failed schema validation: {val_err}",
                    "shot_preview": shot_record,
                },
                422,
            )
            return

        # Validate asset IDs against bible (dynamic enum enforcement)
        validation_errors = []
        asset = shot_record.get("asset_data", {})
        if asset.get("location_id") and asset["location_id"] not in valid_location_ids:
            validation_errors.append(f"Invalid location_id: {asset['location_id']}")
        for char in asset.get("characters", []):
            cid = char.get("char_id", "")
            if cid and cid not in valid_char_ids:
                validation_errors.append(f"Invalid char_id: {cid}")
            wpid = char.get("wardrobe_phase_id", "")
            if (
                cid in valid_wardrobe_phases
                and wpid
                and wpid not in valid_wardrobe_phases[cid]
            ):
                validation_errors.append(
                    f"Invalid wardrobe_phase_id '{wpid}' for {cid}"
                )

        if validation_errors:
            self._json_response(
                {
                    "error": "Asset validation failed",
                    "validation_errors": validation_errors,
                    "shot_preview": shot_record,
                },
                422,
            )
            return

        # Return preview WITHOUT inserting
        anchors = [after_shot_id]
        if before_shot_id:
            anchors.append(before_shot_id)

        self._json_response(
            {
                "ok": True,
                "shot": shot_record,
                "shot_id": new_shot_id,
                "anchors_used": anchors,
                "committed": False,
            }
        )

    def _api_commit_compose(self, body, project=None):
        """POST /api/commit-compose — Commit a previously generated composed shot.

        Body: {
            "episode_id": "EP001",
            "after_shot_id": "EP001_SH05",
            "shot": { <full ShotRecord dict from compose-shot response> }
        }
        """
        project = project or DEFAULT_PROJECT

        episode_id = body.get("episode_id")
        after_shot_id = body.get("after_shot_id")
        shot_record = body.get("shot")

        if not episode_id:
            self._json_response({"error": "Missing episode_id"}, 400)
            return
        if not after_shot_id:
            self._json_response({"error": "Missing after_shot_id"}, 400)
            return
        if not shot_record or not shot_record.get("shot_id"):
            self._json_response({"error": "Missing shot data"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        from orchestrator.execution_plan import insert_composed_shot

        try:
            insert_composed_shot(
                project=project,
                episode_id=episode_id,
                after_shot_id=after_shot_id,
                shot_record=shot_record,
                store=store,
            )
        except Exception as e:
            self._json_response({"error": f"Insert failed: {e}"}, 500)
            return

        self._json_response(
            {
                "ok": True,
                "shot_id": shot_record["shot_id"],
                "committed": True,
            }
        )

    def _api_delete_shot(self, body, project=None):
        """POST /api/delete-shot — Remove a shot from plan and execution store.

        Body: {"shot_id": "EP001_SH03A", "episode_id": "EP001"}
        """
        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_id = body.get("shot_id")
        episode_id = body.get("episode_id")

        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return
        if not episode_id:
            self._json_response({"error": "Missing episode_id"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        # Delete from execution store
        deleted_from_store = store.delete_shot(shot_id)

        # Delete from plan file
        deleted_from_plan = False
        ep_num = int(episode_id.replace("EP", ""))
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
        if plan_path.exists():
            try:
                plan = json.loads(plan_path.read_text(encoding="utf-8"))
                shots = plan.get("shots", [])
                original_count = len(shots)
                shots = [s for s in shots if s.get("shot_id") != shot_id]
                if len(shots) < original_count:
                    deleted_from_plan = True
                    plan["shots"] = shots
                    plan["total_shots"] = len(shots)
                    plan_path.write_text(
                        json.dumps(plan, indent=2, default=str), encoding="utf-8"
                    )
            except (json.JSONDecodeError, IOError) as e:
                print(
                    f"Warning: Failed to update plan file for shot {shot_id} deletion: {e}"
                )

        if not deleted_from_store and not deleted_from_plan:
            self._json_response({"error": f"Shot {shot_id} not found"}, 404)
            return

        self._json_response(
            {
                "ok": True,
                "shot_id": shot_id,
                "deleted_from_store": deleted_from_store,
                "deleted_from_plan": deleted_from_plan,
            }
        )

    # ── Keyframe Pipeline (Layer 2 + Layer 3) ──────────────────────────

    def _api_smart_prompt(self, body, project=None):
        """Build an NBP-optimized keyframe prompt via Flash text call.

        Body: {"shot_id": "EP001_SH01", "director_edit": "optional edits"}
        Returns: {"prompt": str, "flash_reasoning": str, "base_prompt": str, "cost": float}
        """
        import re

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        director_edit = body.get("director_edit")

        ep_match = re.match(r"EP(\d+)_SH(\d+)", shot_id)
        if not ep_match:
            self._json_response({"error": f"Invalid shot_id format: {shot_id}"}, 400)
            return
        ep_num = int(ep_match.group(1))

        # Load plan
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
        if not plan_path.exists():
            self._json_response({"error": f"Plan not found for EP{ep_num:03d}"}, 404)
            return

        try:
            plan = json.loads(plan_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, IOError) as e:
            self._json_response({"error": f"Could not load plan: {e}"}, 500)
            return

        all_shots = plan.get("shots", [])
        shot_data = next((s for s in all_shots if s.get("shot_id") == shot_id), None)
        if not shot_data:
            self._json_response({"error": f"Shot {shot_id} not found in plan"}, 404)
            return

        # Load bible
        bible = None
        if pp["bible_path"].exists():
            try:
                bible = json.loads(pp["bible_path"].read_text(encoding="utf-8"))
            except (json.JSONDecodeError, IOError):
                pass

        # Find approved previz image path
        store = _get_store(project)
        previz_image_path = None
        if store:
            exec_shot = store.get_shot(shot_id)
            if exec_shot:
                takes = exec_shot.get("takes", [])
                approved_take = next((t for t in takes if t.get("approved")), None)
                if approved_take and approved_take.get("file_path"):
                    # Convert serving path back to absolute
                    rel = approved_take["file_path"]
                    if rel.startswith("output/"):
                        previz_image_path = pp["output_dir"] / rel[len("output/") :]

        try:
            from recoil.pipeline._lib.keyframe_context import build_smart_prompt
        except ImportError as e:
            self._json_response({"error": f"keyframe_context not available: {e}"}, 503)
            return

        result = build_smart_prompt(
            shot=shot_data,
            all_shots=all_shots,
            bible=bible or {},
            episode=ep_num,
            project=project,
            previz_image_path=previz_image_path,
            director_edit=director_edit,
        )

        if "error" in result:
            self._json_response({"error": result["error"]}, 500)
            return

        # Include the base prompt (pre-compiled from plan) for diff detection
        pre_compiled = shot_data.get("prompt_data", {}).get("keyframe_nbp", "")
        if not pre_compiled:
            skeleton = shot_data.get("prompt_data", {}).get("prompt_skeleton", {})
            pre_compiled = ", ".join(v for v in skeleton.values() if v)

        self._json_response(
            {
                "prompt": result["prompt"],
                "flash_reasoning": result.get("flash_reasoning", ""),
                "base_prompt": pre_compiled,
                "cost": result.get("cost", 0.001),
            }
        )

    def _api_generate_keyframe(self, body, project=None):
        """Generate keyframe via NBP (async via background thread).

        Body: {"shot_id": "EP001_SH01", "prompt": "the NBP prompt"}
        Gate: status must be previs_approved or keyframe_generated.
        Returns immediately with status "generating". Poll /api/board/{ep} for completion.
        """
        import re
        import threading

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_id = body.get("shot_id")
        prompt = body.get("prompt")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return
        if not prompt:
            self._json_response({"error": "Missing prompt"}, 400)
            return

        ep_match = re.match(r"EP(\d+)_SH(\d+)([A-Za-z]?)", shot_id)
        if not ep_match:
            self._json_response({"error": f"Invalid shot_id format: {shot_id}"}, 400)
            return
        ep_num = int(ep_match.group(1))
        shot_num = int(ep_match.group(2))
        shot_suffix = ep_match.group(3).lower()
        shot_label = f"{shot_num:03d}{shot_suffix}"

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        if not _gen_tracker.try_start(shot_id):
            self._json_response(
                {"error": f"Generation already in progress for {shot_id}"}, 409
            )
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            _gen_tracker.finish(shot_id)
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        # Gate: only generate from previs_approved or keyframe_generated
        force = body.get("force", False)
        if not force and shot["status"] not in (
            "previs_approved",
            "keyframe_generated",
        ):
            _gen_tracker.finish(shot_id)
            self._json_response(
                {
                    "error": f"Cannot generate keyframe from status '{shot['status']}' — need previs_approved or keyframe_generated"
                },
                400,
            )
            return

        # Load shot data for ref building
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
        shot_data = None
        if plan_path.exists():
            try:
                plan = json.loads(plan_path.read_text(encoding="utf-8"))
                shot_data = next(
                    (s for s in plan.get("shots", []) if s.get("shot_id") == shot_id),
                    None,
                )
            except (json.JSONDecodeError, IOError):
                pass

        # Verify tools are importable
        try:
            from tools.generate_previs import _generate_nbp_frame, NBP_COST
            from recoil.pipeline._lib.previz_context import build_keyframe_refs
        except ImportError as e:
            _gen_tracker.finish(shot_id)
            self._json_response({"error": f"Required modules not available: {e}"}, 503)
            return

        # Build ref stack
        ref_images = []
        if shot_data:
            ref_images = build_keyframe_refs(shot_data, project=project)
            print(
                f"  [DEBUG] {shot_id}: build_keyframe_refs returned {len(ref_images)} refs"
            )
            for i, (_, mime, label) in enumerate(ref_images):
                print(f"    ref[{i}]: {mime} — {label}")
        else:
            print(
                f"  [WARN] {shot_id}: shot_data is None — plan_path={plan_path}, exists={plan_path.exists()}"
            )

        # 409 guard: reject if shot is already generating (prevents race condition)
        current_shot = store.get_shot(shot_id)
        if current_shot:
            current_status = current_shot.get("status", "")
            if current_status.endswith("_generating"):
                _gen_tracker.finish(shot_id)
                self._json_response(
                    {
                        "error": f"Shot {shot_id} is already generating (status: {current_status}). Wait for it to complete."
                    },
                    409,
                )
                return

        # Mark as generating
        store.update_shot(shot_id, status="keyframe_generating")

        # Register with task registry
        import uuid as _uuid_kf

        _kf_task_id = _uuid_kf.uuid4().hex[:8]
        with _task_lock:
            _task_registry[_kf_task_id] = {
                "task_id": _kf_task_id,
                "entity_id": shot_id,
                "action": "keyframe",
                "status": "running",
                "started": time.time(),
                "result": None,
                "error": None,
            }

        # Respond immediately
        self._json_response(
            {"status": "generating", "shot_id": shot_id, "task_id": _kf_task_id}, 202
        )

        # Background generation
        _frames_dir = pp["frames_dir"]
        _prompt = prompt
        _ref_images = ref_images
        ep_dir = f"ep_{ep_num:03d}"

        def _bg_generate_keyframe():
            try:
                frames_dir = _frames_dir / ep_dir
                frames_dir.mkdir(parents=True, exist_ok=True)

                gen_result = _generate_nbp_frame(prompt=_prompt, ref_images=_ref_images)

                if not gen_result.get("success"):
                    print(
                        f"  [WARN] Keyframe generate failed for {shot_id}: {gen_result.get('error')}"
                    )
                    try:
                        store.update_shot(
                            shot_id,
                            status="keyframe_mechanical_failed",
                            error_message=gen_result.get("error", "Unknown"),
                        )
                    except InvalidTransitionError:
                        print(
                            f"  [WARN] Shot {shot_id}: could not transition to keyframe_mechanical_failed"
                        )
                    with _task_lock:
                        if _kf_task_id in _task_registry:
                            _task_registry[_kf_task_id]["status"] = "failed"
                            _task_registry[_kf_task_id]["error"] = gen_result.get(
                                "error", "Unknown"
                            )
                    return

                # Determine keyframe take number
                fresh_shot = store.get_shot(shot_id)
                current_takes = fresh_shot.get("takes", []) if fresh_shot else []
                kf_takes = [t for t in current_takes if t.get("layer") == "keyframe"]
                kf_take_num = len(kf_takes) + 1

                output_path = (
                    frames_dir / f"shot_{shot_label}_keyframe_take{kf_take_num}.png"
                )
                output_path.write_bytes(gen_result["image_data"])

                try:
                    from recoil.pipeline._lib.validation import Validator

                    _v = Validator()
                    _g1 = _v.run_gate_1_image(output_path)
                    gate_1 = {
                        "passed": _g1.passed,
                        "details": _g1.details,
                        "cost": _g1.cost,
                    }
                except Exception as _e:
                    gate_1 = {
                        "passed": True,
                        "reason": f"Gate 1 unavailable: {_e}",
                        "cost": 0.0,
                    }
                _rel_path = to_serving_path(output_path, pp)

                take_record = {
                    "take_id": f"kf_take_{kf_take_num}",
                    "take_num": kf_take_num,
                    "layer": "keyframe",
                    "file_path": _rel_path,
                    "prompt": _prompt,
                    "refs_count": len(_ref_images),
                    "cost": NBP_COST,
                    "gate_1": gate_1,
                    "generated_at": time.time(),
                }

                store.update_shot(
                    shot_id,
                    status="keyframe_generated",
                    append_take=take_record,
                    cost_incurred=NBP_COST,
                )

                print(
                    f"  [OK] {shot_id} keyframe take {kf_take_num} generated — ${NBP_COST}"
                )

                with _task_lock:
                    if _kf_task_id in _task_registry:
                        _task_registry[_kf_task_id]["status"] = "complete"
                        _task_registry[_kf_task_id]["result"] = {"shot_id": shot_id}

            except Exception as e:
                print(f"  [ERR] Background keyframe generate for {shot_id}: {e}")
                try:
                    store.update_shot(
                        shot_id,
                        status="keyframe_mechanical_failed",
                        error_message=str(e),
                    )
                except InvalidTransitionError:
                    print(
                        f"  [WARN] Shot {shot_id}: could not transition to keyframe_mechanical_failed"
                    )
                with _task_lock:
                    if _kf_task_id in _task_registry:
                        _task_registry[_kf_task_id]["status"] = "failed"
                        _task_registry[_kf_task_id]["error"] = str(e)
            finally:
                _gen_tracker.finish(shot_id)

        threading.Thread(target=_bg_generate_keyframe, daemon=True).start()

    def _api_lock_keyframe(self, body, project=None):
        """Lock keyframe + set anchor role.

        Body: {"shot_id": "EP001_SH01", "anchor_role": "first_frame"|"last_frame"|"still_only",
               "frame_path": "optional/path/to/override.png"}
        If frame_path is provided, uses that instead of the latest keyframe take.
        Transitions to keyframe_approved, stores anchor_role in gate_results.
        """
        project = project or DEFAULT_PROJECT
        shot_id = body.get("shot_id")
        anchor_role = body.get("anchor_role", "hero_frame")
        frame_position = body.get("frame_position", "middle")
        frame_path_override = body.get("frame_path")

        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return
        if anchor_role not in ("first_frame", "last_frame", "hero_frame", "still_only"):
            self._json_response({"error": f"Invalid anchor_role: {anchor_role}"}, 400)
            return
        if frame_position not in ("first", "middle", "last"):
            self._json_response(
                {"error": f"Invalid frame_position: {frame_position}"}, 400
            )
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        # Workspace-friendly: allow promotion from any status that has content.
        # Only block if there's literally nothing to promote yet.
        if shot["status"] == "previs_pending":
            self._json_response(
                {
                    "error": f"Cannot lock from status '{shot['status']}' — generate previz first"
                },
                400,
            )
            return

        # Find the locked keyframe path for gate_results
        takes = shot.get("takes", [])
        kf_takes = [t for t in takes if t.get("layer") == "keyframe"]

        if frame_path_override:
            kf_path = frame_path_override
        else:
            kf_path = kf_takes[-1].get("file_path", "") if kf_takes else ""

        # Preserve existing extraction frames — only overwrite the anchor slot
        gate_update = {
            "anchor_role": anchor_role,
            "frame_position": frame_position,
        }
        if anchor_role == "hero_frame":
            gate_update["hero_frame"] = kf_path
        elif anchor_role == "first_frame":
            gate_update["first_frame"] = kf_path
        elif anchor_role == "last_frame":
            gate_update["last_frame"] = kf_path

        # Always (re)set to keyframe_approved — workspace-friendly
        try:
            store.update_shot(
                shot_id, status="keyframe_approved", gate_results=gate_update
            )
        except InvalidTransitionError as e:
            self._json_response({"error": str(e), "status": 409}, 409)
            return

        self._json_response(
            {
                "shot_id": shot_id,
                "status": "keyframe_approved",
                "anchor_role": anchor_role,
                "frame_position": frame_position,
            }
        )

    def _api_extract_frame(self, body, project=None):
        """Generate the extrapolated frame (first or last) from locked keyframe.

        Body: {"shot_id": "EP001_SH01", "anchor_role": "first_frame"|"last_frame",
               "prompt_override": "optional", "reference_image": "optional/path/to/ref.png"}
        If reference_image is provided, uses that as the visual reference instead of
        the latest keyframe take. Generates the OTHER frame via NBP. Background thread.
        """
        import re
        import threading

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_id = body.get("shot_id")
        anchor_role = body.get("anchor_role")
        target_frame = body.get("target_frame")
        prompt_override = body.get("prompt_override")
        reference_image_override = body.get("reference_image")

        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        ep_match = re.match(r"EP(\d+)_SH(\d+)([A-Za-z]?)", shot_id)
        if not ep_match:
            self._json_response({"error": f"Invalid shot_id format: {shot_id}"}, 400)
            return
        ep_num = int(ep_match.group(1))
        shot_num = int(ep_match.group(2))
        shot_suffix = ep_match.group(3).lower()
        shot_label = f"{shot_num:03d}{shot_suffix}"

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        # Workspace-friendly: only block if there's literally no content yet
        if shot["status"] == "previs_pending":
            self._json_response(
                {"error": "Cannot extract frames — generate content first"},
                400,
            )
            return

        # Read anchor_role from gate_results if not provided in body
        gate = shot.get("gate_results", {})
        if not anchor_role:
            anchor_role = gate.get("anchor_role", "first_frame")

        if anchor_role not in ("first_frame", "last_frame", "hero_frame"):
            self._json_response({"error": f"Invalid anchor_role: {anchor_role}"}, 400)
            return

        # Find the reference image (keyframe or override)
        if reference_image_override:
            # Path traversal guard — resolve and verify within allowed roots
            ref_rel = reference_image_override
            if ".." in str(ref_rel):
                self._json_response({"error": "Invalid path"}, 400)
                return
            keyframe_abs = None
            for root in (pp["output_dir"], pp["project_dir"], PROJECT_ROOT):
                candidate = (
                    root / ref_rel if not Path(ref_rel).is_absolute() else Path(ref_rel)
                )
                resolved = candidate.resolve()
                if resolved.is_relative_to(root.resolve()) and resolved.is_file():
                    keyframe_abs = resolved
                    break
            if keyframe_abs is None:
                self._json_response(
                    {"error": f"Reference image not found: {ref_rel}"}, 404
                )
                return
        else:
            takes = shot.get("takes", [])
            kf_takes = [t for t in takes if t.get("layer") == "keyframe"]
            if not kf_takes:
                self._json_response({"error": "No keyframe takes found"}, 400)
                return

            # Use the latest keyframe take
            kf_take = kf_takes[-1]
            kf_rel = kf_take.get("file_path", "")
            if kf_rel.startswith("output/"):
                keyframe_abs = pp["output_dir"] / kf_rel[len("output/") :]
            else:
                self._json_response({"error": "Keyframe path not found"}, 400)
                return

            if not keyframe_abs.is_file():
                self._json_response(
                    {"error": f"Keyframe file not found: {keyframe_abs}"}, 404
                )
                return

        # Load plan + bible for extrapolation prompt
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
        shot_data = None
        all_shots = []
        if plan_path.exists():
            try:
                plan = json.loads(plan_path.read_text(encoding="utf-8"))
                all_shots = plan.get("shots", [])
                shot_data = next(
                    (s for s in all_shots if s.get("shot_id") == shot_id), None
                )
            except (json.JSONDecodeError, IOError):
                pass

        bible = None
        if pp["bible_path"].exists():
            try:
                bible = json.loads(pp["bible_path"].read_text(encoding="utf-8"))
            except (json.JSONDecodeError, IOError):
                pass

        # Build extrapolation prompt (for pose description) + edit function
        try:
            from recoil.pipeline._lib.keyframe_context import build_extrapolation_prompt
        except ImportError as e:
            self._json_response({"error": f"Required modules not available: {e}"}, 503)
            return

        # Determine which frame(s) to generate
        if target_frame == "both":
            frames_to_generate = ["first_frame", "last_frame"]
        elif target_frame in ("first_frame", "last_frame"):
            frames_to_generate = [target_frame]
        elif anchor_role == "hero_frame":
            # Hero anchor with no explicit target — default to both
            frames_to_generate = ["first_frame", "last_frame"]
        else:
            # Legacy: endpoint anchor, generate the opposite
            frames_to_generate = [
                "last_frame" if anchor_role == "first_frame" else "first_frame"
            ]

        # Edit mode: hero image is edited directly, no ref stack needed
        print(f"  [DEBUG] {shot_id}: using edit mode (mask-free edit of hero)")

        # Register with task registry
        import uuid as _uuid_ef

        _ef_task_id = _uuid_ef.uuid4().hex[:8]
        with _task_lock:
            _task_registry[_ef_task_id] = {
                "task_id": _ef_task_id,
                "entity_id": shot_id,
                "action": "extract",
                "status": "running",
                "started": time.time(),
                "result": None,
                "error": None,
            }

        # Respond immediately
        self._json_response(
            {"status": "generating", "shot_id": shot_id, "task_id": _ef_task_id}, 202
        )

        # Background generation — sequential for each target frame
        _frames_dir = pp["frames_dir"]
        ep_dir = f"ep_{ep_num:03d}"
        _anchor_role = anchor_role
        _frames_to_gen = frames_to_generate

        def _bg_extract_frames():
            import traceback as _tb
            import sys as _sys

            print(
                f"  [BG] Starting extraction for {shot_id}: {_frames_to_gen}",
                flush=True,
            )
            try:
                from recoil.pipeline._lib.frame_editor import (
                    edit_hero_pose,
                    validate_companion,
                    build_edit_instruction,
                    EDIT_COST,
                )

                frames_dir = _frames_dir / ep_dir
                frames_dir.mkdir(parents=True, exist_ok=True)
                print(
                    f"  [BG] frames_dir: {frames_dir}, anchor_role: {_anchor_role}",
                    flush=True,
                )

                consecutive_failures = 0

                for gen_frame in _frames_to_gen:
                    print(f"  [BG] Generating {gen_frame} via edit mode...", flush=True)

                    # Step 1: Get pose description from Flash text call
                    if not shot_data:
                        print(
                            f"  [WARN] No shot data for {shot_id}, skipping {gen_frame}",
                            flush=True,
                        )
                        continue

                    extrap_result = build_extrapolation_prompt(
                        shot=shot_data,
                        all_shots=all_shots,
                        bible=bible or {},
                        anchor_role=_anchor_role,
                        keyframe_image_path=keyframe_abs,
                        episode=ep_num,
                        project=project,
                        target_frame=gen_frame,
                    )
                    if "error" in extrap_result:
                        print(
                            f"  [WARN] Pose description failed for {shot_id} {gen_frame}: {extrap_result['error']}",
                            flush=True,
                        )
                        consecutive_failures += 1
                        if consecutive_failures >= 2:
                            print(
                                f"  [FALLBACK] {shot_id}: 2 consecutive failures — marking as single-frame-only",
                                flush=True,
                            )
                            store.update_shot(
                                shot_id,
                                gate_results={"extraction_mode": "single_frame"},
                            )
                            break
                        continue

                    pose_text = extrap_result["prompt"]
                    if prompt_override:
                        pose_text += f" Director note: {prompt_override}"

                    # Step 2: Build edit instruction
                    target_type = (
                        "anticipation" if gen_frame == "first_frame" else "aftermath"
                    )
                    edit_instruction = build_edit_instruction(target_type, pose_text)
                    print(
                        f"  [BG] Edit instruction ({len(edit_instruction)} chars)",
                        flush=True,
                    )

                    # Step 3: Edit the hero frame (with retry)
                    max_attempts = 2
                    success = False
                    companion_bytes = None

                    for attempt in range(1, max_attempts + 1):
                        gen_result = edit_hero_pose(keyframe_abs, edit_instruction)

                        if not gen_result.get("success"):
                            print(
                                f"  [WARN] Edit attempt {attempt} failed for {shot_id} {gen_frame}: {gen_result.get('error')}",
                                flush=True,
                            )
                            consecutive_failures += 1
                            if consecutive_failures >= 2:
                                break
                            continue

                        # Step 4: Quality gate
                        hero_bytes = keyframe_abs.read_bytes()
                        companion_bytes = gen_result["image_data"]
                        gate = validate_companion(hero_bytes, companion_bytes)
                        print(
                            f"  [GATE] {shot_id} {gen_frame} attempt {attempt}: correlation={gate['correlation']}, passed={gate['passed']}",
                            flush=True,
                        )

                        if gate["passed"]:
                            success = True
                            consecutive_failures = 0
                            break
                        else:
                            print(
                                f"  [GATE] Failed quality gate (correlation {gate['correlation']} < threshold)",
                                flush=True,
                            )
                            consecutive_failures += 1
                            if consecutive_failures >= 2:
                                break
                            edit_instruction = build_edit_instruction(
                                target_type,
                                pose_text
                                + " Preserve every background detail exactly.",
                            )

                    # Check fallback
                    if consecutive_failures >= 2:
                        print(
                            f"  [FALLBACK] {shot_id}: 2 consecutive failures — marking as single-frame-only",
                            flush=True,
                        )
                        store.update_shot(
                            shot_id, gate_results={"extraction_mode": "single_frame"}
                        )
                        break

                    if not success or companion_bytes is None:
                        continue

                    # Step 5: Save the companion frame — versioned (never overwrite)
                    base_name = f"shot_{shot_label}_{gen_frame}"
                    existing = sorted(frames_dir.glob(f"{base_name}*.png"))
                    if existing:
                        import re as _re

                        max_take = 0
                        for ep in existing:
                            m = _re.search(r"_take(\d+)\.png$", ep.name)
                            if m:
                                max_take = max(max_take, int(m.group(1)))
                            else:
                                max_take = max(max_take, 0)
                        next_take = max_take + 1
                    else:
                        next_take = 1
                    output_path = frames_dir / f"{base_name}_take{next_take}.png"
                    output_path.write_bytes(companion_bytes)

                    _rel_path = to_serving_path(output_path, pp)

                    # Store frame path in gate_results (incremental merge)
                    gate_update = {gen_frame: _rel_path}
                    store.update_shot(
                        shot_id,
                        gate_results=gate_update,
                        cost_incurred=EDIT_COST,
                    )

                    print(
                        f"  [OK] {shot_id} {gen_frame} extracted via edit — ${EDIT_COST} (correlation: {gate['correlation']})",
                        flush=True,
                    )

                with _task_lock:
                    if _ef_task_id in _task_registry:
                        _task_registry[_ef_task_id]["status"] = "complete"
                        _task_registry[_ef_task_id]["result"] = {"shot_id": shot_id}

            except Exception as e:
                print(
                    f"  [ERR] Background frame extraction for {shot_id}: {e}",
                    flush=True,
                )
                _tb.print_exc(file=_sys.stderr)
                _sys.stderr.flush()
                with _task_lock:
                    if _ef_task_id in _task_registry:
                        _task_registry[_ef_task_id]["status"] = "failed"
                        _task_registry[_ef_task_id]["error"] = str(e)

        threading.Thread(target=_bg_extract_frames, daemon=True).start()

    # ── Coverage endpoints ────────────────────────────────────────────

    def _api_coverage_options(self, body, project=None):
        """Return available coverage types for a shot plus existing coverage.

        Body: {"shot_id": "EP001_SH02"}
        Returns: {"shot_id", "current_type", "options": [...], "existing_coverage": [...]}
        """
        import re

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        ep_match = re.match(r"EP(\d+)_SH(\d+)", shot_id)
        if not ep_match:
            self._json_response({"error": f"Invalid shot_id format: {shot_id}"}, 400)
            return
        ep_num = int(ep_match.group(1))
        episode_id = f"EP{ep_num:03d}"

        # Load plan to find the shot data
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
        if not plan_path.exists():
            self._json_response({"error": f"Plan not found for {episode_id}"}, 404)
            return

        try:
            plan = json.loads(plan_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, IOError) as exc:
            self._json_response({"error": f"Failed to load plan: {exc}"}, 500)
            return

        shot_data = next(
            (s for s in plan.get("shots", []) if s.get("shot_id") == shot_id), None
        )
        if shot_data is None:
            self._json_response({"error": f"Shot not found in plan: {shot_id}"}, 404)
            return

        from orchestrator.scene_planner import get_coverage_options

        options = get_coverage_options(shot_data)

        # Check existing coverage shots in store
        store = _get_store(project)
        existing_coverage = []
        if store is not None:
            all_shots = store.get_shots_by_episode(episode_id, include_coverage=True)
            existing_coverage = [
                {
                    "shot_id": s["shot_id"],
                    "coverage_type": s.get("prompt_data", {}).get("shot_type", ""),
                    "status": s["status"],
                }
                for s in all_shots
                if s.get("is_coverage") and s.get("coverage_of") == shot_id
            ]

        current_type = shot_data.get("prompt_data", {}).get("shot_type", "")

        self._json_response(
            {
                "shot_id": shot_id,
                "current_type": current_type,
                "options": options,
                "existing_coverage": existing_coverage,
            }
        )

    def _api_add_coverage(self, body, project=None):
        """Add a single coverage shot for a given angle (manual director pick).

        Body: {"shot_id": "EP001_SH02", "coverage_type": "CU"}
        Returns: {"shot_id": cov_id, "coverage_of", "coverage_type", "coverage_num", "status": "generating"}
        """
        import re

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_id = body.get("shot_id")
        coverage_type = body.get("coverage_type")
        if not shot_id or not coverage_type:
            self._json_response({"error": "Missing shot_id or coverage_type"}, 400)
            return

        ep_match = re.match(r"EP(\d+)_SH(\d+)", shot_id)
        if not ep_match:
            self._json_response({"error": f"Invalid shot_id format: {shot_id}"}, 400)
            return
        ep_num = int(ep_match.group(1))
        episode_id = f"EP{ep_num:03d}"

        # Load plan
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
        if not plan_path.exists():
            self._json_response({"error": f"Plan not found for {episode_id}"}, 404)
            return

        try:
            plan = json.loads(plan_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, IOError) as exc:
            self._json_response({"error": f"Failed to load plan: {exc}"}, 500)
            return

        shot_data = next(
            (s for s in plan.get("shots", []) if s.get("shot_id") == shot_id), None
        )
        if shot_data is None:
            self._json_response({"error": f"Shot not found in plan: {shot_id}"}, 404)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        # Count existing coverage for this shot to determine coverage_num
        all_shots = store.get_shots_by_episode(episode_id, include_coverage=True)
        existing_cov = [
            s
            for s in all_shots
            if s.get("is_coverage") and s.get("coverage_of") == shot_id
        ]
        coverage_num = len(existing_cov) + 1

        from orchestrator.scene_planner import generate_single_coverage

        cov_shot = generate_single_coverage(
            shot_data, coverage_type, coverage_num, episode_id
        )
        cov_id = cov_shot["shot_id"]

        # Insert into execution store
        store.insert_shot(
            {
                "shot_id": cov_id,
                "episode_id": episode_id,
                "pipeline": cov_shot.get("routing_data", {}).get("pipeline", "still"),
                "status": "previs_pending",
                "is_coverage": True,
                "coverage_of": shot_id,
            }
        )

        # Append to plan JSON if not already there
        plan_shot_ids = {s.get("shot_id") for s in plan.get("shots", [])}
        if cov_id not in plan_shot_ids:
            plan.setdefault("shots", []).append(cov_shot)
            try:
                plan_path.write_text(
                    json.dumps(plan, indent=2, ensure_ascii=False), encoding="utf-8"
                )
            except IOError as exc:
                print(f"  [WARN] Failed to write coverage shot to plan: {exc}")

        # Auto-trigger previz generation in background
        def _bg_previz():
            try:
                self._api_generate_previz({"shot_id": cov_id}, project=project)
            except Exception as exc:
                print(f"  [ERR] Background previz for coverage {cov_id}: {exc}")

        threading.Thread(target=_bg_previz, daemon=True).start()

        self._json_response(
            {
                "shot_id": cov_id,
                "coverage_of": shot_id,
                "coverage_type": coverage_type,
                "coverage_num": coverage_num,
                "status": "generating",
            }
        )

    def _api_generate_coverage(self, body, project=None):
        """Generate coverage for all eligible shots in an episode (batch mode).

        Body: {"episode_id": "EP001", "depth": 1}
        Returns: {"episode_id", "depth", "coverage_count", "created", "cost_estimate", "shots": [...]}
        """
        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        episode_id = body.get("episode_id")
        depth = body.get("depth", 1)
        if not episode_id:
            self._json_response({"error": "Missing episode_id"}, 400)
            return

        # Normalize episode_id
        episode_id = episode_id.upper()
        if not episode_id.startswith("EP"):
            episode_id = f"EP{int(episode_id):03d}"

        import re

        ep_match = re.match(r"EP(\d+)", episode_id)
        if not ep_match:
            self._json_response(
                {"error": f"Invalid episode_id format: {episode_id}"}, 400
            )
            return
        ep_num = int(ep_match.group(1))

        # Load plan
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
        if not plan_path.exists():
            self._json_response({"error": f"Plan not found for {episode_id}"}, 404)
            return

        try:
            plan = json.loads(plan_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, IOError) as exc:
            self._json_response({"error": f"Failed to load plan: {exc}"}, 500)
            return

        shots = plan.get("shots", [])
        # Filter out existing coverage shots from the plan
        primary_shots = [s for s in shots if not s.get("is_coverage")]

        from orchestrator.scene_planner import generate_coverage_plan

        coverage_shots = generate_coverage_plan(primary_shots, episode_id, depth)

        if not coverage_shots:
            self._json_response(
                {
                    "episode_id": episode_id,
                    "depth": depth,
                    "coverage_count": 0,
                    "created": [],
                    "cost_estimate": 0,
                    "shots": [],
                }
            )
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        created = []
        plan_shot_ids = {s.get("shot_id") for s in shots}

        for cov_shot in coverage_shots:
            cov_id = cov_shot["shot_id"]

            # Insert into execution store
            store.insert_shot(
                {
                    "shot_id": cov_id,
                    "episode_id": episode_id,
                    "pipeline": cov_shot.get("routing_data", {}).get(
                        "pipeline", "still"
                    ),
                    "status": "previs_pending",
                    "is_coverage": True,
                    "coverage_of": cov_shot.get("coverage_of", ""),
                }
            )

            # Append to plan JSON if not already there
            if cov_id not in plan_shot_ids:
                plan.setdefault("shots", []).append(cov_shot)
                plan_shot_ids.add(cov_id)

            created.append(
                {
                    "shot_id": cov_id,
                    "coverage_of": cov_shot.get("coverage_of", ""),
                    "coverage_type": cov_shot.get("prompt_data", {}).get(
                        "shot_type", ""
                    ),
                }
            )

        # Write updated plan
        try:
            plan_path.write_text(
                json.dumps(plan, indent=2, ensure_ascii=False), encoding="utf-8"
            )
        except IOError as exc:
            print(f"  [WARN] Failed to write coverage shots to plan: {exc}")

        # Estimate cost at $0.039/frame (Flash previz rate)
        cost_estimate = round(len(created) * 0.039, 3)

        self._json_response(
            {
                "episode_id": episode_id,
                "depth": depth,
                "coverage_count": len(created),
                "created": created,
                "cost_estimate": cost_estimate,
                "shots": created,
            }
        )

    def _api_get_coverage_passes(self, ep_str, pp):
        """Return coverage passes with server-side validation warnings."""
        import re

        ep_match = re.match(r"(?:ep_?)?(\d+)", ep_str, re.IGNORECASE)
        if not ep_match:
            self._json_response({"error": f"Invalid episode: {ep_str}"}, 400)
            return

        ep_num = int(ep_match.group(1))
        passes_path = (
            pp["project_dir"]
            / "state"
            / STATE_NAMESPACE
            / "coverage_passes"
            / f"ep_{ep_num:03d}_passes.json"
        )

        if not passes_path.exists():
            self._json_response({"passes": [], "message": "No coverage passes found"})
            return

        try:
            passes_data = json.loads(passes_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError) as e:
            self._json_response({"error": f"Failed to read passes: {e}"}, 500)
            return

        # Run validator on each pass
        try:
            from orchestrator.coverage_validator import validate_pass, Severity
            from orchestrator.coverage_planner import CoveragePass, CoverageSegment

            for p_data in passes_data:
                # Reconstruct CoveragePass for validator
                segments = [
                    CoverageSegment(
                        segment_index=s.get("segment_index", i),
                        source_shot_id=s.get("source_shot_id", ""),
                        shot_type=s.get("shot_type", ""),
                        duration_s=s.get("duration_s", 5),
                        prompt=s.get("prompt", ""),
                        transition=s.get("transition", "smooth"),
                    )
                    for i, s in enumerate(p_data.get("segments", []))
                ]
                cp = CoveragePass(
                    pass_id=p_data.get("pass_id", ""),
                    episode_id=p_data.get("episode_id", ""),
                    shot_range=tuple(p_data.get("shot_range", ["", ""])),
                    camera_side=p_data.get("camera_side", ""),
                    label=p_data.get("label", ""),
                    focus_character=p_data.get("focus_character", ""),
                    pass_type=p_data.get("pass_type", ""),
                    location_id=p_data.get("location_id", ""),
                    segments=segments,
                    element_config=p_data.get("element_config", {}),
                    generation_config=p_data.get("generation_config", {}),
                    status=p_data.get("status", "draft"),
                )
                results = validate_pass(cp)
                p_data["warnings"] = [
                    {
                        "severity": r.severity.value,
                        "check": r.check,
                        "message": r.message,
                    }
                    for r in results
                    if r.severity in (Severity.WARN, Severity.BLOCK)
                ]
        except ImportError:
            # Validator not available — serve without warnings
            for p_data in passes_data:
                p_data["warnings"] = []

        # Orphan check: verify source_shot_ids exist in current plan
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
        if plan_path.exists():
            try:
                plan = json.loads(plan_path.read_text(encoding="utf-8"))
                plan_ids = {s.get("shot_id") for s in plan.get("shots", [])}
                for p_data in passes_data:
                    orphans = [
                        s.get("source_shot_id")
                        for s in p_data.get("segments", [])
                        if s.get("source_shot_id")
                        and s["source_shot_id"] not in plan_ids
                    ]
                    if orphans:
                        p_data.setdefault("warnings", []).append(
                            {
                                "severity": "WARN",
                                "check": "orphan_shot_ids",
                                "message": f"Shot IDs not found in current plan: {', '.join(orphans)}",
                            }
                        )
            except (json.JSONDecodeError, OSError):
                pass

        self._json_response({"passes": passes_data})

    def _api_save_pass(self, body, pp):
        """Save an edited pass back to ep_NNN_passes.json with validation."""
        import re as _re

        pass_id = body.get("pass_id")
        ep_str = body.get("episode")
        pass_data = body.get("pass_data")

        if not pass_id or not ep_str or not pass_data:
            self._json_response(
                {"error": "Missing pass_id, episode, or pass_data"}, 400
            )
            return

        ep_match = _re.match(r"(?:ep_?)?(\d+)", str(ep_str), _re.IGNORECASE)
        if not ep_match:
            self._json_response({"error": f"Invalid episode: {ep_str}"}, 400)
            return
        ep_num = int(ep_match.group(1))
        passes_path = (
            pp["project_dir"]
            / "state"
            / STATE_NAMESPACE
            / "coverage_passes"
            / f"ep_{ep_num:03d}_passes.json"
        )

        if not passes_path.exists():
            self._json_response({"error": "Passes file not found"}, 404)
            return

        all_passes = json.loads(passes_path.read_text(encoding="utf-8"))

        replaced = False
        warnings = []
        has_blockers = False
        for i, p in enumerate(all_passes):
            if p.get("pass_id") == pass_id:
                # Validate before saving
                try:
                    from orchestrator.coverage_validator import validate_pass, Severity
                    from orchestrator.coverage_planner import (
                        CoveragePass,
                        CoverageSegment,
                    )

                    segments = [
                        CoverageSegment(
                            segment_index=s.get("segment_index", j),
                            source_shot_id=s.get("source_shot_id", ""),
                            shot_type=s.get("shot_type", ""),
                            duration_s=s.get("duration_s", 5),
                            prompt=s.get("prompt", ""),
                            transition=s.get("transition", "smooth"),
                        )
                        for j, s in enumerate(pass_data.get("segments", []))
                    ]

                    cp = CoveragePass(
                        pass_id=pass_data["pass_id"],
                        episode_id=pass_data.get("episode_id", ""),
                        shot_range=tuple(pass_data.get("shot_range", ["", ""])),
                        camera_side=pass_data.get("camera_side", ""),
                        label=pass_data.get("label", ""),
                        focus_character=pass_data.get("focus_character", ""),
                        pass_type=pass_data.get("pass_type", ""),
                        location_id=pass_data.get("location_id", ""),
                        segments=segments,
                        element_config=pass_data.get("element_config", {}),
                        generation_config=pass_data.get("generation_config", {}),
                        status=pass_data.get("status", "edited"),
                    )
                    results = validate_pass(cp)
                    warnings = [
                        {
                            "severity": r.severity.value,
                            "check": r.check,
                            "message": r.message,
                        }
                        for r in results
                        if r.severity in (Severity.WARN, Severity.BLOCK)
                    ]
                    has_blockers = any(r.severity == Severity.BLOCK for r in results)
                except ImportError:
                    warnings = []

                all_passes[i] = pass_data
                replaced = True
                break

        if not replaced:
            self._json_response({"error": f"Pass {pass_id} not found in file"}, 404)
            return

        # Atomic write
        tmp_path = passes_path.with_suffix(".tmp")
        tmp_path.write_text(json.dumps(all_passes, indent=2), encoding="utf-8")
        tmp_path.rename(passes_path)

        self._json_response(
            {
                "status": "saved",
                "pass_id": pass_id,
                "warnings": warnings,
                "has_blockers": has_blockers,
            }
        )

    def _api_merge_passes(self, body, pp):
        """Merge two passes into one. Element budget gate enforced."""
        import re as _re

        pass_id_a = body.get("pass_id_a")
        pass_id_b = body.get("pass_id_b")
        ep_str = body.get("episode")

        if not pass_id_a or not pass_id_b or not ep_str:
            self._json_response(
                {"error": "Missing pass_id_a, pass_id_b, or episode"}, 400
            )
            return

        ep_match = _re.match(r"(?:ep_?)?(\d+)", str(ep_str), _re.IGNORECASE)
        if not ep_match:
            self._json_response({"error": f"Invalid episode: {ep_str}"}, 400)
            return
        ep_num = int(ep_match.group(1))
        passes_path = (
            pp["project_dir"]
            / "state"
            / STATE_NAMESPACE
            / "coverage_passes"
            / f"ep_{ep_num:03d}_passes.json"
        )

        if not passes_path.exists():
            self._json_response({"error": "Passes file not found"}, 404)
            return

        all_passes = json.loads(passes_path.read_text(encoding="utf-8"))

        # Find passes by index to ensure chronological merge order
        pa_idx = next(
            (i for i, p in enumerate(all_passes) if p["pass_id"] == pass_id_a), None
        )
        pb_idx = next(
            (i for i, p in enumerate(all_passes) if p["pass_id"] == pass_id_b), None
        )
        if pa_idx is None or pb_idx is None:
            self._json_response({"error": "One or both passes not found"}, 404)
            return

        # Ensure chronological order (earlier pass absorbs later pass)
        if pa_idx > pb_idx:
            pa_idx, pb_idx = pb_idx, pa_idx
            pass_id_a, pass_id_b = pass_id_b, pass_id_a

        pa = all_passes[pa_idx]
        pb = all_passes[pb_idx]

        # ENV passes: require same location
        if pa.get("pass_type") == "env" and pa.get("location_id") != pb.get(
            "location_id"
        ):
            self._json_response(
                {"error": "Cannot merge ENV passes from different locations"}, 400
            )
            return

        # Element budget check
        from orchestrator.coverage_planner import MAX_CHARS_I2V, MAX_CHARS_T2V

        chars_a = {
            e.get("char_id", "")
            for e in pa.get("element_config", {}).get("character_elements", [])
            if e.get("char_id")
        }
        chars_b = {
            e.get("char_id", "")
            for e in pb.get("element_config", {}).get("character_elements", [])
            if e.get("char_id")
        }
        combined_chars = chars_a | chars_b
        mode = pa.get("generation_config", {}).get("mode", "i2v")
        max_chars = MAX_CHARS_I2V if mode == "i2v" else MAX_CHARS_T2V
        if len(combined_chars) > max_chars:
            self._json_response(
                {
                    "error": f"Cannot merge: {len(combined_chars)} combined characters exceeds {max_chars}-char {mode} limit"
                },
                400,
            )
            return

        # Merge: concatenate segments, re-index
        merged_segs = pa.get("segments", []) + pb.get("segments", [])
        for idx, s in enumerate(merged_segs):
            s["segment_index"] = idx

        pa["segments"] = merged_segs
        pa["duration_s"] = sum(s.get("duration_s", 5) for s in merged_segs)
        pa["character_count"] = len(combined_chars)
        pa["shot_range"] = [
            merged_segs[0]["source_shot_id"],
            merged_segs[-1]["source_shot_id"],
        ]
        pa.setdefault("element_config", {})["character_elements"] = [
            {"char_id": c} for c in sorted(combined_chars)
        ]
        pa["status"] = "edited"

        # Determine focus_character by segment frequency
        if combined_chars:
            char_counts = {}
            for s in merged_segs:
                prompt_lower = s.get("prompt", "").lower()
                for c in combined_chars:
                    if c.lower() in prompt_lower:
                        char_counts[c] = char_counts.get(c, 0) + 1
            if char_counts:
                pa["focus_character"] = max(char_counts, key=char_counts.get)

        # Handle cross-location merge labeling
        if pa.get("location_id") != pb.get("location_id"):
            pa["location_id"] = (
                f"{pa.get('location_id', '')}+{pb.get('location_id', '')}"
            )

        # Remove pass B
        all_passes = [p for p in all_passes if p["pass_id"] != pass_id_b]

        # Validate merged result
        warnings = []
        try:
            from orchestrator.coverage_validator import validate_pass, Severity
            from orchestrator.coverage_planner import CoveragePass, CoverageSegment

            segs = [
                CoverageSegment(
                    segment_index=s.get("segment_index", i),
                    source_shot_id=s.get("source_shot_id", ""),
                    shot_type=s.get("shot_type", ""),
                    duration_s=s.get("duration_s", 5),
                    prompt=s.get("prompt", ""),
                    transition=s.get("transition", "smooth"),
                )
                for i, s in enumerate(pa.get("segments", []))
            ]
            cp = CoveragePass(
                pass_id=pa["pass_id"],
                episode_id=pa.get("episode_id", ""),
                shot_range=tuple(pa.get("shot_range", ["", ""])),
                camera_side=pa.get("camera_side", ""),
                label=pa.get("label", ""),
                focus_character=pa.get("focus_character", ""),
                pass_type=pa.get("pass_type", ""),
                location_id=pa.get("location_id", ""),
                segments=segs,
                element_config=pa.get("element_config", {}),
                generation_config=pa.get("generation_config", {}),
                status="edited",
            )
            results = validate_pass(cp)
            warnings = [
                {"severity": r.severity.value, "check": r.check, "message": r.message}
                for r in results
                if r.severity in (Severity.WARN, Severity.BLOCK)
            ]
        except ImportError:
            pass
        pa["warnings"] = warnings

        # Atomic write
        tmp = passes_path.with_suffix(".tmp")
        tmp.write_text(json.dumps(all_passes, indent=2), encoding="utf-8")
        tmp.rename(passes_path)

        self._json_response(
            {"status": "merged", "surviving_pass": pa, "deleted_pass_id": pass_id_b}
        )

    def _api_split_pass(self, body, pp):
        """Split a pass at a segment boundary."""
        import re as _re

        pass_id = body.get("pass_id")
        split_after = body.get("split_after_index")  # 0-based segment index
        ep_str = body.get("episode")

        if pass_id is None or split_after is None or not ep_str:
            self._json_response(
                {"error": "Missing pass_id, split_after_index, or episode"}, 400
            )
            return

        split_after = int(split_after)
        ep_match = _re.match(r"(?:ep_?)?(\d+)", str(ep_str), _re.IGNORECASE)
        if not ep_match:
            self._json_response({"error": f"Invalid episode: {ep_str}"}, 400)
            return
        ep_num = int(ep_match.group(1))
        passes_path = (
            pp["project_dir"]
            / "state"
            / STATE_NAMESPACE
            / "coverage_passes"
            / f"ep_{ep_num:03d}_passes.json"
        )

        if not passes_path.exists():
            self._json_response({"error": "Passes file not found"}, 404)
            return

        all_passes = json.loads(passes_path.read_text(encoding="utf-8"))
        target_idx = next(
            (i for i, p in enumerate(all_passes) if p["pass_id"] == pass_id), None
        )
        if target_idx is None:
            self._json_response({"error": "Pass not found"}, 404)
            return

        target = all_passes[target_idx]
        segs = target.get("segments", [])

        if split_after < 0 or split_after >= len(segs) - 1:
            self._json_response(
                {
                    "error": f"Invalid split index {split_after} for {len(segs)} segments"
                },
                400,
            )
            return

        # Find next available pass number
        max_num = 0
        for p in all_passes:
            m = _re.search(r"PASS_(\d+)", p.get("pass_id", ""))
            if m:
                max_num = max(max_num, int(m.group(1)))

        # Split segments
        segs_a = segs[: split_after + 1]
        segs_b = segs[split_after + 1 :]
        for i, s in enumerate(segs_a):
            s["segment_index"] = i
        for i, s in enumerate(segs_b):
            s["segment_index"] = i

        # Re-derive element_config per split half from plan data
        plan_path = (
            pp.get("plans_dir", pp["project_dir"] / "state" / "plans")
            / f"ep_{ep_num:03d}_plan.json"
        )
        plan_data = {}
        if plan_path.exists():
            try:
                plan_json = json.loads(plan_path.read_text(encoding="utf-8"))
                for shot in plan_json.get("shots", []):
                    plan_data[shot.get("shot_id")] = shot
            except (json.JSONDecodeError, OSError):
                pass

        def _derive_chars(segments):
            chars = set()
            for s in segments:
                sid = s.get("source_shot_id", "")
                if sid in plan_data:
                    for c in plan_data[sid].get("asset_data", {}).get("characters", []):
                        cid = c.get("char_id", "") if isinstance(c, dict) else str(c)
                        if cid:
                            chars.add(cid.upper())
            return chars

        chars_a = _derive_chars(segs_a)
        chars_b = _derive_chars(segs_b)

        # Update pass A (keeps original pass_id)
        target["segments"] = segs_a
        target["duration_s"] = sum(s.get("duration_s", 5) for s in segs_a)
        target["shot_range"] = [
            segs_a[0]["source_shot_id"],
            segs_a[-1]["source_shot_id"],
        ]
        target["status"] = "edited"
        target["character_count"] = len(chars_a)
        if chars_a:
            target["element_config"]["character_elements"] = [
                {"char_id": c} for c in sorted(chars_a)
            ]
            # Re-derive focus_character for pass A
            char_counts_a = {}
            for s in segs_a:
                prompt_lower = s.get("prompt", "").lower()
                for c in chars_a:
                    if c.lower() in prompt_lower:
                        char_counts_a[c] = char_counts_a.get(c, 0) + 1
            if char_counts_a:
                target["focus_character"] = max(char_counts_a, key=char_counts_a.get)

        # Create pass B
        ep_id = target.get("episode_id", f"EP{ep_num:03d}")
        side = target.get("camera_side", "N")
        # Re-derive focus_character for pass B
        focus = ""
        if chars_b:
            char_counts_b = {}
            for s in segs_b:
                prompt_lower = s.get("prompt", "").lower()
                for c in chars_b:
                    if c.lower() in prompt_lower:
                        char_counts_b[c] = char_counts_b.get(c, 0) + 1
            if char_counts_b:
                focus = max(char_counts_b, key=char_counts_b.get)
        if not focus:
            focus = target.get("focus_character", "")
        suffix = focus if focus else "ENV"
        new_id = f"{ep_id}_PASS_{max_num + 1:03d}_{side}_{suffix}"

        # Resolve start frame for new pass
        start_frame = ""
        first_shot_id = segs_b[0].get("source_shot_id", "")
        if first_shot_id in plan_data:
            sf = (
                plan_data[first_shot_id]
                .get("routing_data", {})
                .get("start_frame_path", "")
            )
            if sf:
                start_frame = sf

        pass_b = {
            "pass_id": new_id,
            "episode_id": ep_id,
            "shot_range": [segs_b[0]["source_shot_id"], segs_b[-1]["source_shot_id"]],
            "camera_side": side,
            "label": target.get("label", ""),
            "focus_character": focus,
            "pass_type": target.get("pass_type", "character"),
            "location_id": target.get("location_id", ""),
            "duration_s": sum(s.get("duration_s", 5) for s in segs_b),
            "character_count": len(chars_b),
            "segments": segs_b,
            "element_config": {
                "character_elements": [{"char_id": c} for c in sorted(chars_b)]
            },
            "generation_config": {
                **target.get("generation_config", {}),
                "start_frame_path": start_frame,
            },
            "status": "edited",
        }

        # Insert pass B right after pass A
        all_passes.insert(target_idx + 1, pass_b)

        # Atomic write
        tmp = passes_path.with_suffix(".tmp")
        tmp.write_text(json.dumps(all_passes, indent=2), encoding="utf-8")
        tmp.rename(passes_path)

        self._json_response({"status": "split", "pass_a": target, "pass_b": pass_b})

    def _api_create_pass(self, body, pp):
        """Create a new pass from a list of shot IDs."""
        import re as _re

        shot_ids = body.get("shot_ids", [])
        ep_str = body.get("episode")

        if not shot_ids or not ep_str:
            self._json_response({"error": "Missing shot_ids or episode"}, 400)
            return

        ep_match = _re.match(r"(?:ep_?)?(\d+)", str(ep_str), _re.IGNORECASE)
        if not ep_match:
            self._json_response({"error": f"Invalid episode: {ep_str}"}, 400)
            return
        ep_num = int(ep_match.group(1))

        passes_path = (
            pp["project_dir"]
            / "state"
            / STATE_NAMESPACE
            / "coverage_passes"
            / f"ep_{ep_num:03d}_passes.json"
        )
        plan_path = (
            pp.get("plans_dir", pp["project_dir"] / "state" / "plans")
            / f"ep_{ep_num:03d}_plan.json"
        )

        # Load plan to get shot data
        if not plan_path.exists():
            self._json_response({"error": "Plan file not found"}, 404)
            return

        plan_json = json.loads(plan_path.read_text(encoding="utf-8"))
        plan_shots = {
            s["shot_id"]: s for s in plan_json.get("shots", []) if "shot_id" in s
        }

        # Load existing passes to find next pass number
        all_passes = []
        if passes_path.exists():
            all_passes = json.loads(passes_path.read_text(encoding="utf-8"))

        max_num = 0
        for p in all_passes:
            m = _re.search(r"PASS_(\d+)", p.get("pass_id", ""))
            if m:
                max_num = max(max_num, int(m.group(1)))

        # Build segments from plan data
        segments = []
        chars = set()
        for i, sid in enumerate(shot_ids):
            ps = plan_shots.get(sid, {})
            shot_type = ps.get("prompt_data", {}).get("shot_type", "MS")
            skeleton = ps.get("prompt_data", {}).get("prompt_skeleton", {})
            if isinstance(skeleton, dict):
                prompt_parts = []
                if skeleton.get("subject_line"):
                    prompt_parts.append(f"{shot_type}: {skeleton['subject_line']}")
                if skeleton.get("action_line"):
                    prompt_parts.append(skeleton["action_line"])
                if skeleton.get("emotion_line"):
                    prompt_parts.append(f"[{skeleton['emotion_line']}]")
                prompt = (
                    " ".join(prompt_parts)
                    if prompt_parts
                    else ps.get("source_text", "")
                )
            else:
                prompt = skeleton if skeleton else ps.get("source_text", "")
            raw_dur = ps.get("routing_data", {}).get("target_editorial_duration_s")
            if raw_dur is None:
                raw_dur = ps.get("prompt_data", {}).get("duration_s", 5)
            min_d, max_d = get_segment_duration_bounds("kling-o3")
            duration = round(max(min_d, min(max_d, raw_dur or 5)))
            source_text = ps.get("source_text", "")
            segments.append(
                {
                    "segment_index": i,
                    "source_shot_id": sid,
                    "shot_type": shot_type,
                    "duration_s": duration,
                    "prompt": prompt,
                    "transition": "smooth",
                    "source_text": source_text,
                }
            )
            for c in ps.get("asset_data", {}).get("characters", []):
                cid = c.get("char_id", "") if isinstance(c, dict) else str(c)
                if cid:
                    chars.add(cid.upper())

        ep_id = f"EP{ep_num:03d}"
        focus = (
            max(
                chars,
                key=lambda c: sum(
                    1 for s in segments if c.lower() in s["prompt"].lower()
                ),
                default="",
            )
            if chars
            else ""
        )
        suffix = focus if focus else "CUSTOM"
        new_id = f"{ep_id}_PASS_{max_num + 1:03d}_N_{suffix}"

        new_pass = {
            "pass_id": new_id,
            "episode_id": ep_id,
            "shot_range": [shot_ids[0], shot_ids[-1]],
            "camera_side": "N",
            "label": f"{focus} custom pass" if focus else "Custom pass",
            "focus_character": focus,
            "pass_type": "character" if chars else "env",
            "location_id": "",
            "duration_s": sum(s["duration_s"] for s in segments),
            "character_count": len(chars),
            "segments": segments,
            "element_config": {
                "character_elements": [{"char_id": c} for c in sorted(chars)]
            },
            "generation_config": {
                "model": "kling-o3",
                "mode": "i2v",
                "cfg_scale": 0.55,
                "start_frame_path": "",
            },
            "status": "edited",
        }

        all_passes.append(new_pass)

        # Ensure directory exists
        passes_path.parent.mkdir(parents=True, exist_ok=True)

        # Atomic write
        tmp = passes_path.with_suffix(".tmp")
        tmp.write_text(json.dumps(all_passes, indent=2), encoding="utf-8")
        tmp.rename(passes_path)

        self._json_response({"status": "created", "pass": new_pass})

    def _api_reset_pass_to_plan(self, body, pp):
        """Reset a pass's prompts and durations to plan data. Sets status back to draft."""
        import re as _re

        pass_id = body.get("pass_id")
        ep_str = body.get("episode")

        if not pass_id or not ep_str:
            self._json_response({"error": "Missing pass_id or episode"}, 400)
            return

        ep_match = _re.match(r"(?:ep_?)?(\d+)", str(ep_str), _re.IGNORECASE)
        if not ep_match:
            self._json_response({"error": f"Invalid episode: {ep_str}"}, 400)
            return
        ep_num = int(ep_match.group(1))

        passes_path = (
            pp["project_dir"]
            / "state"
            / STATE_NAMESPACE
            / "coverage_passes"
            / f"ep_{ep_num:03d}_passes.json"
        )
        plan_path = (
            pp.get("plans_dir", pp["project_dir"] / "state" / "plans")
            / f"ep_{ep_num:03d}_plan.json"
        )

        if not passes_path.exists():
            self._json_response({"error": "Passes file not found"}, 404)
            return

        all_passes = json.loads(passes_path.read_text(encoding="utf-8"))
        target = next((p for p in all_passes if p["pass_id"] == pass_id), None)
        if not target:
            self._json_response({"error": "Pass not found"}, 404)
            return

        # Load plan
        plan_shots = {}
        if plan_path.exists():
            try:
                plan_json = json.loads(plan_path.read_text(encoding="utf-8"))
                for shot in plan_json.get("shots", []):
                    plan_shots[shot.get("shot_id")] = shot
            except (json.JSONDecodeError, OSError):
                pass

        # Reset each segment to plan data
        reset_warnings = []
        for seg in target.get("segments", []):
            sid = seg.get("source_shot_id", "")
            plan_shot = plan_shots.get(sid)
            if plan_shot:
                skeleton = plan_shot.get("prompt_data", {}).get(
                    "prompt_skeleton", seg["prompt"]
                )
                if isinstance(skeleton, dict):
                    st = plan_shot.get("prompt_data", {}).get(
                        "shot_type", seg.get("shot_type", "MS")
                    )
                    prompt_parts = []
                    if skeleton.get("subject_line"):
                        prompt_parts.append(f"{st}: {skeleton['subject_line']}")
                    if skeleton.get("action_line"):
                        prompt_parts.append(skeleton["action_line"])
                    if skeleton.get("emotion_line"):
                        prompt_parts.append(f"[{skeleton['emotion_line']}]")
                    seg["prompt"] = (
                        " ".join(prompt_parts)
                        if prompt_parts
                        else seg.get("prompt", "")
                    )
                else:
                    seg["prompt"] = skeleton if skeleton else seg.get("prompt", "")
                raw_dur = plan_shot.get("routing_data", {}).get(
                    "target_editorial_duration_s"
                )
                if raw_dur is None:
                    raw_dur = plan_shot.get("prompt_data", {}).get("duration_s", 5)
                pass_model = target.get("generation_config", {}).get(
                    "model", "seeddance-2.0"
                )
                min_d, max_d = get_segment_duration_bounds(pass_model)
                seg["duration_s"] = round(max(min_d, min(max_d, raw_dur or 5)))
                seg["source_text"] = plan_shot.get("source_text", "")
                seg["shot_type"] = plan_shot.get("prompt_data", {}).get(
                    "shot_type", seg.get("shot_type", "MS")
                )
            else:
                reset_warnings.append(
                    f"Shot {sid} missing from plan — kept existing prompt"
                )

        target["status"] = "draft"
        target["duration_s"] = sum(
            s.get("duration_s", 5) for s in target.get("segments", [])
        )
        target.pop("_orphaned", None)

        # Atomic write
        tmp = passes_path.with_suffix(".tmp")
        tmp.write_text(json.dumps(all_passes, indent=2), encoding="utf-8")
        tmp.rename(passes_path)

        self._json_response(
            {"status": "reset", "pass": target, "reset_warnings": reset_warnings}
        )

    def _api_coverage_prompts(self, shot_id, project=None):
        """Return enriched coverage prompts for a shot.

        GET /api/coverage-prompts/{shot_id}
        Returns: {prompts: [{framing, prompt, duration}], shot_id}
        Includes subject, action, environment, emotion, aesthetic tone.
        """
        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "Store not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        # Load plan for full prompt_data
        import re

        ep_match = re.match(r"EP(\d+)", shot_id)
        ep_num = int(ep_match.group(1)) if ep_match else 1
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"

        plan_shot = shot  # fallback
        if plan_path.exists():
            try:
                plan = json.loads(plan_path.read_text(encoding="utf-8"))
                found = next(
                    (s for s in plan.get("shots", []) if s.get("shot_id") == shot_id),
                    None,
                )
                if found:
                    plan_shot = found
            except (json.JSONDecodeError, IOError):
                pass

        # Load Bible for aesthetic directives
        bible = {}
        bible_path = pp.get("bible_path")
        if bible_path and bible_path.exists():
            try:
                bible = json.loads(bible_path.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, IOError):
                pass

        from recoil.pipeline._lib.prompt_engine import build_coverage_prompts

        prompts = build_coverage_prompts(plan_shot, bible=bible)

        self._json_response({"shot_id": shot_id, "prompts": prompts})

    def _api_generate_coverage_multi(self, body, project=None):
        """Generate coverage variants (WS/MS/CU) via multi-prompt I2V.

        Body: {
            "shot_id": "EP001_SH02",
            "model": "kling-v3",
            "prompts": [
                {"framing": "WS", "text": "Wide shot...", "duration": 5},
                {"framing": "MS", "text": "Medium shot...", "duration": 5},
                {"framing": "CU", "text": "Close-up...", "duration": 5}
            ]
        }
        Returns immediately. Poll /api/board/{ep} for status updates.
        """
        import re

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        prompts = body.get("prompts", [])
        if not prompts or len(prompts) < 2:
            self._json_response({"error": "Need at least 2 prompts for coverage"}, 400)
            return

        video_model = body.get("model", "kling-v3")
        video_model = LEGACY_MODEL_MAP.get(video_model, video_model)

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        # Require at least previs_approved status
        force = body.get("force", False)
        allowed_for_render = (
            "previs_approved",
            "keyframe_approved",
            "video_pending",
            "video_complete",
        )
        if not force and shot["status"] not in allowed_for_render:
            self._json_response(
                {
                    "error": f"Cannot generate coverage from status '{shot['status']}' — need at least previs_approved"
                },
                400,
            )
            return

        gate = shot.get("gate_results", {})
        first_frame = gate.get("first_frame")
        hero_frame = gate.get("hero_frame")

        # Path traversal guard — resolve and verify within allowed roots
        def _safe_resolve_frame(rel_path):
            if not rel_path:
                return None
            if ".." in str(rel_path):
                return None
            for root in (pp["project_dir"], PROJECT_ROOT):
                p = (
                    root / rel_path
                    if not Path(rel_path).is_absolute()
                    else Path(rel_path)
                )
                resolved = p.resolve()
                if resolved.is_relative_to(root.resolve()) and resolved.exists():
                    return resolved
            return None

        # Use hero_frame as start, fallback to first_frame
        start_frame_path = _safe_resolve_frame(hero_frame)
        if not start_frame_path:
            start_frame_path = _safe_resolve_frame(first_frame)

        if not start_frame_path:
            self._json_response(
                {"error": "No frame images found for coverage generation"}, 400
            )
            return

        # ── Duplicate check via GenerationTracker ──
        gen_key = f"coverage_{shot_id}"
        if not _gen_tracker.try_start(gen_key):
            self._json_response(
                {"error": "Coverage generation already in progress for this shot"}, 409
            )
            return

        # Build multi_prompt_sequence from prompts
        multi_prompt_sequence = []
        for i, p in enumerate(prompts):
            multi_prompt_sequence.append(
                {
                    "index": i + 1,
                    "prompt": p.get("text", ""),
                    "duration": p.get("duration", 5),
                }
            )

        # Build batch: same shot dict repeated N times with _api_duration set
        batch = []
        for p in prompts:
            shot_copy = dict(shot)
            shot_copy["_api_duration"] = p.get("duration", 5)
            batch.append(shot_copy)

        # Extract episode info for output path
        ep_match = re.match(r"EP(\d+)_SH(\d+)([A-Za-z]?)", shot_id)
        ep_num = int(ep_match.group(1)) if ep_match else 1

        self._json_response(
            {
                "shot_id": shot_id,
                "status": "coverage_submitted",
                "message": f"Submitting {len(prompts)} coverage variants for {shot_id}...",
                "model": video_model,
                "prompts": len(prompts),
                "start_frame": str(start_frame_path),
            }
        )

        _prompts = prompts
        _batch = batch
        _sequence = multi_prompt_sequence
        _start_path = start_frame_path
        _video_model = video_model
        _pp = pp

        def _run_coverage():
            try:
                from recoil.execution.step_runner import StepRunner
                from recoil.execution.step_types import ProjectPaths

                paths = ProjectPaths.for_episode(project, ep_num)
                runner = StepRunner(store=store, paths=paths)

                results = runner.execute_multi_shot(
                    batch=_batch,
                    multi_prompt_sequence=_sequence,
                    model=_video_model,
                    start_frame=_start_path,
                    aspect_ratio=_get_project_aspect_ratio(project),
                )

                # Tag takes with framing metadata
                for i, result in enumerate(results):
                    if result.success and i < len(_prompts):
                        framing = _prompts[i].get("framing", "")
                        try:
                            updated_shot = store.get_shot(shot_id)
                            if updated_shot:
                                takes = updated_shot.get("takes", [])
                                if takes:
                                    # Tag the most recent take(s) with coverage metadata
                                    take = takes[-1]
                                    take["framing"] = framing
                                    take["is_coverage"] = True
                                    store.update_shot(shot_id, takes=takes)
                        except Exception:
                            pass

                ok_count = sum(1 for r in results if r.success)
                total_cost = sum(r.cost_usd for r in results)
                print(
                    f"  [OK] Coverage for {shot_id}: {ok_count}/{len(results)} variants — ${total_cost:.3f}"
                )

            except Exception as e:
                print(f"  [ERR] Background coverage generate for {shot_id}: {e}")
                import traceback

                traceback.print_exc()
                try:
                    store.update_shot(
                        shot_id,
                        status="video_failed",
                        error_message=str(e),
                    )
                except Exception:
                    pass
            finally:
                _gen_tracker.finish(gen_key)

        _thread_pool.submit(_run_coverage)

    def _api_generate_sequence(self, body, project=None):
        """Generate a multi-shot sequence across multiple shots via multi-prompt I2V.

        Body: {
            "shot_ids": ["EP001_SH02", "EP001_SH02A", "EP001_SH03"],
            "model": "kling-o3",
            "use_elements": true,
            "element_char_ids": ["KIT", "VAREK"]
        }
        Returns immediately. Poll /api/board/{ep} for status updates.
        """
        import re

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_ids = body.get("shot_ids", [])
        pass_id = body.get("pass_id")

        # Single-segment passes are valid when pass_id is present
        min_shots = 1 if pass_id else 2
        if not shot_ids or len(shot_ids) < min_shots:
            self._json_response(
                {"error": f"Need at least {min_shots} shot_ids for a sequence"}, 400
            )
            return

        video_model = body.get("model", "kling-v3")
        video_model = LEGACY_MODEL_MAP.get(video_model, video_model)
        use_elements = body.get("use_elements", False)
        element_char_ids = body.get("element_char_ids", [])
        prompt_overrides = body.get("prompt_overrides") or {}
        duration_overrides = body.get("duration_overrides") or {}

        # ── Override precedence ──────────────────────────────────────────
        # Request fields are authoritative when explicitly set.
        # Pass provides structure (prompts, segments) and DEFAULTS for
        # generation settings (model, elements, cfg_scale).  The request
        # can override any default.
        #
        # Key-presence detection: "use_elements" in body means the
        # frontend (or API caller) explicitly chose a value.  When the
        # key is absent, the pass default applies.  Same for "model".
        # ─────────────────────────────────────────────────────────────────
        _frontend_set_elements = "use_elements" in body
        _frontend_set_model = "model" in body
        _elements_source = "request" if _frontend_set_elements else "default"

        # Load pass config if pass_id provided
        pass_config = None
        if pass_id:
            ep_match_p = re.match(r"EP(\d+)", pass_id)
            if ep_match_p:
                ep_num_p = int(ep_match_p.group(1))
                passes_path = (
                    pp["project_dir"]
                    / "state"
                    / STATE_NAMESPACE
                    / "coverage_passes"
                    / f"ep_{ep_num_p:03d}_passes.json"
                )
                if passes_path.exists():
                    try:
                        all_passes = json.loads(passes_path.read_text(encoding="utf-8"))
                        pass_config = next(
                            (p for p in all_passes if p.get("pass_id") == pass_id), None
                        )
                    except (json.JSONDecodeError, OSError):
                        pass

            if pass_config:
                gen_cfg = pass_config.get("generation_config", {})

                # Model: request takes precedence when explicitly set,
                # otherwise fall back to pass config
                if not _frontend_set_model and gen_cfg.get("model"):
                    video_model = gen_cfg["model"]
                    print(f"  [PASS] Model from pass: {video_model}")
                elif _frontend_set_model:
                    print(
                        f"  [PASS] Model from request (overrides pass): {video_model}"
                    )

                if gen_cfg.get("cfg_scale") is not None:
                    body["cfg_scale"] = gen_cfg["cfg_scale"]

                # Elements: load chars from pass ONLY IF the frontend
                # didn't explicitly disable elements AND didn't already
                # provide char IDs.
                pass_chars = pass_config.get("element_config", {}).get(
                    "character_elements", []
                )
                _frontend_disabled_elements = (
                    _frontend_set_elements and not use_elements
                )

                if (
                    not element_char_ids
                    and pass_chars
                    and not _frontend_disabled_elements
                ):
                    element_char_ids = [
                        e.get("char_id", e) if isinstance(e, dict) else e
                        for e in pass_chars
                    ]
                    use_elements = bool(element_char_ids)
                    _elements_source = "pass"
                    print(f"  [PASS] Elements from pass: {element_char_ids}")
                elif _frontend_disabled_elements and pass_chars:
                    _elements_source = "disabled_by_request"
                    print(
                        f"  [PASS] Elements disabled by request (pass had: {[e.get('char_id', e) if isinstance(e, dict) else e for e in pass_chars]})"
                    )

        # Elements require O3 — auto-upgrade kling-v3 to kling-o3
        if use_elements and element_char_ids and video_model == "kling-v3":
            video_model = "kling-o3"

        # Element gate: only O3 Kling model supports elements
        _ELEMENT_MODELS = {"kling-o3"}
        if use_elements and video_model not in _ELEMENT_MODELS:
            print(f"  [WARN] Model {video_model} does not support elements — disabling")
            use_elements = False
            element_char_ids = []
            _elements_source = "disabled_model_incompatible"

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        # Validate all shot_ids exist
        shots_data = []
        for sid in shot_ids:
            shot = store.get_shot(sid)
            if shot is None:
                self._json_response({"error": f"Shot not found: {sid}"}, 404)
                return
            shots_data.append(shot)

        # Get start frame from first shot's hero_frame
        first_shot = shots_data[0]
        gate = first_shot.get("gate_results", {})
        first_frame = gate.get("first_frame")
        hero_frame = gate.get("hero_frame")

        # Path traversal guard
        def _safe_resolve_frame(rel_path):
            if not rel_path:
                return None
            if ".." in str(rel_path):
                return None
            for root in (pp["project_dir"], PROJECT_ROOT):
                p = (
                    root / rel_path
                    if not Path(rel_path).is_absolute()
                    else Path(rel_path)
                )
                resolved = p.resolve()
                if resolved.is_relative_to(root.resolve()) and resolved.exists():
                    return resolved
            return None

        start_frame_path = _safe_resolve_frame(hero_frame)
        if not start_frame_path:
            start_frame_path = _safe_resolve_frame(first_frame)

        # Extract episode for plan loading
        ep_match = re.match(r"EP(\d+)", shot_ids[0])
        ep_num = int(ep_match.group(1)) if ep_match else 1

        # Load plan to get shot data for prompt building
        plan_path = pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
        plan_shots = []
        if plan_path.exists():
            try:
                plan = json.loads(plan_path.read_text(encoding="utf-8"))
                plan_shots_map = {s.get("shot_id"): s for s in plan.get("shots", [])}
                for sid in shot_ids:
                    ps = plan_shots_map.get(sid)
                    if ps:
                        raw_dur = (
                            ps.get("duration")
                            or ps.get("routing_data", {}).get(
                                "target_editorial_duration_s"
                            )
                            or 5
                        )
                        # Kling multi-shot requires minimum 3s per shot
                        ps["_api_duration"] = max(int(raw_dur), 3)
                        plan_shots.append(ps)
                    else:
                        # Fallback: build minimal plan shot from store data
                        sd = store.get_shot(sid)
                        plan_shots.append(
                            {
                                "shot_id": sid,
                                "_api_duration": 5,
                                "prompt_data": {
                                    "prompt_skeleton": {
                                        "action_line": "smooth cinematic camera movement"
                                    }
                                },
                            }
                        )
            except (json.JSONDecodeError, IOError):
                pass

        if not plan_shots:
            self._json_response({"error": "Could not load shot data from plan"}, 400)
            return

        # Build elements payload first (characters + location) so we know
        # element count for @Element injection into prompts
        elements_payload = None
        has_location_element = False
        total_elements = 0
        if use_elements and element_char_ids:
            try:
                from recoil.pipeline._lib.elements import (
                    ElementManager,
                    extract_batch_location,
                )

                batch_location_id = extract_batch_location(plan_shots)
                elements_payload, has_location_element, total_elements = (
                    ElementManager.build_elements_with_info(
                        element_char_ids,
                        project,
                        location_id=batch_location_id,
                    )
                )
            except Exception as e:
                print(f"  [WARN] Elements build failed: {e}")
                elements_payload = None

        # Build multi_prompt_sequence (with @Element injection if using elements)
        from recoil.pipeline._lib.prompt_engine import build_multi_prompt_sequence

        multi_prompt_sequence = build_multi_prompt_sequence(
            plan_shots,
            batch_char_ids=sorted(element_char_ids)
            if use_elements and element_char_ids
            else None,
            has_location_element=has_location_element,
            total_elements=total_elements,
            prompt_overrides=prompt_overrides if prompt_overrides else None,
            duration_overrides=duration_overrides if duration_overrides else None,
        )

        # ── Duplicate check via GenerationTracker ──
        gen_key = f"sequence_{'_'.join(shot_ids[:3])}"
        if not _gen_tracker.try_start(gen_key):
            self._json_response(
                {"error": "Sequence generation already in progress"}, 409
            )
            return

        self._json_response(
            {
                "shot_ids": shot_ids,
                "status": "sequence_submitted",
                "message": f"Submitting {len(shot_ids)}-shot sequence...",
                "model": video_model,
                "use_elements": use_elements,
                "elements_source": _elements_source,
                "element_char_ids": element_char_ids,
                "start_frame": str(start_frame_path) if start_frame_path else None,
            }
        )

        # Store pass_id in generation metadata for editorial tracking
        if pass_id:
            for sid in shot_ids:
                shot_state = store.get_shot(sid)
                if shot_state:
                    meta = shot_state.get("generation_metadata", {})
                    meta["pass_id"] = pass_id
                    store.update_shot(sid, generation_metadata=meta)

        _plan_shots = plan_shots
        _sequence = multi_prompt_sequence
        _start_path = start_frame_path
        _video_model = video_model
        _elements_payload = elements_payload
        _pp = pp

        def _run_sequence():
            import datetime as _dt

            _logpath = os.path.join(os.path.dirname(__file__), "_sequence_log.txt")

            def _seqlog(msg):
                with open(_logpath, "a") as _lf:
                    _lf.write(f"[{_dt.datetime.now().isoformat()}] {msg}\n")

            _seqlog(f"START sequence {shot_ids} model={_video_model}")
            try:
                from recoil.execution.step_runner import StepRunner
                from recoil.execution.step_types import ProjectPaths

                paths = ProjectPaths.for_episode(project, ep_num)
                runner = StepRunner(store=store, paths=paths)

                _seqlog(
                    f"  Calling execute_multi_shot: {len(_plan_shots)} shots, model={_video_model}"
                )
                results = runner.execute_multi_shot(
                    batch=_plan_shots,
                    multi_prompt_sequence=_sequence,
                    model=_video_model,
                    start_frame=_start_path,
                    aspect_ratio=_get_project_aspect_ratio(project),
                    elements_payload=_elements_payload,
                )

                ok_count = sum(1 for r in results if r.success)
                total_cost = sum(r.cost_usd for r in results)
                _seqlog(f"  DONE: {ok_count}/{len(results)} ok, ${total_cost:.3f}")
                for r in results:
                    if not r.success:
                        _seqlog(f"  FAIL {r.shot_id}: {r.error}")
                print(
                    f"  [OK] Sequence {shot_ids[0]}..{shot_ids[-1]}: {ok_count}/{len(results)} shots — ${total_cost:.3f}"
                )

            except Exception as e:
                _seqlog(f"  EXCEPTION: {e}")
                import traceback

                _seqlog(traceback.format_exc())
                print(f"  [ERR] Background sequence generate for {shot_ids}: {e}")
                traceback.print_exc()
                for sid in shot_ids:
                    try:
                        store.update_shot(
                            sid,
                            status="video_failed",
                            error_message=str(e),
                        )
                    except Exception:
                        pass
            finally:
                _gen_tracker.finish(gen_key)

        _thread_pool.submit(_run_sequence)

    def _api_promote_coverage(self, body, project=None):
        """Promote a coverage shot's hero frame to replace the primary shot's hero.

        Body: {"coverage_shot_id": "EP001_SH02_COV_01"}
        Returns: {"ok": True, "primary_shot_id", "coverage_shot_id", "hero_frame"}
        """
        project = project or DEFAULT_PROJECT

        coverage_shot_id = body.get("coverage_shot_id")
        if not coverage_shot_id:
            self._json_response({"error": "Missing coverage_shot_id"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        cov_shot = store.get_shot(coverage_shot_id)
        if cov_shot is None:
            self._json_response(
                {"error": f"Coverage shot not found: {coverage_shot_id}"}, 404
            )
            return

        if not cov_shot.get("is_coverage"):
            self._json_response(
                {"error": f"Shot is not a coverage shot: {coverage_shot_id}"}, 400
            )
            return

        primary_shot_id = cov_shot.get("coverage_of")
        if not primary_shot_id:
            self._json_response(
                {
                    "error": f"Coverage shot has no primary reference: {coverage_shot_id}"
                },
                400,
            )
            return

        primary_shot = store.get_shot(primary_shot_id)
        if primary_shot is None:
            self._json_response(
                {"error": f"Primary shot not found: {primary_shot_id}"}, 404
            )
            return

        # Get hero frame from coverage shot
        cov_gate = cov_shot.get("gate_results", {})
        hero_frame = cov_gate.get("hero_frame")
        if not hero_frame:
            self._json_response(
                {"error": f"Coverage shot has no hero frame: {coverage_shot_id}"}, 400
            )
            return

        # Copy hero frame into primary shot's gate_results
        primary_gate = primary_shot.get("gate_results", {})
        primary_gate["hero_frame"] = hero_frame
        store.update_shot(primary_shot_id, gate_results=primary_gate)

        self._json_response(
            {
                "ok": True,
                "primary_shot_id": primary_shot_id,
                "coverage_shot_id": coverage_shot_id,
                "hero_frame": hero_frame,
            }
        )

    def _api_assign_video_frames(self, body, project=None):
        """Reassign which extracted frames serve as start/end for video.

        Body: {"shot_id": "EP001_SH01", "start_from": "first_frame"|"hero_frame"|"last_frame",
               "end_from": "first_frame"|"hero_frame"|"last_frame"|null}
        Updates gate_results.first_frame and gate_results.last_frame without re-extracting.
        Status must be keyframe_approved (frames already extracted).
        """
        project = project or DEFAULT_PROJECT
        shot_id = body.get("shot_id")
        start_from = body.get("start_from")
        end_from = body.get("end_from")

        if not shot_id or not start_from:
            self._json_response({"error": "Missing shot_id or start_from"}, 400)
            return

        valid_slots = ("first_frame", "hero_frame", "last_frame")
        if start_from not in valid_slots:
            self._json_response({"error": f"Invalid start_from: {start_from}"}, 400)
            return
        if end_from and end_from not in valid_slots:
            self._json_response({"error": f"Invalid end_from: {end_from}"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        if shot["status"] != "keyframe_approved":
            self._json_response(
                {"error": f"Need keyframe_approved status, got '{shot['status']}'"}, 400
            )
            return

        gate = shot.get("gate_results", {})

        # Read the actual image paths from current gate_results
        source_path = gate.get(start_from)
        if not source_path:
            self._json_response({"error": f"No image at {start_from}"}, 400)
            return

        # Build updated gate_results — keep existing paths, reassign first/last
        gate_update = dict(gate)
        gate_update["first_frame"] = source_path

        if end_from:
            end_path = gate.get(end_from)
            if not end_path:
                self._json_response({"error": f"No image at {end_from}"}, 400)
                return
            gate_update["last_frame"] = end_path
        else:
            gate_update.pop("last_frame", None)

        store.update_shot(shot_id, gate_results=gate_update)
        self._json_response(
            {
                "shot_id": shot_id,
                "first_frame": gate_update.get("first_frame"),
                "last_frame": gate_update.get("last_frame"),
            }
        )

    def _api_confirm_frame_pair(self, body, project=None):
        """Confirm the first/last frame pair and transition to video_pending.

        Body: {"shot_id": "EP001_SH01"}
        Stores frame paths in gate_results and transitions to video_pending.
        """
        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        # Workspace-friendly: allow confirm from keyframe_approved or video_pending
        # (re-confirming after a failed/timed-out render)
        force = body.get("force", False)
        if not force and shot["status"] not in ("keyframe_approved", "video_pending"):
            self._json_response(
                {
                    "error": f"Cannot confirm pair from status '{shot['status']}' — need keyframe first"
                },
                400,
            )
            return

        gate = shot.get("gate_results", {})
        anchor_role = gate.get("anchor_role", "first_frame")
        frame_position = gate.get("frame_position", "first")

        # For still_only, skip frame pair — go directly to video_pending
        if anchor_role == "still_only":
            store.update_shot(shot_id, status="video_pending")
            self._json_response(
                {
                    "shot_id": shot_id,
                    "status": "video_pending",
                    "anchor_role": "still_only",
                }
            )
            return

        # Collect all available frames
        first_frame = gate.get("first_frame")
        hero_frame = gate.get("hero_frame")
        last_frame = gate.get("last_frame")

        # The anchor frame comes from the keyframe take if not already in gate_results
        takes = shot.get("takes", [])
        kf_takes = [t for t in takes if t.get("layer") == "keyframe"]
        kf_path = kf_takes[-1].get("file_path", "") if kf_takes else ""

        if anchor_role == "first_frame" and not first_frame:
            first_frame = kf_path
        elif anchor_role == "last_frame" and not last_frame:
            last_frame = kf_path
        elif anchor_role == "hero_frame" and not hero_frame:
            hero_frame = kf_path

        # Determine what's required based on frame_position
        # Allow partial confirmation — store whatever exists
        # The video pipeline decides how to use the available frames
        available = {}
        if first_frame:
            available["first_frame"] = first_frame
        if hero_frame:
            available["hero_frame"] = hero_frame
        if last_frame:
            available["last_frame"] = last_frame

        # Must have at least the anchor frame
        anchor_key = {
            "first_frame": "first_frame",
            "last_frame": "last_frame",
            "hero_frame": "hero_frame",
        }.get(anchor_role)
        if anchor_key and anchor_key not in available:
            self._json_response(
                {"error": f"Anchor frame ({anchor_role}) not found in gate_results"},
                400,
            )
            return

        # Store all available frames and transition
        gate_final = {
            "anchor_role": anchor_role,
            "frame_position": frame_position,
        }
        gate_final.update(available)

        store.update_shot(
            shot_id,
            status="video_pending",
            gate_results=gate_final,
        )

        response = {
            "shot_id": shot_id,
            "status": "video_pending",
            "anchor_role": anchor_role,
            "frame_position": frame_position,
            "frame_count": len(available),
        }
        response.update(available)
        self._json_response(response)

    def _api_generate_video(self, body, project=None):
        """Generate video from confirmed frame pair via Kling or Veo.

        Body: {"shot_id": "EP001_SH01", "model": "kling-v3"|"veo-3.1"}
        Gate: status must be video_pending.
        Returns immediately. Poll /api/board/{ep} for status updates.
        """
        import re

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        video_model = body.get("model", "kling-v3")
        video_model = LEGACY_MODEL_MAP.get(video_model, video_model)
        user_prompt = body.get("prompt", "")

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        # Workspace-friendly: allow from any status with assigned frames
        force = body.get("force", False)
        allowed_for_render = (
            "previs_approved",
            "keyframe_approved",
            "video_pending",
            "video_complete",
        )
        if not force and shot["status"] not in allowed_for_render:
            self._json_response(
                {
                    "error": f"Cannot generate video from status '{shot['status']}' — need at least previs_approved"
                },
                400,
            )
            return
        # Set to video_pending if not already
        if shot["status"] in ("previs_approved", "keyframe_approved", "video_complete"):
            store.update_shot(shot_id, status="video_pending")

        gate = shot.get("gate_results", {})
        first_frame = gate.get("first_frame")
        last_frame = gate.get("last_frame")
        hero_frame = gate.get("hero_frame")

        # Path traversal guard — resolve and verify within allowed roots
        def _safe_resolve_frame(rel_path):
            if not rel_path:
                return None
            if ".." in str(rel_path):
                return None
            for root in (pp["project_dir"], PROJECT_ROOT):
                p = (
                    root / rel_path
                    if not Path(rel_path).is_absolute()
                    else Path(rel_path)
                )
                resolved = p.resolve()
                if resolved.is_relative_to(root.resolve()) and resolved.exists():
                    return resolved
            return None

        # Need at least one frame
        start_frame_path = _safe_resolve_frame(first_frame)
        end_frame_path = _safe_resolve_frame(last_frame)

        # Fallback: use hero frame as start
        if not start_frame_path:
            start_frame_path = _safe_resolve_frame(hero_frame)

        if not start_frame_path:
            self._json_response(
                {"error": "No frame images found for video generation"}, 400
            )
            return

        # Check API availability for chosen model
        use_veo = video_model.startswith("veo")

        if use_veo:
            try:
                from recoil.execution.api_client import GoogleGenaiClient

                client = GoogleGenaiClient()
                if not client.is_available():
                    self._json_response(
                        {
                            "error": "Gemini API not configured. Set GEMINI_API_KEY environment variable."
                        },
                        503,
                    )
                    return
            except Exception as e:
                self._json_response({"error": f"Veo client not available: {e}"}, 503)
                return
        elif video_model in ("kling-v3-direct", "kling-o3-direct"):
            try:
                from recoil.execution.api_client import KlingClient

                client = KlingClient()
                if not client.is_available():
                    self._json_response(
                        {
                            "error": "Kling API not configured. Set KLING_ACCESS_KEY and KLING_SECRET_KEY environment variables."
                        },
                        503,
                    )
                    return
            except Exception as e:
                self._json_response({"error": f"Kling client not available: {e}"}, 503)
                return
        else:
            try:
                from recoil.execution.api_client import FalAiKlingClient

                client = FalAiKlingClient()
                if not client.is_available():
                    self._json_response(
                        {
                            "error": "fal.ai API not configured. Set FAL_KEY environment variable."
                        },
                        503,
                    )
                    return
            except Exception as e:
                self._json_response(
                    {"error": f"fal.ai Kling client not available: {e}"}, 503
                )
                return

        engine_label = "Veo 3.1" if use_veo else "Kling V3"

        # ── Duplicate check via GenerationTracker ──
        if not _gen_tracker.try_start(shot_id):
            self._json_response(
                {"error": "Generation already in progress for this shot"}, 409
            )
            return

        # Mark as submitted
        store.update_shot(shot_id, status="video_submitted")

        # Register with task registry
        import uuid as _uuid_vid

        _vid_task_id = _uuid_vid.uuid4().hex[:8]
        with _task_lock:
            _task_registry[_vid_task_id] = {
                "task_id": _vid_task_id,
                "entity_id": shot_id,
                "action": "video",
                "status": "running",
                "started": time.time(),
                "result": None,
                "error": None,
            }

        self._json_response(
            {"status": "generating", "shot_id": shot_id, "task_id": _vid_task_id}, 202
        )

        # Extract episode info for output path
        ep_match = re.match(r"EP(\d+)_SH(\d+)([A-Za-z]?)", shot_id)
        ep_num = int(ep_match.group(1)) if ep_match else 1
        shot_num = int(ep_match.group(2)) if ep_match else 1
        shot_suffix = ep_match.group(3).lower() if ep_match else ""
        shot_label = f"{shot_num:03d}{shot_suffix}"

        _start_path = start_frame_path
        _end_path = end_frame_path
        _pp = pp
        _use_veo = use_veo
        _video_model = video_model
        _user_prompt = user_prompt

        def _run_video():
            try:
                from recoil.execution.step_runner import StepRunner
                from recoil.execution.step_types import ProjectPaths
                from recoil.pipeline.core.dispatch import dispatch
                from recoil.pipeline.core.dispatch_context import DispatchContext

                paths = ProjectPaths.for_episode(project, ep_num)
                runner = StepRunner(store=store, paths=paths)

                # Build prompt — user's visible prompt takes priority over plan data
                action_prompt = _user_prompt
                if not action_prompt:
                    plan_path = _pp["plans_dir"] / f"ep_{ep_num:03d}_plan.json"
                    if plan_path.exists():
                        try:
                            plan = json.loads(plan_path.read_text(encoding="utf-8"))
                            shot_data = next(
                                (
                                    s
                                    for s in plan.get("shots", [])
                                    if s.get("shot_id") == shot_id
                                ),
                                None,
                            )
                            if shot_data:
                                skeleton = shot_data.get("prompt_data", {}).get(
                                    "prompt_skeleton", {}
                                )
                                action_prompt = skeleton.get(
                                    "action_line", skeleton.get("subject_line", "")
                                )
                        except (json.JSONDecodeError, IOError):
                            pass
                if not action_prompt:
                    action_prompt = "smooth cinematic camera movement"

                ctx = DispatchContext(
                    caller_id="review_server",
                    step_runner=runner,
                    project=project,
                    episode=ep_num,
                )
                receipt = dispatch(
                    "video_i2v",
                    {
                        "shot_id": shot_id,
                        "prompt": action_prompt,
                        "model": _video_model,
                        "start_frame": _start_path,
                        "end_frame": _end_path,
                        "duration": 5,
                        "aspect_ratio": _get_project_aspect_ratio(project),
                    },
                    context=ctx,
                )
                result = receipt.run_result

                engine = "Veo" if _use_veo else "Kling"
                if result.success:
                    cost_usd = read_cost_from_result(result)
                    print(
                        f"  [OK] {shot_id} video generated via {engine} — ${cost_usd:.3f}"
                    )
                    with _task_lock:
                        if _vid_task_id in _task_registry:
                            _task_registry[_vid_task_id]["status"] = "complete"
                            _task_registry[_vid_task_id]["result"] = {
                                "shot_id": shot_id
                            }
                else:
                    print(
                        f"  [WARN] Video generation failed for {shot_id} via {engine}: {result.error}"
                    )
                    with _task_lock:
                        if _vid_task_id in _task_registry:
                            _task_registry[_vid_task_id]["status"] = "failed"
                            _task_registry[_vid_task_id]["error"] = (
                                result.error or "Unknown"
                            )

            except Exception as e:
                print(f"  [ERR] Background video generate for {shot_id}: {e}")
                import traceback

                traceback.print_exc()
                try:
                    store.update_shot(
                        shot_id,
                        status="video_failed",
                        error_message=str(e),
                    )
                except Exception:
                    pass
                with _task_lock:
                    if _vid_task_id in _task_registry:
                        _task_registry[_vid_task_id]["status"] = "failed"
                        _task_registry[_vid_task_id]["error"] = str(e)
            finally:
                _gen_tracker.finish(shot_id)

        _thread_pool.submit(_run_video)

    def _api_dailies_unlock(self, body, project=None):
        """Undo approval — regress status one step back.

        Body: {"shot_id": "EP001_SH01"}
        Supported transitions:
          previs_approved   → previs_generated
          keyframe_generated → previs_approved
          keyframe_approved  → keyframe_generated
          video_pending      → keyframe_approved
        Preserves all existing takes.
        """
        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        UNLOCK_MAP = {
            "previs_approved": "previs_generated",
            "keyframe_generated": "previs_approved",
            "keyframe_approved": "keyframe_generated",
            "video_pending": "keyframe_approved",
        }

        current = shot["status"]
        target = UNLOCK_MAP.get(current)
        if not target:
            self._json_response(
                {"error": f"Cannot unlock shot in status '{current}'"},
                400,
            )
            return

        try:
            ep_num = int(shot["episode_id"].replace("EP", ""))
            runner = _get_runner(project, ep_num)
            runner.transition(shot_id, target, reason=f"Console unlock from {current}")
        except InvalidTransitionError as e:
            self._json_response({"error": str(e), "status": 409}, 409)
            return

        # Remove approval from Layer 1 generation log (only for previz unlock)
        if current == "previs_approved":
            import re

            ep_match = re.match(r"EP(\d+)", shot["episode_id"])
            if ep_match:
                ep_num = int(ep_match.group(1))
                ep_dir = f"ep_{ep_num:03d}"
                log = load_shot_data(ep_dir, project=project)
                if log and "human_approvals" in log:
                    approvals = log["human_approvals"]
                    if shot_id in approvals:
                        approvals[shot_id].pop("previs_approved", None)
                        approvals[shot_id].pop("previs_approved_at", None)
                        save_log(ep_dir, log, project=project)

        self._json_response({"shot_id": shot_id, "new_status": target})

    def _api_dailies_mark_seen(self, body, project=None):
        """Record which takes have been seen by the mobile reviewer.

        Body: {"seen_takes": ["EP001_SH003:take_002", ...], "timestamp": 1709...}
        Stores in a lightweight JSON file next to the execution store.
        This is called via navigator.sendBeacon so body may be plain text.
        """
        seen_takes = body.get("seen_takes", [])
        ts = body.get("timestamp", time.time())
        if not seen_takes:
            self._json_response({"status": "ok", "stored": 0})
            return

        # Store in a simple JSON file alongside execution data
        store = _get_store(project)
        if store is None:
            self._json_response({"status": "ok", "stored": 0})
            return

        seen_file = Path(store.shots_dir) / "mobile_seen.json"
        existing = {}
        if seen_file.exists():
            try:
                existing = json.loads(seen_file.read_text())
            except Exception:
                existing = {}

        for take_key in seen_takes:
            existing[take_key] = ts

        seen_file.write_text(json.dumps(existing, indent=2))
        self._json_response({"status": "ok", "stored": len(seen_takes)})

    def _api_dailies_undo_reject(self, body, project=None):
        """Undo a rejection — set status back to previs_generated.

        Body: {"shot_id": "EP001_SH01"}
        """
        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        # Map rejected states back to their generated counterpart
        undo_map = {
            "previs_rejected": "previs_generated",
            "keyframe_rejected": "keyframe_generated",
            "video_rejected": "video_complete",
        }
        new_status = undo_map.get(shot["status"])
        if not new_status:
            self._json_response(
                {"error": f"Cannot undo-reject shot in status '{shot['status']}'"},
                400,
            )
            return

        try:
            store.update_shot(shot_id, status=new_status)
        except InvalidTransitionError as e:
            self._json_response({"error": str(e), "status": 409}, 409)
            return
        self._json_response({"shot_id": shot_id, "new_status": new_status})

    def _api_dailies_take_action(self, body, action, project=None):
        """Mark an individual take as kept (approved), rejected, restored, or reset.

        Body: {"shot_id": "EP001_SH01", "take_index": 0}
        action: "keep" | "reject" | "restore" | "unkept"
        """
        shot_id = body.get("shot_id")
        take_index = body.get("take_index")
        if not shot_id or take_index is None:
            self._json_response({"error": "Missing shot_id or take_index"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        takes = shot.get("takes", [])
        if take_index < 0 or take_index >= len(takes):
            self._json_response(
                {"error": f"Take index {take_index} out of range (0-{len(takes) - 1})"},
                400,
            )
            return

        if action == "keep":
            takes[take_index]["approved"] = True
            takes[take_index].pop("rejected", None)
        elif action == "reject":
            takes[take_index]["rejected"] = True
            takes[take_index].pop("approved", None)
        elif action == "restore":
            takes[take_index].pop("rejected", None)
            takes[take_index].pop("approved", None)
        elif action == "unkept":
            takes[take_index].pop("approved", None)
            takes[take_index].pop("rejected", None)

        store.update_shot(shot_id, takes=takes)

        self._json_response(
            {
                "shot_id": shot_id,
                "take_index": take_index,
                "action": action,
            }
        )

    def _api_reveal_in_finder(self, body):
        """Reveal a file in macOS Finder. Body: {"path": "output/frames/..."}"""
        rel_path = body.get("path", "")
        if not rel_path:
            self._json_response({"error": "Missing path"}, 400)
            return
        abs_path = Path(self._resolve_output_path(rel_path))
        if not abs_path.exists():
            self._json_response({"error": f"File not found: {rel_path}"}, 404)
            return
        import subprocess

        subprocess.Popen(["open", "-R", str(abs_path)])
        self._json_response({"status": "revealed", "path": str(abs_path)})

    def _api_stale_check(self, episode_id, project=None):
        """Compare source_hash in plan vs current screenplay text."""
        # Normalize
        if episode_id.startswith("ep_"):
            ep_num = get_episode_number(episode_id)
            episode_id = f"EP{ep_num:03d}"

        ep_dir = f"ep_{int(episode_id.replace('EP', '')):03d}"
        shot_data = load_shot_data(ep_dir, project=project)
        if shot_data is None:
            self._json_response({"error": f"No plan or log for {episode_id}"}, 404)
            return

        stored_hash = shot_data.get("source_hash")
        if not stored_hash:
            self._json_response(
                {
                    "episode_id": episode_id,
                    "stale": None,
                    "message": "No source_hash in plan",
                }
            )
            return

        # Try to read current screenplay from Recoil
        try:
            from recoil.core.paths import RECOIL_ROOT

            screenplay_dir = RECOIL_ROOT / "screenplays"
            candidates = (
                list(screenplay_dir.glob(f"*{episode_id}*"))
                if screenplay_dir.is_dir()
                else []
            )
            if not candidates:
                candidates = (
                    list(screenplay_dir.glob("*.fountain"))
                    if screenplay_dir.is_dir()
                    else []
                )

            current_hash = None
            for fp in candidates:
                text = fp.read_text(encoding="utf-8")
                current_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
                break

            if current_hash is None:
                self._json_response(
                    {
                        "episode_id": episode_id,
                        "stale": None,
                        "message": "Could not find screenplay to compare",
                    }
                )
                return

            is_stale = current_hash != stored_hash
            self._json_response(
                {
                    "episode_id": episode_id,
                    "stale": is_stale,
                    "stored_hash": stored_hash,
                    "current_hash": current_hash,
                }
            )
        except Exception as e:
            self._json_response(
                {
                    "episode_id": episode_id,
                    "stale": None,
                    "message": f"Error checking staleness: {e}",
                }
            )

    def _api_dailies_batch_override(self, body, project=None):
        """Apply prompt override to multiple shots at once."""
        shot_ids = body.get("shot_ids", [])
        prompt = body.get("prompt")
        if not shot_ids or not prompt:
            self._json_response({"error": "Missing shot_ids or prompt"}, 400)
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        results = []
        for shot_id in shot_ids:
            try:
                shot = store.get_shot(shot_id)
                if shot:
                    ep_num = int(shot["episode_id"].replace("EP", ""))
                    ep_dir = f"ep_{ep_num:03d}"
                    log = load_shot_data(ep_dir, project=project) or {"episode": ep_num}
                    overrides = log.setdefault("manual_prompt_overrides", {})
                    overrides[shot_id] = {
                        "prompt": prompt,
                        "override_at": time.time(),
                    }
                    save_log(ep_dir, log, project=project)
                    store.force_reset_status(
                        shot_id,
                        "previs_pending",
                        reason="batch override — prompt changed by user",
                    )
                    results.append({"shot_id": shot_id, "overridden": True})
                else:
                    results.append(
                        {"shot_id": shot_id, "overridden": False, "error": "not found"}
                    )
            except Exception as e:
                results.append(
                    {"shot_id": shot_id, "overridden": False, "error": str(e)}
                )

        self._json_response({"results": results, "count": len(results)})

    def _api_studio_budget(self):
        """Cross-project budget aggregation."""
        try:
            from recoil.execution.execution_store import global_budget_summary

            data = global_budget_summary()
            self._json_response(data)
        except Exception as e:
            self._json_response({"error": str(e)}, 500)

    # ── Casting API Implementations ──────────────────────────────────

    def _casting_state_path(self, project_dir):
        """Path to casting_state.json for a project."""
        return project_dir / "state" / STATE_NAMESPACE / "casting_state.json"

    def _load_casting_state(self, project_dir):
        """Load or initialize casting_state.json."""
        cs_path = self._casting_state_path(project_dir)
        if cs_path.is_file():
            try:
                return json.loads(cs_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                return {"characters": {}, "locations": {}}
        return {"characters": {}, "locations": {}}

    def _save_casting_state(self, project_dir, state):
        """Write casting_state.json."""
        cs_path = self._casting_state_path(project_dir)
        cs_path.parent.mkdir(parents=True, exist_ok=True)
        cs_path.write_text(json.dumps(state, indent=2), encoding="utf-8")

    # ── URSS GridSession CRUD ────────────────────────────────────────

    def _create_grid_session(
        self,
        project_dir,
        asset_type,
        parent_context,
        anchor_path=None,
        anchor_source=None,
        mood_text=None,
    ):
        """Create a new GridSession and persist it."""
        import uuid as _uuid
        from recoil.pipeline._lib.ref_selector import load_descriptor, candidate_count

        descriptor = load_descriptor(asset_type)
        session_id = str(_uuid.uuid4())[:8]
        count = candidate_count(descriptor)

        session = {
            "session_id": session_id,
            "asset_type": asset_type,
            "parent_context": parent_context,
            "descriptor": descriptor,
            "anchor": {
                "path": anchor_path,
                "source": anchor_source,
                "mood_text": mood_text or "",
            },
            "candidates": [
                {"slot": i, "path": None, "state": "empty", "re_roll_generation": 0}
                for i in range(count)
            ],
            "re_roll_count": 0,
            "user_overrides": [],
            "collapsed_override": "",
            "hero_locked": False,
            "hero_path": None,
            "beauty_pass_path": None,
            "status": "created",
            "cost": 0.0,
        }

        # Persist
        state = self._load_casting_state(project_dir)
        if "grid_sessions" not in state:
            state["grid_sessions"] = {}
        state["grid_sessions"][session_id] = session
        self._save_casting_state(project_dir, state)

        return session

    def _get_grid_session(self, project_dir, session_id):
        """Load a GridSession by ID."""
        state = self._load_casting_state(project_dir)
        return state.get("grid_sessions", {}).get(session_id)

    def _update_grid_session(self, project_dir, session_id, updates):
        """Update fields on a GridSession and persist."""
        state = self._load_casting_state(project_dir)
        session = state.get("grid_sessions", {}).get(session_id)
        if not session:
            return None
        session.update(updates)
        self._save_casting_state(project_dir, state)
        return session

    def _api_casting_characters(self, project_name, project_dir):
        """GET /api/project/{name}/casting/characters

        Returns characters from Global Bible + casting_state + ref dirs.
        """
        pp = _paths_for_project(project_name)
        bible_path = pp["bible_path"]

        characters = {}
        locations = {}

        if bible_path.is_file():
            try:
                bible = json.loads(bible_path.read_text(encoding="utf-8"))

                # Extract characters
                for char_id, char_data in bible.get("characters", {}).items():
                    characters[char_id] = {
                        "display_name": char_data.get("display_name", char_id),
                        "role": char_data.get("role", ""),
                        "visual_description": char_data.get(
                            "visual_description", char_data.get("description", "")
                        ),
                        "casting_description": char_data.get("casting_description", ""),
                        "phases": char_data.get("phases", []),
                        "wardrobe_arc_thesis": char_data.get("wardrobe_arc_thesis", ""),
                    }

                # Extract locations
                for loc_id, loc_data in bible.get("locations", {}).items():
                    from recoil.pipeline._lib.taxonomy import slugify_asset_id

                    loc_slug = slugify_asset_id(loc_id)
                    # Check for existing refs
                    refs = []
                    loc_ref_dir = pp["location_refs_dir"] / loc_slug
                    if loc_ref_dir.is_dir():
                        for f in sorted(loc_ref_dir.iterdir()):
                            if f.suffix.lower() in IMAGE_EXTS:
                                refs.append(
                                    f"output/refs/locations/{loc_slug}/{f.name}"
                                )

                    locations[loc_id] = {
                        "description": loc_data.get("description", ""),
                        "refs": refs,
                    }
            except json.JSONDecodeError:
                pass

        # Ref dir for enriching characters below (bible is authoritative for
        # the character list — orphan ref dirs are NOT auto-promoted)
        char_refs_dir = pp["character_refs_dir"]

        # Load casting state
        casting_state = self._load_casting_state(project_dir)

        # Enrich characters with ref_resolver status (filesystem is source of truth)
        from recoil.pipeline._lib.ref_image_ops import character_ref_status

        refs_root = Path(project_dir) / "output" / "refs"
        project_dir_str = str(project_dir)

        for char_id in characters:
            char_state = casting_state.get("characters", {}).get(char_id, {})

            # Get canonical ref status from filesystem via ref_resolver
            ref_status = character_ref_status(refs_root, char_id)

            # Convert absolute paths to project-relative paths
            def _to_proj_rel(abs_path_str):
                if abs_path_str and abs_path_str.startswith(project_dir_str):
                    return (
                        abs_path_str[len(project_dir_str) :]
                        .replace("\\", "/")
                        .lstrip("/")
                    )
                return abs_path_str

            if ref_status.get("hero") and ref_status["hero"].get("path"):
                ref_status["hero"]["path"] = _to_proj_rel(ref_status["hero"]["path"])
            for angle, info in ref_status.get("turnaround", {}).items():
                if info and info.get("path"):
                    info["path"] = _to_proj_rel(info["path"])
            for angle, info in ref_status.get("wardrobe_turnaround", {}).items():
                if info and info.get("path"):
                    info["path"] = _to_proj_rel(info["path"])

            characters[char_id]["ref_status"] = ref_status

            # Backfill casting_state hero_path for backward compatibility
            if (
                ref_status.get("hero")
                and ref_status["hero"].get("path")
                and not char_state.get("hero_path")
            ):
                char_state["hero_path"] = ref_status["hero"]["path"]
                char_state.setdefault("status", "hero_selected")

            casting_state.setdefault("characters", {})[char_id] = char_state

        # Include grid_sessions and continuity_sessions for URSS
        casting_state["grid_sessions"] = casting_state.get("grid_sessions", {})
        casting_state["continuity_sessions"] = casting_state.get(
            "continuity_sessions", {}
        )

        # Auto-sync grid sessions: if hero detected but session not locked, mark it
        for sid, gs in casting_state["grid_sessions"].items():
            if gs.get("hero_locked"):
                continue
            gs_char = (gs.get("parent_context", {}).get("character_id") or "").upper()
            if not gs_char:
                continue
            char_hero = (
                casting_state.get("characters", {}).get(gs_char, {}).get("hero_path")
            )
            if char_hero:
                gs["hero_locked"] = True
                gs["hero_path"] = char_hero
                if gs.get("status") in ("created", "generating"):
                    gs["status"] = "hero_locked"

        self._json_response(
            {
                "characters": characters,
                "locations": locations,
                "casting_state": casting_state,
            }
        )

    def _api_casting_expressions(self, project_name, project_dir, char_id):
        """GET /api/project/{name}/casting/expressions[/{char_id}]"""
        casting_state = self._load_casting_state(project_dir)
        if char_id:
            char_state = casting_state.get("characters", {}).get(char_id, {})
            self._json_response(char_state.get("expressions", {}))
        else:
            # Return all expression data
            all_expr = {}
            for cid, cstate in casting_state.get("characters", {}).items():
                if cstate.get("expressions"):
                    all_expr[cid] = cstate["expressions"]
            self._json_response(all_expr)

    def _api_casting_locations(self, project_name, project_dir):
        """GET /api/project/{name}/casting/locations"""
        pp = _paths_for_project(project_name)
        locations = {}

        # Load casting_state for hero info
        state_path = (
            pp["project_dir"] / "state" / STATE_NAMESPACE / "casting_state.json"
        )
        loc_heroes = {}
        if state_path.is_file():
            try:
                casting = json.loads(state_path.read_text(encoding="utf-8"))
                loc_heroes = casting.get("locations", {})
            except (json.JSONDecodeError, IOError):
                pass

        if pp["location_refs_dir"].is_dir():
            for loc_dir in sorted(pp["location_refs_dir"].iterdir()):
                if loc_dir.is_dir() and not loc_dir.name.startswith(("_", ".")):
                    refs = []
                    for f in sorted(loc_dir.iterdir()):
                        if f.is_file() and f.suffix.lower() in IMAGE_EXTS:
                            refs.append(
                                f"output/refs/locations/{loc_dir.name}/{f.name}"
                            )
                    # Look up casting state by both lowercase and uppercase keys
                    loc_state = loc_heroes.get(
                        loc_dir.name, loc_heroes.get(loc_dir.name.upper(), {})
                    )
                    locations[loc_dir.name] = {
                        "refs": refs,
                        "hero_path": loc_state.get("hero_path"),
                        "moodboard_picks": loc_state.get("moodboard_picks", []),
                    }
        self._json_response({"locations": locations})

    def _api_casting_generate_grid(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/generate-grid

        Shells out to prep_character_angles.py --description (Path B).
        Falls back to global bible visual_description if request body
        description is empty.
        """
        import subprocess as sp
        import threading

        char_id = body.get("character_id", "")
        description = body.get("description", "")

        if not char_id:
            self._json_response({"error": "Missing character_id"}, 400)
            return

        gender = body.get("gender", "")

        # Fallback: load full description from global bible
        if not description or not gender:
            pp = _paths_for_project(project_name)
            bible_path = pp["bible_path"]
            if bible_path.is_file():
                try:
                    bible = json.loads(bible_path.read_text(encoding="utf-8"))
                    char_data = bible.get("characters", {}).get(char_id.upper(), {})
                    if not description:
                        description = char_data.get(
                            "visual_description", char_data.get("description", "")
                        )
                    if not gender:
                        gender = char_data.get("gender", "")
                except (json.JSONDecodeError, OSError):
                    pass

        tool_path = PROJECT_ROOT / "tools" / "prep_character_angles.py"
        if not tool_path.is_file():
            self._json_response({"error": "prep_character_angles.py not found"}, 500)
            return

        cmd = [
            sys.executable,
            str(tool_path),
            char_id,
            "--description",
            description or f"Character {char_id}",
            "--project",
            project_name,
        ]
        if gender:
            cmd.extend(["--gender", gender])

        def _run():
            try:
                result = sp.run(
                    cmd,
                    capture_output=True,
                    text=True,
                    timeout=120,
                    cwd=str(PROJECT_ROOT),
                )
                if result.returncode == 0:
                    try:
                        output = json.loads(result.stdout)
                        # Update casting state with grid result
                        state = self._load_casting_state(project_dir)
                        chars = state.setdefault("characters", {})
                        char_state = chars.setdefault(char_id.upper(), {})
                        if output.get("grid_result", {}).get("panels"):
                            # Convert absolute panel paths to relative output/ paths
                            pp = _paths_for_project(project_name)
                            proj_out = str(pp["output_dir"])
                            panels = []
                            for p in output["grid_result"]["panels"]:
                                p = str(p)
                                if p.startswith(proj_out):
                                    p = "output/" + p[len(proj_out) :].lstrip("/")
                                panels.append(p)
                            char_state["grid_images"] = panels
                        self._save_casting_state(project_dir, state)
                    except json.JSONDecodeError:
                        pass
            except Exception as e:
                print(f"  [WARN] Grid generation failed for {char_id}: {e}")

        # Run in background thread so UI isn't blocked indefinitely
        thread = threading.Thread(target=_run, daemon=True)
        thread.start()

        self._json_response(
            {
                "status": "generating",
                "character_id": char_id,
                "message": "Grid generation started. Poll /casting/characters to check status.",
            }
        )

    def _api_casting_select_hero(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/select-hero

        Updates casting_state with the selected panel as hero.
        """
        char_id = body.get("character_id", "").upper()
        panel_index = body.get("panel_index")

        if not char_id or panel_index is None:
            self._json_response({"error": "Missing character_id or panel_index"}, 400)
            return

        state = self._load_casting_state(project_dir)
        chars = state.setdefault("characters", {})
        char_state = chars.setdefault(char_id, {})

        grid_images = char_state.get("grid_images", [])
        if panel_index < 0 or panel_index >= len(grid_images):
            self._json_response({"error": f"Invalid panel_index: {panel_index}"}, 400)
            return

        char_state["selected_panel"] = panel_index
        char_state["hero_path"] = grid_images[panel_index]
        char_state["hero_source"] = "grid"
        char_state["status"] = "hero_selected"
        char_state["bible_synced"] = False

        # Copy selected image to canonical hero path and generate thumbnail
        try:
            from recoil.pipeline._lib.ref_image_ops import generate_thumbnail
            from recoil.pipeline._lib.taxonomy import slugify_asset_id

            char_slug = slugify_asset_id(char_id)
            pp = _paths_for_project(project_name)
            char_ref_dir = pp["character_refs_dir"] / char_slug
            char_ref_dir.mkdir(parents=True, exist_ok=True)
            legacy_hero = (
                char_ref_dir
                / f"{char_slug}_hero{Path(grid_images[panel_index]).suffix.lower() or '.png'}"
            )

            # Resolve the selected panel to absolute path
            selected_rel = grid_images[panel_index]
            selected_abs = _resolve_output_rel(selected_rel)
            if selected_abs.is_file():
                shutil.copy(str(selected_abs), str(legacy_hero))
                generate_thumbnail(legacy_hero)

                # Also promote to _canonical/
                refs_root = pp["refs_dir"]
                canonical_dir = refs_root / "_canonical" / "characters" / char_slug
                canonical_dir.mkdir(parents=True, exist_ok=True)
                for old_hero in canonical_dir.glob("hero.*"):
                    old_hero.unlink()
                canonical_hero = canonical_dir / f"hero{selected_abs.suffix.lower()}"
                shutil.copy2(str(selected_abs), str(canonical_hero))

                # Update hero_path to canonical location
                try:
                    char_state["hero_path"] = str(
                        canonical_hero.relative_to(pp["project_dir"])
                    )
                except ValueError:
                    char_state["hero_path"] = str(canonical_hero)
        except Exception as e:
            # Non-fatal — casting_state is already saved with grid path
            print(f"[select-hero] Warning: could not copy hero/thumbnail: {e}")

        self._save_casting_state(project_dir, state)

        self._json_response(
            {
                "status": "saved",
                "character_id": char_id,
                "hero_path": char_state["hero_path"],
            }
        )

    def _api_casting_promote_grid(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/promote-grid

        Split a grid image into individual turnaround panels and write to canonical ref paths.
        Body: {"character_id": "SADIE", "grid_path": "output/refs/characters/sadie/_exploration/grid.png", "panel_count": 4}
        """
        from recoil.pipeline._lib.ref_image_ops import promote_grid

        char_id = body.get("character_id", "").upper()
        grid_rel = body.get("grid_path", "")
        panel_count = body.get("panel_count", 4)

        if not char_id or not grid_rel:
            self._json_response({"error": "Missing character_id or grid_path"}, 400)
            return

        # Resolve grid path via _resolve_output_rel
        grid_abs = _resolve_output_rel(grid_rel)
        if not grid_abs.is_file():
            self._json_response({"error": f"Grid image not found: {grid_rel}"}, 404)
            return

        pp = _paths_for_project(project_name)
        refs_root = pp["refs_dir"]
        project_dir_str = str(pp["project_dir"])

        try:
            written = promote_grid(
                refs_root, char_id, grid_abs, panel_count=panel_count
            )
            # Convert absolute paths to project-relative
            panels_written = {}
            for role, abs_path in written.items():
                rel = str(abs_path)
                if rel.startswith(project_dir_str):
                    rel = rel[len(project_dir_str) :].lstrip("/")
                panels_written[role] = rel

            self._json_response(
                {
                    "status": "ok",
                    "character_id": char_id,
                    "panels_written": panels_written,
                    "panel_count": len(panels_written),
                }
            )
        except ValueError as e:
            self._json_response({"error": str(e)}, 400)
        except Exception as e:
            self._json_response({"error": f"promote_grid failed: {e}"}, 500)

    def _api_casting_generate_turnaround(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/generate-turnaround

        Shells out to prep_character_angles.py --hero (Path A).
        """
        import subprocess as sp
        import threading

        char_id = body.get("character_id", "").upper()
        if not char_id:
            self._json_response({"error": "Missing character_id"}, 400)
            return

        # Find hero image
        state = self._load_casting_state(project_dir)
        char_state = state.get("characters", {}).get(char_id, {})
        hero_rel = char_state.get("hero_path", "")

        if not hero_rel:
            self._json_response(
                {"error": "No hero selected — generate grid and select first"}, 400
            )
            return

        # Resolve hero path (it's relative to output/)
        hero_abs = _resolve_output_rel(hero_rel)
        if not hero_abs.is_file():
            self._json_response({"error": f"Hero image not found: {hero_rel}"}, 404)
            return

        tool_path = PROJECT_ROOT / "tools" / "prep_character_angles.py"

        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        char_slug = slugify_asset_id(char_id)

        cmd = [
            sys.executable,
            str(tool_path),
            char_slug,
            "--hero",
            str(hero_abs),
            "--project",
            project_name,
        ]

        def _run():
            try:
                result = sp.run(
                    cmd,
                    capture_output=True,
                    text=True,
                    timeout=300,
                    cwd=str(PROJECT_ROOT),
                )
                if result.returncode == 0:
                    try:
                        output = json.loads(result.stdout)
                        # Update casting state with turnaround paths
                        state = self._load_casting_state(project_dir)
                        chars = state.setdefault("characters", {})
                        cs = chars.setdefault(char_id, {})
                        turnaround = {}
                        # Use angle_panels from the 2x2 grid
                        angle_grid = output.get("angle_grid", {})
                        panels = angle_grid.get("panels", [])
                        angles = angle_grid.get(
                            "angles", output.get("angles_generated", [])
                        )
                        # Convert absolute panel paths to relative output/ paths
                        pp = _paths_for_project(project_name)
                        for i, angle in enumerate(angles):
                            if i < len(panels):
                                panel_path = str(panels[i])
                                proj_out = str(pp["output_dir"])
                                if panel_path.startswith(proj_out):
                                    panel_path = "output/" + panel_path[
                                        len(proj_out) :
                                    ].lstrip("/")
                                turnaround[angle] = {
                                    "path": panel_path,
                                    "approved": False,
                                }
                            else:
                                turnaround[angle] = {
                                    "path": f"output/refs/characters/{char_slug}/angle_panels/{char_slug}_{angle}.png",
                                    "approved": False,
                                }
                        if turnaround:
                            cs["turnaround"] = turnaround
                        self._save_casting_state(project_dir, state)
                    except json.JSONDecodeError:
                        pass
            except Exception as e:
                print(f"  [WARN] Turnaround generation failed for {char_id}: {e}")

        thread = threading.Thread(target=_run, daemon=True)
        thread.start()

        self._json_response(
            {
                "status": "generating",
                "character_id": char_id,
                "message": "Turnaround generation started. Poll /casting/characters to check status.",
            }
        )

    def _api_casting_approve_ref(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/approve-ref

        Approve or reject a turnaround angle.
        """
        char_id = body.get("character_id", "").upper()
        angle = body.get("angle", "")
        approved = body.get("approved", False)

        if not char_id or not angle:
            self._json_response({"error": "Missing character_id or angle"}, 400)
            return

        state = self._load_casting_state(project_dir)
        chars = state.setdefault("characters", {})
        char_state = chars.setdefault(char_id, {})
        turnaround = char_state.setdefault("turnaround", {})

        if angle in turnaround:
            turnaround[angle]["approved"] = approved
            turnaround[angle]["rejected"] = not approved
        else:
            turnaround[angle] = {"approved": approved, "rejected": not approved}

        if approved:
            char_state["bible_synced"] = False

        self._save_casting_state(project_dir, state)
        self._json_response(
            {
                "status": "saved",
                "character_id": char_id,
                "angle": angle,
                "approved": approved,
            }
        )

    def _api_casting_generate_expressions(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/generate-expressions

        ADR-C05: Generates universal expression matrix (not per-character).
        Run once for the entire show. Stores in assets/expressions/.
        No hero image needed — uses generic bald androgynous actor.
        """
        import subprocess as sp
        import threading

        # Check if expressions already exist
        expr_dir = PROJECT_ROOT / "assets" / "expressions"
        if expr_dir.is_dir() and any(expr_dir.glob("*_active.png")):
            self._json_response(
                {
                    "status": "already_generated",
                    "message": "Universal expression matrix already exists in assets/expressions/.",
                    "output_dir": str(expr_dir),
                }
            )
            return

        def _run():
            try:
                tool_path = PROJECT_ROOT / "tools" / "prep_expressions.py"
                cmd = [
                    sys.executable,
                    str(tool_path),
                ]
                result = sp.run(
                    cmd,
                    capture_output=True,
                    text=True,
                    timeout=300,
                    cwd=str(PROJECT_ROOT),
                )
                if result.returncode == 0:
                    try:
                        output = json.loads(result.stdout)
                        # Store universal expression state (not per-character)
                        state = self._load_casting_state(project_dir)
                        # Convert absolute output_dir to relative output/ path
                        out_dir = str(output.get("output_dir", ""))
                        pp = _paths_for_project(project_name)
                        proj_out = str(pp["output_dir"])
                        if out_dir.startswith(proj_out):
                            out_dir = "output/" + out_dir[len(proj_out) :].lstrip("/")
                        state["universal_expressions"] = {
                            "generated": True,
                            "count": len(output.get("expressions", [])),
                            "cost": output.get("cost", 0),
                            "output_dir": out_dir,
                        }
                        self._save_casting_state(project_dir, state)
                    except json.JSONDecodeError:
                        pass
                else:
                    print(f"  [WARN] Expression tool failed: {result.stderr}")
            except Exception as e:
                print(f"  [WARN] Expression generation failed: {e}")

        thread = threading.Thread(target=_run, daemon=True)
        thread.start()

        self._json_response(
            {
                "status": "generating",
                "message": "Universal expression matrix generation started (3 grids, ~$0.117).",
            }
        )

    def _api_casting_generate_location(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/generate-location

        Shells out to prep_location_refs.py or generate_location_refs.py.
        """
        import subprocess as sp
        import threading

        loc_id = body.get("location_id", "")
        if not loc_id:
            self._json_response({"error": "Missing location_id"}, 400)
            return

        tool_path = PROJECT_ROOT / "tools" / "prep_location_refs.py"
        if not tool_path.is_file():
            tool_path = PROJECT_ROOT / "tools" / "generate_location_refs.py"

        if not tool_path.is_file():
            self._json_response(
                {"error": "Location ref generation tool not found"}, 500
            )
            return

        cmd = [
            sys.executable,
            str(tool_path),
            "--project",
            project_name,
        ]

        def _run():
            try:
                sp.run(
                    cmd,
                    capture_output=True,
                    text=True,
                    timeout=300,
                    cwd=str(PROJECT_ROOT),
                )
            except Exception as e:
                print(f"  [WARN] Location ref generation failed for {loc_id}: {e}")

        thread = threading.Thread(target=_run, daemon=True)
        thread.start()

        self._json_response(
            {
                "status": "generating",
                "location_id": loc_id,
                "message": "Location ref generation started.",
            }
        )

    def _api_casting_select_location_hero(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/select-location-hero

        Set a specific ref image as the hero for a location.
        Body: {"location_id": "DOCKING_BAY_7", "ref_path": "output/refs/locations/docking_bay_7/v3.png"}
        Updates casting_state.locations[location_id].hero_path.
        """
        loc_id = body.get("location_id", "")
        ref_path = body.get("ref_path", "")
        if not loc_id or not ref_path:
            self._json_response({"error": "Missing location_id or ref_path"}, 400)
            return

        # Verify file exists
        abs_path = project_dir / ref_path
        if not abs_path.is_file():
            self._json_response({"error": f"Ref file not found: {ref_path}"}, 404)
            return

        # Load/update casting_state
        pp = _paths_for_project(project_name)
        state_path = (
            pp["project_dir"] / "state" / STATE_NAMESPACE / "casting_state.json"
        )
        casting = {}
        if state_path.is_file():
            try:
                casting = json.loads(state_path.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, IOError):
                pass

        locations = casting.setdefault("locations", {})
        loc_state = locations.setdefault(loc_id, {})
        loc_state["hero_path"] = ref_path
        loc_state["hero_selected_at"] = time.time()

        state_path.write_text(json.dumps(casting, indent=2), encoding="utf-8")

        self._json_response(
            {
                "location_id": loc_id,
                "hero_path": ref_path,
            }
        )

    def _api_update_location_moodboard(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/update-location-moodboard

        Save moodboard picks for a location.
        Body: {"location_id": "int_lower_decks_corridor", "moodboard_picks": ["file1.png", ...]}
        """
        loc_id = body.get("location_id", "")
        picks = body.get("moodboard_picks", [])
        if not loc_id:
            self._json_response({"error": "Missing location_id"}, 400)
            return
        if not isinstance(picks, list):
            self._json_response({"error": "moodboard_picks must be a list"}, 400)
            return

        pp = _paths_for_project(project_name)
        state_path = (
            pp["project_dir"] / "state" / STATE_NAMESPACE / "casting_state.json"
        )
        casting = {}
        if state_path.is_file():
            try:
                casting = json.loads(state_path.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, IOError):
                pass

        locations = casting.setdefault("locations", {})
        loc_state = locations.setdefault(loc_id, {})
        loc_state["moodboard_picks"] = picks

        state_path.parent.mkdir(parents=True, exist_ok=True)
        state_path.write_text(json.dumps(casting, indent=2), encoding="utf-8")

        self._json_response(
            {
                "location_id": loc_id,
                "moodboard_picks": picks,
            }
        )

    def _api_upload_location_ref(self, project_name, project_dir):
        """POST /api/project/{name}/casting/upload-location-ref

        Raw binary upload. Filename in X-File-Name header, location in X-Location-Id header.
        """
        import urllib.parse
        import re as _re

        loc_id = self.headers.get("X-Location-Id", "")
        raw_name = urllib.parse.unquote(self.headers.get("X-File-Name", "unnamed.png"))

        if not loc_id:
            self._json_response({"error": "Missing X-Location-Id header"}, 400)
            return

        content_length = int(self.headers.get("Content-Length", 0))
        if content_length > 10 * 1024 * 1024:
            self._json_response({"error": "File too large (max 10MB)"}, 413)
            return
        if content_length == 0:
            self._json_response({"error": "Empty file"}, 400)
            return

        stem = Path(raw_name).stem
        ext = Path(raw_name).suffix.lower()
        if ext not in (".png", ".jpg", ".jpeg"):
            self._json_response({"error": f"Invalid file type: {ext}"}, 400)
            return
        stem = _re.sub(r"[^\w\-]", "_", stem)
        stem = _re.sub(r"_+", "_", stem).strip("_") or "ref"

        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        pp = _paths_for_project(project_name)
        loc_slug = slugify_asset_id(loc_id)
        refs_dir = pp["location_refs_dir"] / loc_slug
        refs_dir.mkdir(parents=True, exist_ok=True)

        candidate = f"{stem}{ext}"
        counter = 1
        while (refs_dir / candidate).exists():
            candidate = f"{stem}_{counter}{ext}"
            counter += 1

        file_data = self.rfile.read(content_length)
        dest = refs_dir / candidate
        dest.write_bytes(file_data)

        self._ensure_location_thumbnail(dest)

        self._json_response(
            {
                "filename": candidate,
                "path": f"output/refs/locations/{loc_slug}/{candidate}",
            }
        )

    def _api_casting_explorations(self, project_name, project_dir, char_id):
        """GET /api/project/{name}/casting/explorations/{char_id}

        Returns list of exploration image paths for the character.
        """
        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        pp = _paths_for_project(project_name)
        char_slug = slugify_asset_id(char_id)
        exploration_dir = pp["character_refs_dir"] / char_slug / "_exploration"

        images = []
        if exploration_dir.is_dir():
            for f in sorted(exploration_dir.iterdir()):
                if f.is_file() and f.suffix.lower() in IMAGE_EXTS:
                    images.append(
                        {
                            "filename": f.name,
                            "path": f"output/refs/characters/{char_slug}/_exploration/{f.name}",
                        }
                    )

        self._json_response({"character_id": char_id, "images": images})

    def _api_assign_turnaround(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/assign-turnaround

        Copy an exploration image to a canonical turnaround slot.
        Body: { character_id, angle, source_path, turnaround_type }
        turnaround_type: "character" (default) or "wardrobe"
        angle: "front" | "three_quarter" | "profile" | "back"
        source_path: relative path like "output/refs/characters/sadie/_exploration/foo.png"
        """
        import shutil

        char_id = body.get("character_id", "").upper()
        angle = body.get("angle", "")
        source_path = body.get("source_path", "")
        turnaround_type = body.get("turnaround_type", "character")

        if not char_id or not angle or not source_path:
            self._json_response(
                {"error": "Missing character_id, angle, or source_path"}, 400
            )
            return

        if angle not in ("front", "three_quarter", "profile", "back"):
            self._json_response({"error": f"Invalid angle: {angle}"}, 400)
            return

        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        pp = _paths_for_project(project_name)
        char_slug = slugify_asset_id(char_id)

        src_abs = pp["project_dir"] / source_path
        if not src_abs.is_file():
            self._json_response({"error": f"Source not found: {source_path}"}, 404)
            return

        ext = src_abs.suffix.lower()
        prefix = (
            f"{char_slug}_wardrobe_{angle}"
            if turnaround_type == "wardrobe"
            else f"{char_slug}_{angle}"
        )
        dest = pp["character_refs_dir"] / char_slug / f"{prefix}{ext}"

        shutil.copy2(str(src_abs), str(dest))

        rel_path = f"output/refs/characters/{char_slug}/{prefix}{ext}"
        self._json_response({"ok": True, "path": rel_path, "angle": angle})

    def _api_upload_turnaround(self, project_name, project_dir):
        """POST /api/project/{name}/casting/upload-turnaround

        Raw binary upload for a turnaround slot.
        Headers: X-Character-Id, X-Angle, X-Turnaround-Type (character|wardrobe)
        """
        import urllib.parse

        char_id = (self.headers.get("X-Character-Id", "") or "").upper()
        angle = self.headers.get("X-Angle", "")
        turnaround_type = self.headers.get("X-Turnaround-Type", "character")
        raw_name = urllib.parse.unquote(self.headers.get("X-File-Name", "upload.png"))

        if not char_id or not angle:
            self._json_response(
                {"error": "Missing X-Character-Id or X-Angle header"}, 400
            )
            return

        if angle not in ("front", "three_quarter", "profile", "back"):
            self._json_response({"error": f"Invalid angle: {angle}"}, 400)
            return

        content_length = int(self.headers.get("Content-Length", 0))
        if content_length > 50 * 1024 * 1024:
            self._json_response({"error": "File too large (max 50MB)"}, 413)
            return
        if content_length == 0:
            self._json_response({"error": "Empty file"}, 400)
            return

        ext = Path(raw_name).suffix.lower()
        if ext not in (".png", ".jpg", ".jpeg"):
            self._json_response({"error": f"Invalid file type: {ext}"}, 400)
            return

        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        pp = _paths_for_project(project_name)
        char_slug = slugify_asset_id(char_id)
        char_dir = pp["character_refs_dir"] / char_slug
        char_dir.mkdir(parents=True, exist_ok=True)

        prefix = (
            f"{char_slug}_wardrobe_{angle}"
            if turnaround_type == "wardrobe"
            else f"{char_slug}_{angle}"
        )
        dest = char_dir / f"{prefix}{ext}"

        file_data = self.rfile.read(content_length)
        dest.write_bytes(file_data)

        rel_path = f"output/refs/characters/{char_slug}/{prefix}{ext}"
        self._json_response({"ok": True, "path": rel_path, "angle": angle})

    def _api_delete_location_ref(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/delete-location-ref

        Delete a location ref image and its thumbnail. Also removes from moodboard_picks.
        """
        loc_id = body.get("location_id", "")
        filename = body.get("filename", "")

        if not loc_id or not filename:
            self._json_response({"error": "Missing location_id or filename"}, 400)
            return

        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        pp = _paths_for_project(project_name)
        loc_slug = slugify_asset_id(loc_id)
        refs_dir = pp["location_refs_dir"] / loc_slug
        file_path = refs_dir / filename

        if not file_path.is_file():
            self._json_response({"error": f"File not found: {filename}"}, 404)
            return

        file_path.unlink()

        # Delete thumbnail if exists
        thumb_dir = refs_dir / "_thumbs"
        thumb_name = Path(filename).stem + ".jpg"
        thumb_path = thumb_dir / thumb_name
        if thumb_path.is_file():
            thumb_path.unlink()

        # Remove from moodboard_picks in casting_state
        state_path = (
            pp["project_dir"] / "state" / STATE_NAMESPACE / "casting_state.json"
        )
        if state_path.is_file():
            try:
                casting = json.loads(state_path.read_text(encoding="utf-8"))
                loc_state = casting.get("locations", {}).get(loc_id, {})
                picks = loc_state.get("moodboard_picks", [])
                if filename in picks:
                    picks.remove(filename)
                    state_path.write_text(
                        json.dumps(casting, indent=2), encoding="utf-8"
                    )
            except (json.JSONDecodeError, IOError):
                pass

        self._json_response({"deleted": filename, "location_id": loc_id})

    def _ensure_location_thumbnail(self, abs_path):
        """Generate a 320px JPEG thumbnail for a location ref image."""
        try:
            from PIL import Image as PILImage
        except ImportError:
            return

        thumb_dir = abs_path.parent / "_thumbs"
        thumb_path = thumb_dir / (abs_path.stem + ".jpg")

        if (
            thumb_path.is_file()
            and thumb_path.stat().st_mtime >= abs_path.stat().st_mtime
        ):
            return

        thumb_dir.mkdir(exist_ok=True)
        try:
            img = PILImage.open(abs_path)
            img.thumbnail((320, 320), PILImage.LANCZOS)
            if img.mode in ("RGBA", "P"):
                img = img.convert("RGB")
            img.save(thumb_path, "JPEG", quality=85)
        except Exception:
            pass

    # ── Screen Test endpoints ─────────────────────────────────────────

    def _api_screen_test_get(self, project_name, project_dir, character):
        """GET /api/project/{name}/screen-test/{character}

        Returns phase grid state + bible data for a character.
        """
        from recoil.pipeline._lib.screen_test import load_screen_test_state

        char_id = character.upper()

        # Load Global Bible
        pp = _paths_for_project(project_name)
        bible_path = pp["bible_path"]

        char_info = {}
        phases_from_bible = []
        char_props = []

        if bible_path and bible_path.is_file():
            try:
                bible = json.loads(bible_path.read_text(encoding="utf-8"))
                char_data = bible.get("characters", {}).get(char_id, {})
                char_info = {
                    "char_id": char_id,
                    "display_name": char_data.get("display_name", char_id),
                    "visual_description": char_data.get(
                        "visual_description", char_data.get("description", "")
                    ),
                }
                phases_from_bible = char_data.get("phases", [])

                # Gather props associated with this character
                all_props = bible.get("props", {})
                for prop_id, prop_data in all_props.items():
                    assoc = prop_data.get("associated_characters", [])
                    if char_id in assoc or char_id.lower() in [
                        c.lower() for c in assoc
                    ]:
                        char_props.append(
                            {
                                "prop_id": prop_id,
                                "description": prop_data.get("description", ""),
                                "state_notes": prop_data.get("state_notes", ""),
                            }
                        )
            except (json.JSONDecodeError, OSError):
                pass

        # Load screen test state
        st_state = load_screen_test_state(project_dir)
        char_st = st_state.characters.get(char_id)

        # Load casting state for hero_path and cast_status
        casting_state = self._load_casting_state(project_dir)
        char_cast = casting_state.get("characters", {}).get(char_id, {})
        hero_path = char_cast.get("hero_path", "")
        cast_status = char_cast.get("status", "")

        # Build phases array
        phases = []
        for p in phases_from_bible:
            phase_id = p.get("phase_id", "")
            # Get screen test state for this phase
            phase_st = char_st.phases.get(phase_id) if char_st else None

            phase_entry = {
                "phase_id": phase_id,
                "start_ep": p.get("start_ep"),
                "end_ep": p.get("end_ep"),
                "wardrobe_description": p.get("wardrobe_description", ""),
                "hair_makeup": p.get("hair_makeup", ""),
                "distinguishing_marks": p.get("distinguishing_marks", ""),
                "status": phase_st.status if phase_st else "empty",
                "locked_image": phase_st.locked_image if phase_st else None,
                "held_images": phase_st.held_images if phase_st else [],
                "director_note": phase_st.director_note if phase_st else None,
                "history_count": len(phase_st.generation_history) if phase_st else 0,
                "bible_synced": phase_st.bible_synced
                if phase_st and hasattr(phase_st, "bible_synced")
                else False,
            }
            phases.append(phase_entry)

        self._json_response(
            {
                "character": char_info,
                "hero_path": hero_path,
                "cast_status": cast_status,
                "anchor_phase": char_st.anchor_phase if char_st else None,
                "phases": phases,
                "props": char_props,
            }
        )

    def _api_screen_test_generate(self, project_name, project_dir, character, body):
        """POST /api/project/{name}/screen-test/{character}

        Generate images for all empty/rejected phases in a background thread.
        """
        import threading
        from recoil.pipeline._lib.screen_test import (
            load_screen_test_state,
            save_screen_test_state,
            CharacterScreenTest,
            PhaseState,
            record_generation,
        )
        from tools.screen_test_gen import build_phase_prompt, generate_phase_image

        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        char_id = character.upper()
        char_slug = slugify_asset_id(character)

        # Load Global Bible
        pp = _paths_for_project(project_name)
        bible_path = pp["bible_path"]

        if not bible_path or not bible_path.is_file():
            self._json_response({"error": "Global bible not found"}, 404)
            return

        try:
            bible = json.loads(bible_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError) as e:
            self._json_response({"error": f"Failed to load bible: {e}"}, 500)
            return

        char_data = bible.get("characters", {}).get(char_id, {})
        if not char_data:
            self._json_response(
                {"error": f"Character not found in bible: {char_id}"}, 404
            )
            return

        # Get hero_path from casting state
        casting_state = self._load_casting_state(project_dir)
        char_cast = casting_state.get("characters", {}).get(char_id, {})
        hero_rel = char_cast.get("hero_path", "")

        if not hero_rel:
            self._json_response(
                {"error": "No hero selected — cast character first"}, 400
            )
            return

        # Resolve hero path
        hero_abs = _resolve_output_rel(hero_rel)
        if not hero_abs.is_file():
            self._json_response({"error": f"Hero image not found: {hero_rel}"}, 404)
            return

        # Resolve three-quarter path (optional)
        tq_path = None
        turnaround = char_cast.get("turnaround", {})
        tq_info = turnaround.get("three_quarter", {})
        if tq_info.get("path"):
            tq_candidate = _resolve_output_rel(tq_info["path"])
            if tq_candidate.is_file():
                tq_path = tq_candidate

        # Identify phases needing generation (empty or rejected)
        st_state = load_screen_test_state(project_dir)
        char_st = st_state.characters.get(char_id, CharacterScreenTest())
        bible_phases = char_data.get("phases", [])

        # Gather props for this character from bible
        all_props = bible.get("props", {})
        char_props = []
        for prop_id, prop_data in all_props.items():
            assoc = prop_data.get("associated_characters", [])
            if char_id in assoc or char_id.lower() in [c.lower() for c in assoc]:
                char_props.append(
                    {
                        "prop_id": prop_id,
                        "description": prop_data.get("description", ""),
                        "state_notes": prop_data.get("state_notes", ""),
                    }
                )

        phases_to_generate = []
        for p in bible_phases:
            phase_id = p.get("phase_id", "")
            phase_st = char_st.phases.get(phase_id)
            status = phase_st.status if phase_st else "empty"
            if status in ("empty", "rejected"):
                phases_to_generate.append(p)

        if not phases_to_generate:
            self._json_response(
                {
                    "status": "nothing_to_generate",
                    "character_id": char_id,
                    "message": "All phases already have images (generated/held/locked).",
                }
            )
            return

        # Mark phases as "generating" immediately
        for p in phases_to_generate:
            phase_id = p.get("phase_id", "")
            if phase_id not in char_st.phases:
                char_st.phases[phase_id] = PhaseState(phase_id=phase_id)
            char_st.phases[phase_id].status = "generating"

        st_state.characters[char_id] = char_st
        save_screen_test_state(project_dir, st_state)

        # Output directory
        screen_test_dir = pp["character_refs_dir"] / char_slug / "screen_test"

        def _run():
            for p in phases_to_generate:
                phase_id = p.get("phase_id", "")
                try:
                    # Build prompt (with props + show-level aesthetic directives)
                    prompt = build_phase_prompt(
                        char=char_data,
                        phase=p,
                        props=char_props,
                        aesthetic_directives=bible.get("aesthetic_directives"),
                    )

                    # Determine version number
                    current_state = load_screen_test_state(project_dir)
                    current_char = current_state.characters.get(
                        char_id, CharacterScreenTest()
                    )
                    current_phase = current_char.phases.get(
                        phase_id, PhaseState(phase_id=phase_id)
                    )
                    version = len(current_phase.generation_history) + 1

                    # Generate image
                    output_path = screen_test_dir / f"{phase_id}_v{version}.png"

                    # Get anchor path if set
                    anchor_path = None
                    if (
                        current_char.anchor_phase
                        and current_char.anchor_phase != phase_id
                    ):
                        anchor_phase_st = current_char.phases.get(
                            current_char.anchor_phase
                        )
                        if anchor_phase_st and anchor_phase_st.locked_image:
                            anchor_candidate = _resolve_output_rel(
                                anchor_phase_st.locked_image
                            )
                            if anchor_candidate.is_file():
                                anchor_path = anchor_candidate

                    success = generate_phase_image(
                        hero_path=hero_abs,
                        three_quarter_path=tq_path,
                        prompt=prompt,
                        output_path=output_path,
                        anchor_path=anchor_path,
                    )

                    if success:
                        # Reload state, record generation, save
                        current_state = load_screen_test_state(project_dir)
                        current_char = current_state.characters.get(
                            char_id, CharacterScreenTest()
                        )
                        if phase_id not in current_char.phases:
                            current_char.phases[phase_id] = PhaseState(
                                phase_id=phase_id
                            )
                        rel_path = f"output/refs/characters/{char_slug}/screen_test/{phase_id}_v{version}.png"
                        record_generation(
                            current_char.phases[phase_id], rel_path, prompt
                        )
                        current_state.characters[char_id] = current_char
                        save_screen_test_state(project_dir, current_state)
                    else:
                        # Mark as empty again on failure
                        current_state = load_screen_test_state(project_dir)
                        current_char = current_state.characters.get(
                            char_id, CharacterScreenTest()
                        )
                        if phase_id in current_char.phases:
                            current_char.phases[phase_id].status = "empty"
                        current_state.characters[char_id] = current_char
                        save_screen_test_state(project_dir, current_state)
                        print(
                            f"  [WARN] Screen test generation failed for {char_id}/{phase_id}"
                        )

                except Exception as e:
                    print(
                        f"  [WARN] Screen test generation error for {char_id}/{phase_id}: {e}"
                    )
                    try:
                        current_state = load_screen_test_state(project_dir)
                        current_char = current_state.characters.get(
                            char_id, CharacterScreenTest()
                        )
                        if phase_id in current_char.phases:
                            current_char.phases[phase_id].status = "empty"
                        current_state.characters[char_id] = current_char
                        save_screen_test_state(project_dir, current_state)
                    except Exception:
                        pass

        thread = threading.Thread(target=_run, daemon=True)
        thread.start()

        self._json_response(
            {
                "status": "generating",
                "character_id": char_id,
                "phases": [p.get("phase_id", "") for p in phases_to_generate],
                "message": f"Generating {len(phases_to_generate)} phase(s). Poll GET to check status.",
            }
        )

    def _api_screen_test_reroll(
        self, project_name, project_dir, character, phase_id, body
    ):
        """POST /api/project/{name}/screen-test/{character}/{phase}/reroll

        Re-roll one phase with optional director's note and deep mode.
        """
        import threading
        from recoil.pipeline._lib.screen_test import (
            load_screen_test_state,
            save_screen_test_state,
            CharacterScreenTest,
            PhaseState,
            record_generation,
        )
        from tools.screen_test_gen import (
            build_phase_prompt,
            enrich_director_note,
            generate_phase_image,
        )
        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        char_id = character.upper()
        char_slug = slugify_asset_id(character)
        director_note = body.get("director_note", "")
        deep = body.get("deep", False)
        num_images = 4 if deep else 1

        # Load Global Bible
        pp = _paths_for_project(project_name)
        bible_path = pp["bible_path"]

        if not bible_path or not bible_path.is_file():
            self._json_response({"error": "Global bible not found"}, 404)
            return

        try:
            bible = json.loads(bible_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError) as e:
            self._json_response({"error": f"Failed to load bible: {e}"}, 500)
            return

        char_data = bible.get("characters", {}).get(char_id, {})
        if not char_data:
            self._json_response(
                {"error": f"Character not found in bible: {char_id}"}, 404
            )
            return

        # Find the phase in bible
        bible_phase = None
        for p in char_data.get("phases", []):
            if p.get("phase_id") == phase_id:
                bible_phase = p
                break

        if not bible_phase:
            self._json_response({"error": f"Phase not found in bible: {phase_id}"}, 404)
            return

        # Gather props for this character from bible
        all_props = bible.get("props", {})
        char_props = []
        for prop_id, prop_data in all_props.items():
            assoc = prop_data.get("associated_characters", [])
            if char_id in assoc or char_id.lower() in [c.lower() for c in assoc]:
                char_props.append(
                    {
                        "prop_id": prop_id,
                        "description": prop_data.get("description", ""),
                        "state_notes": prop_data.get("state_notes", ""),
                    }
                )

        # Get hero_path from casting state
        casting_state = self._load_casting_state(project_dir)
        char_cast = casting_state.get("characters", {}).get(char_id, {})
        hero_rel = char_cast.get("hero_path", "")

        if not hero_rel:
            self._json_response(
                {"error": "No hero selected — cast character first"}, 400
            )
            return

        hero_abs = _resolve_output_rel(hero_rel)
        if not hero_abs.is_file():
            self._json_response({"error": f"Hero image not found: {hero_rel}"}, 404)
            return

        # Resolve three-quarter path (optional)
        tq_path = None
        turnaround = char_cast.get("turnaround", {})
        tq_info = turnaround.get("three_quarter", {})
        if tq_info.get("path"):
            tq_candidate = _resolve_output_rel(tq_info["path"])
            if tq_candidate.is_file():
                tq_path = tq_candidate

        # Mark as generating
        st_state = load_screen_test_state(project_dir)
        char_st = st_state.characters.get(char_id, CharacterScreenTest())
        if phase_id not in char_st.phases:
            char_st.phases[phase_id] = PhaseState(phase_id=phase_id)
        char_st.phases[phase_id].status = "generating"
        if director_note:
            char_st.phases[phase_id].director_note = director_note
        st_state.characters[char_id] = char_st
        save_screen_test_state(project_dir, st_state)

        screen_test_dir = pp["character_refs_dir"] / char_slug / "screen_test"

        def _run():
            try:
                # Enrich director note if provided
                enriched_note = None
                if director_note:
                    try:
                        enriched_note = enrich_director_note(
                            char_data.get("visual_description", ""),
                            director_note,
                        )
                    except Exception as e:
                        print(f"  [WARN] Director note enrichment failed: {e}")
                        enriched_note = director_note

                prompt = build_phase_prompt(
                    char=char_data,
                    phase=bible_phase,
                    enriched_note=enriched_note,
                    props=char_props,
                    aesthetic_directives=bible.get("aesthetic_directives"),
                )

                # Get anchor path
                current_state = load_screen_test_state(project_dir)
                current_char = current_state.characters.get(
                    char_id, CharacterScreenTest()
                )
                anchor_path = None
                if current_char.anchor_phase and current_char.anchor_phase != phase_id:
                    anchor_phase_st = current_char.phases.get(current_char.anchor_phase)
                    if anchor_phase_st and anchor_phase_st.locked_image:
                        anchor_candidate = _resolve_output_rel(
                            anchor_phase_st.locked_image
                        )
                        if anchor_candidate.is_file():
                            anchor_path = anchor_candidate

                for i in range(num_images):
                    current_state = load_screen_test_state(project_dir)
                    current_char = current_state.characters.get(
                        char_id, CharacterScreenTest()
                    )
                    current_phase = current_char.phases.get(
                        phase_id, PhaseState(phase_id=phase_id)
                    )
                    version = len(current_phase.generation_history) + 1

                    output_path = screen_test_dir / f"{phase_id}_v{version}.png"

                    success = generate_phase_image(
                        hero_path=hero_abs,
                        three_quarter_path=tq_path,
                        prompt=prompt,
                        output_path=output_path,
                        anchor_path=anchor_path,
                    )

                    if success:
                        current_state = load_screen_test_state(project_dir)
                        current_char = current_state.characters.get(
                            char_id, CharacterScreenTest()
                        )
                        if phase_id not in current_char.phases:
                            current_char.phases[phase_id] = PhaseState(
                                phase_id=phase_id
                            )
                        rel_path = f"output/refs/characters/{char_slug}/screen_test/{phase_id}_v{version}.png"
                        record_generation(
                            current_char.phases[phase_id],
                            rel_path,
                            prompt,
                            note=director_note or None,
                        )
                        current_state.characters[char_id] = current_char
                        save_screen_test_state(project_dir, current_state)
                    else:
                        print(
                            f"  [WARN] Screen test reroll failed for {char_id}/{phase_id} v{version}"
                        )

                # If all generations failed, reset status
                final_state = load_screen_test_state(project_dir)
                final_char = final_state.characters.get(char_id, CharacterScreenTest())
                final_phase = final_char.phases.get(phase_id)
                if final_phase and final_phase.status == "generating":
                    final_phase.status = (
                        "empty" if not final_phase.generation_history else "generated"
                    )
                    final_state.characters[char_id] = final_char
                    save_screen_test_state(project_dir, final_state)

            except Exception as e:
                print(
                    f"  [WARN] Screen test reroll error for {char_id}/{phase_id}: {e}"
                )
                try:
                    current_state = load_screen_test_state(project_dir)
                    current_char = current_state.characters.get(
                        char_id, CharacterScreenTest()
                    )
                    if phase_id in current_char.phases:
                        ph = current_char.phases[phase_id]
                        ph.status = (
                            "empty" if not ph.generation_history else "generated"
                        )
                    current_state.characters[char_id] = current_char
                    save_screen_test_state(project_dir, current_state)
                except Exception:
                    pass

        thread = threading.Thread(target=_run, daemon=True)
        thread.start()

        self._json_response(
            {
                "status": "generating",
                "character_id": char_id,
                "phase_id": phase_id,
                "num_images": num_images,
                "director_note": director_note or None,
                "message": f"Re-rolling {phase_id} with {num_images} image(s). Poll GET to check status.",
            }
        )

    def _api_screen_test_verdict(
        self, project_name, project_dir, character, phase_id, body
    ):
        """POST /api/project/{name}/screen-test/{character}/{phase}/verdict

        Apply lock/hold/reject to a phase.
        """
        from recoil.pipeline._lib.screen_test import (
            load_screen_test_state,
            save_screen_test_state,
            apply_verdict,
        )

        char_id = character.upper()
        action = body.get("action", "")

        if action not in ("lock", "hold", "reject"):
            self._json_response(
                {"error": f"Invalid action: {action}. Must be lock, hold, or reject."},
                400,
            )
            return

        st_state = load_screen_test_state(project_dir)
        char_st = st_state.characters.get(char_id)

        if not char_st:
            self._json_response(
                {"error": f"No screen test state for character: {char_id}"}, 404
            )
            return

        phase_st = char_st.phases.get(phase_id)
        if not phase_st:
            self._json_response(
                {"error": f"No screen test state for phase: {phase_id}"}, 404
            )
            return

        try:
            apply_verdict(phase_st, action)
        except ValueError as e:
            self._json_response({"error": str(e)}, 400)
            return

        save_screen_test_state(project_dir, st_state)

        self._json_response(
            {
                "status": "saved",
                "character_id": char_id,
                "phase_id": phase_id,
                "action": action,
                "new_status": phase_st.status,
            }
        )

    def _api_screen_test_set_anchor(self, project_name, project_dir, character, body):
        """POST /api/project/{name}/screen-test/{character}/set-anchor

        Mark a locked phase as the style anchor.
        """
        from recoil.pipeline._lib.screen_test import (
            load_screen_test_state,
            save_screen_test_state,
        )

        char_id = character.upper()
        phase_id = body.get("phase", "")

        if not phase_id:
            self._json_response({"error": "Missing 'phase' in request body"}, 400)
            return

        st_state = load_screen_test_state(project_dir)
        char_st = st_state.characters.get(char_id)

        if not char_st:
            self._json_response(
                {"error": f"No screen test state for character: {char_id}"}, 404
            )
            return

        phase_st = char_st.phases.get(phase_id)
        if not phase_st:
            self._json_response({"error": f"Phase not found: {phase_id}"}, 404)
            return

        if phase_st.status != "locked":
            self._json_response(
                {
                    "error": f"Phase '{phase_id}' is not locked (status: {phase_st.status}). Lock it first."
                },
                400,
            )
            return

        char_st.anchor_phase = phase_id
        save_screen_test_state(project_dir, st_state)

        self._json_response(
            {
                "status": "saved",
                "character_id": char_id,
                "anchor_phase": phase_id,
                "message": f"Phase '{phase_id}' set as style anchor for {char_id}.",
            }
        )

    # ── Visual Sync endpoints ──────────────────────────────────────

    def _api_propose_visual_sync(self, project_name, project_dir, body):
        """POST /api/project/{name}/bible/propose-visual-sync

        Vision AI analyzes an approved image and proposes bible text updates.
        Body: { char_id, phase_id (optional), image_path, current_text, sync_type }
        Returns: { proposed_changes: {...}, sync_type }
        """
        from recoil.pipeline._lib.visual_sync import propose_visual_sync

        char_id = body.get("char_id")
        image_rel = body.get("image_path", "")
        current_text = body.get("current_text", {})
        sync_type = body.get("sync_type", "phase")

        if not char_id or not image_rel:
            self._json_response({"error": "char_id and image_path required"}, 400)
            return

        # Resolve image path (project output first, starsend fallback)
        image_abs = str(_resolve_output_rel(image_rel))

        if not Path(image_abs).exists():
            self._json_response({"error": f"Image not found: {image_rel}"}, 404)
            return

        try:
            proposed = propose_visual_sync(image_abs, current_text, sync_type)
            self._json_response({"proposed_changes": proposed, "sync_type": sync_type})
        except Exception as e:
            self._json_response({"error": str(e)}, 500)

    def _api_casting_bible_synced(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/bible-synced

        Mark a character's casting hero as bible-synced.
        """
        char_id = body.get("character_id", "").upper()
        if not char_id:
            self._json_response({"error": "Missing character_id"}, 400)
            return

        state = self._load_casting_state(project_dir)
        char_state = state.get("characters", {}).get(char_id)
        if not char_state:
            self._json_response(
                {"error": f"Character {char_id} not in casting state"}, 404
            )
            return

        char_state["bible_synced"] = True
        self._save_casting_state(project_dir, state)
        self._json_response({"ok": True, "character_id": char_id, "bible_synced": True})

    # ── URSS GridSession Endpoints ───────────────────────────────────

    def _api_grid_session_create(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/grid-session"""
        from recoil.pipeline._lib.ref_selector import load_descriptor, extract_mood_text

        asset_type = body.get("asset_type")
        if not asset_type:
            self._json_response({"error": "asset_type required"}, 400)
            return

        parent_context = body.get("parent_context", {})
        anchor_path = body.get("anchor_image_path")
        anchor_source = None
        mood_text = None

        if anchor_path:
            anchor_source = "provided"

        # Create session immediately (no blocking API calls)
        session = self._create_grid_session(
            project_dir,
            asset_type,
            parent_context,
            anchor_path=anchor_path,
            anchor_source=anchor_source,
            mood_text="",
        )
        session_id = session["session_id"]

        # Run vision extraction in background if needed
        if anchor_path:
            descriptor = load_descriptor(asset_type)
            ref_strategy = descriptor.get("ref_handling", {}).get("strategy", "")

            if ref_strategy in ("vision_extraction", "hybrid"):
                abs_path = str(_resolve_output_rel(anchor_path))

                if Path(abs_path).exists():
                    import threading

                    def _extract():
                        try:
                            mt = extract_mood_text(abs_path)
                            self._update_grid_session(
                                project_dir,
                                session_id,
                                {
                                    "anchor": {**session["anchor"], "mood_text": mt},
                                },
                            )
                            print(f"[URSS] Vision extraction done ({len(mt)} chars)")
                        except Exception as e:
                            print(f"[URSS] Vision extraction failed: {e}")

                    threading.Thread(target=_extract, daemon=True).start()

        self._json_response({"session": session})

    def _api_grid_session_get(self, project_name, project_dir, session_id):
        """GET /api/project/{name}/casting/grid-session/{id}"""
        session = self._get_grid_session(project_dir, session_id)
        if not session:
            self._json_response({"error": f"Session {session_id} not found"}, 404)
            return
        self._json_response({"session": session})

    def _api_grid_session_action(self, project_name, project_dir, session_id, body):
        """POST /api/project/{name}/casting/grid-session/{id}/action"""
        session = self._get_grid_session(project_dir, session_id)
        if not session:
            self._json_response({"error": f"Session {session_id} not found"}, 404)
            return

        slot = body.get("slot")
        action = body.get("action")
        if slot is None or action not in ("reject", "keep", "lock"):
            self._json_response(
                {"error": "slot (int) and action (reject|keep|lock) required"}, 400
            )
            return

        candidates = session.get("candidates", [])
        if slot < 0 or slot >= len(candidates):
            self._json_response({"error": f"Invalid slot {slot}"}, 400)
            return

        if action == "lock":
            candidates[slot]["state"] = "locked"
            session["hero_locked"] = True
            session["hero_path"] = candidates[slot]["path"]
            session["status"] = "hero_locked"
        else:
            candidates[slot]["state"] = action

        self._update_grid_session(
            project_dir,
            session_id,
            {
                "candidates": candidates,
                "hero_locked": session.get("hero_locked", False),
                "hero_path": session.get("hero_path"),
                "status": session.get("status", "active"),
            },
        )

        self._json_response(
            {"session": self._get_grid_session(project_dir, session_id)}
        )

    def _api_grid_session_reroll(self, project_name, project_dir, session_id, body):
        """POST /api/project/{name}/casting/grid-session/{id}/reroll"""
        session = self._get_grid_session(project_dir, session_id)
        if not session:
            self._json_response({"error": f"Session {session_id} not found"}, 404)
            return

        override_text = body.get("override_text", "").strip()
        # Allow client to send edited mood text
        edited_mood = body.get("mood_text")

        overrides = session.get("user_overrides", [])
        if override_text:
            overrides.append(override_text)
        collapsed = ", ".join(overrides) if overrides else ""

        update = {
            "status": "generating",
            "user_overrides": overrides,
            "collapsed_override": collapsed,
        }
        # Persist edited mood text into the session anchor
        if edited_mood is not None:
            anchor = dict(session.get("anchor", {}))
            anchor["mood_text"] = edited_mood
            update["anchor"] = anchor
        self._update_grid_session(project_dir, session_id, update)

        self._json_response({"status": "generating", "session_id": session_id})

        pp = _paths_for_project(project_name)
        import threading

        def _run():
            try:
                from recoil.pipeline._lib.ref_selector import generate_candidates

                descriptor = dict(session["descriptor"])  # copy to avoid mutating state
                # ADR-LV09: clamp legacy high-temperature sessions for casting grids
                if (
                    session.get("asset_type") == "character"
                    and descriptor.get("temperature", 0) > 0.70
                ):
                    descriptor["temperature"] = 0.68
                parent = session.get("parent_context", {})
                description = self._get_description_for_session(project_dir, session)
                candidates = session.get("candidates", [])
                slots_to_fill = [
                    c["slot"]
                    for c in candidates
                    if c["state"] in ("empty", "reject", "rejected", "new")
                ]

                if not slots_to_fill:
                    self._update_grid_session(
                        project_dir, session_id, {"status": "active"}
                    )
                    return

                from recoil.pipeline._lib.taxonomy import slugify_asset_id

                char_id = slugify_asset_id(
                    parent.get("character_id", "")
                    or parent.get("location_id", "")
                    or "unknown"
                )
                asset_type = session["asset_type"]
                out_dir = pp["refs_dir"] / _asset_type_dir(asset_type) / char_id

                result = generate_candidates(
                    descriptor=descriptor,
                    description=description,
                    output_dir=out_dir,
                    prefix=f"{char_id}_{asset_type}_r{session['re_roll_count'] + 1}",
                    mood_text=session.get("anchor", {}).get("mood_text", ""),
                    user_override=collapsed,
                    anchor_image_path=session.get("anchor", {}).get("path"),
                    gender=self._get_gender_for_session(project_dir, session),
                )

                new_panels = result.get("panels", [])
                gen_num = session["re_roll_count"] + 1
                for i, slot_idx in enumerate(slots_to_fill):
                    if i < len(new_panels):
                        rel_path = _to_relative_output_path(new_panels[i])
                        candidates[slot_idx] = {
                            "slot": slot_idx,
                            "path": rel_path,
                            "state": "new",
                            "re_roll_generation": gen_num,
                        }

                self._update_grid_session(
                    project_dir,
                    session_id,
                    {
                        "candidates": candidates,
                        "re_roll_count": gen_num,
                        "cost": session.get("cost", 0) + result.get("cost", 0),
                        "status": "active",
                    },
                )

            except Exception as e:
                print(f"[URSS] Grid session reroll failed: {e}")
                self._update_grid_session(project_dir, session_id, {"status": "error"})

        threading.Thread(target=_run, daemon=True).start()

    def _api_grid_session_lock_hero(self, project_name, project_dir, session_id, body):
        """POST /api/project/{name}/casting/grid-session/{id}/lock-hero"""
        session = self._get_grid_session(project_dir, session_id)
        if not session:
            self._json_response({"error": f"Session {session_id} not found"}, 404)
            return

        # Allow locking the anchor image directly (no slot needed)
        if body.get("anchor"):
            hero_path = session.get("anchor", {}).get("path")
            if not hero_path:
                self._json_response({"error": "Session has no anchor image"}, 400)
                return
            self._update_grid_session(
                project_dir,
                session_id,
                {
                    "hero_locked": True,
                    "hero_path": hero_path,
                    "status": "hero_locked",
                },
            )
        else:
            slot = body.get("slot")
            candidates = session.get("candidates", [])
            if slot is None or slot < 0 or slot >= len(candidates):
                self._json_response({"error": "Valid slot required"}, 400)
                return

            hero_path = candidates[slot]["path"]
            if not hero_path:
                self._json_response({"error": "Candidate has no image"}, 400)
                return

            candidates[slot]["state"] = "locked"
            self._update_grid_session(
                project_dir,
                session_id,
                {
                    "candidates": candidates,
                    "hero_locked": True,
                    "hero_path": hero_path,
                    "status": "hero_locked",
                },
            )

        # Beauty pass is now opt-in via separate /beauty-pass endpoint
        self._update_casting_hero(project_dir, session, session_id, hero_path)

        self._json_response(
            {
                "session": self._get_grid_session(project_dir, session_id),
                "hero_path": hero_path,
            }
        )

    def _api_grid_session_beauty_pass(
        self, project_name, project_dir, session_id, body
    ):
        """POST /api/project/{name}/casting/grid-session/{id}/beauty-pass"""
        session = self._get_grid_session(project_dir, session_id)
        if not session:
            self._json_response({"error": f"Session {session_id} not found"}, 404)
            return
        if not session.get("hero_locked"):
            self._json_response(
                {"error": "Hero must be locked before running beauty pass"}, 400
            )
            return

        hero_path = session.get("hero_path")
        if not hero_path:
            self._json_response({"error": "No hero path set"}, 400)
            return

        from recoil.pipeline._lib.ref_selector import load_descriptor, run_beauty_pass

        descriptor = load_descriptor(session["asset_type"])
        if not descriptor.get("beauty_pass"):
            self._json_response(
                {"error": "Beauty pass not supported for this asset type"}, 400
            )
            return

        description = self._get_description_for_session(project_dir, session)
        hero_abs = self._resolve_output_path(hero_path)
        bp_temp = descriptor.get("beauty_pass_temp", 0.2)
        bp_output = Path(hero_abs).parent / f"{Path(hero_abs).stem}_beauty.png"

        def _run_bp():
            try:
                result = run_beauty_pass(
                    hero_image_path=str(hero_abs),
                    description=description,
                    output_path=bp_output,
                    temperature=bp_temp,
                )
                if result["path"]:
                    bp_rel = _to_relative_output_path(result["path"])
                    self._update_grid_session(
                        project_dir,
                        session_id,
                        {
                            "beauty_pass_path": bp_rel,
                            "beauty_pass_cost": result["cost"],
                            "beauty_pass_model": result["model"],
                            "status": "beauty_complete",
                        },
                    )
                    self._update_casting_hero(project_dir, session, session_id, bp_rel)
                else:
                    self._update_grid_session(
                        project_dir,
                        session_id,
                        {
                            "beauty_pass_error": "No image returned",
                            "status": "beauty_failed",
                        },
                    )
            except Exception as e:
                print(f"[URSS] Beauty pass error: {e}")
                self._update_grid_session(
                    project_dir,
                    session_id,
                    {
                        "beauty_pass_error": str(e),
                        "status": "beauty_failed",
                    },
                )

        import threading

        threading.Thread(target=_run_bp, daemon=True).start()
        self._update_grid_session(project_dir, session_id, {"status": "beauty_running"})

        self._json_response(
            {"session": self._get_grid_session(project_dir, session_id)}
        )

    def _api_grid_session_update_overrides(
        self, project_name, project_dir, session_id, body
    ):
        """POST /api/project/{name}/casting/grid-session/{id}/update-overrides"""
        session = self._get_grid_session(project_dir, session_id)
        if not session:
            self._json_response({"error": f"Session {session_id} not found"}, 404)
            return

        new_text = body.get("collapsed_override", "").strip()
        if new_text:
            overrides = [s.strip() for s in new_text.split(",") if s.strip()]
        else:
            overrides = []
        collapsed = ", ".join(overrides)

        self._update_grid_session(
            project_dir,
            session_id,
            {
                "user_overrides": overrides,
                "collapsed_override": collapsed,
            },
        )

        updated = self._get_grid_session(project_dir, session_id)
        self._json_response({"session": updated})

    # ── URSS Helper functions ────────────────────────────────────────

    def _api_grid_session_unlock(self, project_name, project_dir, session_id):
        """POST /api/project/{name}/casting/grid-session/{id}/unlock"""
        session = self._get_grid_session(project_dir, session_id)
        if not session:
            self._json_response({"error": f"Session {session_id} not found"}, 404)
            return

        # Reset locked candidate back to 'new'
        candidates = session.get("candidates", [])
        for c in candidates:
            if c["state"] == "locked":
                c["state"] = "new"

        self._update_grid_session(
            project_dir,
            session_id,
            {
                "hero_locked": False,
                "hero_path": None,
                "status": "active",
                "candidates": candidates,
            },
        )

        # Restore original hero in casting state
        parent = session.get("parent_context", {})
        char_id = parent.get("character_id", "").upper()
        if char_id:
            state = self._load_casting_state(project_dir)
            cs = state.get("characters", {}).get(char_id, {})
            # Revert to the anchor image as hero
            anchor_path = session.get("anchor", {}).get("path")
            if anchor_path:
                cs["hero_path"] = anchor_path
                cs["status"] = "hero_selected"
                state.setdefault("characters", {})[char_id] = cs
                self._save_casting_state(project_dir, state)

        updated = self._get_grid_session(project_dir, session_id)
        self._json_response({"session": updated})

    def _get_description_for_session(self, project_dir, session):
        """Get the text description for a grid session from the bible."""
        parent = session.get("parent_context", {})
        asset_type = session["asset_type"]

        pp = _paths_for_project(project_dir.name)
        bible_path = pp["bible_path"]
        bible = {}
        if bible_path and bible_path.is_file():
            bible = json.loads(bible_path.read_text(encoding="utf-8"))

        if asset_type == "character":
            char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
            # ADR-LV02: prefer casting_description (no props) for casting grids
            return char.get("casting_description") or char.get("visual_description", "")
        elif asset_type == "location":
            loc = bible.get("locations", {}).get(parent.get("location_id", ""), {})
            return loc.get("description", "")
        elif asset_type == "wardrobe":
            char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
            phases = char.get("phases", [])
            phase_id = parent.get("phase_id")
            for p in phases:
                if p.get("phase_id") == phase_id:
                    return p.get("wardrobe_description", "")
            return phases[0].get("wardrobe_description", "") if phases else ""
        elif asset_type == "hair_makeup":
            char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
            phases = char.get("phases", [])
            phase_id = parent.get("phase_id")
            for p in phases:
                if p.get("phase_id") == phase_id:
                    return p.get("hair_makeup", "")
            return phases[0].get("hair_makeup", "") if phases else ""
        elif asset_type == "props":
            return parent.get("prop_description", "")
        return ""

    def _resolve_output_path(self, rel_path):
        """Resolve an output/-relative path to an absolute path.
        Uses project output only — no cross-project fallback."""
        if rel_path.startswith("output/"):
            stripped = rel_path.replace("output/", "", 1)
            project_path = _PROJECT_OUTPUT / stripped
            return str(project_path)
        return str(Path(rel_path).resolve())

    def _update_casting_hero(self, project_dir, session, session_id, hero_path):
        """Update backward-compat casting_state.characters with hero path."""
        parent = session.get("parent_context", {})
        char_id = parent.get("character_id", "").upper()
        if char_id and session["asset_type"] == "character":
            state = self._load_casting_state(project_dir)
            if "characters" not in state:
                state["characters"] = {}
            if char_id not in state["characters"]:
                state["characters"][char_id] = {}
            state["characters"][char_id]["hero_path"] = hero_path
            state["characters"][char_id]["status"] = "hero_selected"
            state["characters"][char_id]["hero_source"] = "grid_session"
            state["characters"][char_id]["bible_synced"] = False
            state["characters"][char_id]["grid_session_id"] = session_id
            self._save_casting_state(project_dir, state)

    def _get_gender_for_session(self, project_dir, session):
        """Get gender from bible for character sessions."""
        parent = session.get("parent_context", {})
        if session["asset_type"] not in ("character", "wardrobe", "hair_makeup"):
            return None
        pp = _paths_for_project(project_dir.name)
        bible_path = pp["bible_path"]
        bible = {}
        if bible_path and bible_path.is_file():
            bible = json.loads(bible_path.read_text(encoding="utf-8"))
        char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
        return char.get("gender")

    # ── Wardrobe Intent Gate Endpoints ──────────────────────────────

    def _api_wi_propose_philosophy(self, project_name, project_dir, body):
        """POST /api/project/{name}/wardrobe-intent/propose-philosophy

        Generate 3 series-level wardrobe philosophy options.
        """
        from recoil.pipeline._lib.ref_selector import propose_wardrobe_philosophy

        pp = _paths_for_project(project_name)
        project_dir = pp["project_dir"]

        # Load treatment and series bible
        treatment = ""
        series_bible = ""
        treatment_path = project_dir / "treatment.md"
        bible_text_path = project_dir / "bible" / "series_bible.md"
        if treatment_path.is_file():
            treatment = treatment_path.read_text(encoding="utf-8")
        if bible_text_path.is_file():
            series_bible = bible_text_path.read_text(encoding="utf-8")

        if not treatment and not series_bible:
            self._json_response({"error": "No treatment or series bible found"}, 400)
            return

        options = propose_wardrobe_philosophy(treatment, series_bible)
        self._json_response({"options": options})

    def _api_wi_approve_philosophy(self, project_name, project_dir, body):
        """POST /api/project/{name}/wardrobe-intent/approve-philosophy

        Save the director's chosen wardrobe philosophy to the bible.
        Body: { "philosophy": "...", "source": "auto|director|edited" }
        """
        philosophy = body.get("philosophy", "").strip()
        if not philosophy:
            self._json_response({"error": "philosophy required"}, 400)
            return

        pp = _paths_for_project(project_name)
        bible_path = pp["bible_path"]
        if not bible_path or not bible_path.is_file():
            self._json_response({"error": "Global bible not found"}, 404)
            return

        bible = json.loads(bible_path.read_text(encoding="utf-8"))
        bible["wardrobe_philosophy"] = philosophy
        bible["wardrobe_philosophy_approved"] = True
        bible_path.write_text(
            json.dumps(bible, indent=2, default=str), encoding="utf-8"
        )

        self._json_response(
            {
                "status": "approved",
                "wardrobe_philosophy": philosophy,
            }
        )

    def _api_wi_propose_theses(self, project_name, project_dir, body):
        """POST /api/project/{name}/wardrobe-intent/propose-theses

        Generate 3 thesis options for a character.
        Body: { "character_id": "TORCH" }
        """
        from recoil.pipeline._lib.ref_selector import propose_character_theses

        character_id = body.get("character_id", "").upper()
        if not character_id:
            self._json_response({"error": "character_id required"}, 400)
            return

        pp = _paths_for_project(project_name)
        bible_path = pp["bible_path"]
        if not bible_path or not bible_path.is_file():
            self._json_response({"error": "Global bible not found"}, 404)
            return

        bible = json.loads(bible_path.read_text(encoding="utf-8"))
        char_data = bible.get("characters", {}).get(character_id)
        if not char_data:
            self._json_response(
                {"error": f"Character {character_id} not in bible"}, 404
            )
            return

        series_philosophy = bible.get("wardrobe_philosophy", "")
        char_description = char_data.get("visual_description", "")
        phases = char_data.get("phases", [])

        # Extract phase boundaries only (no wardrobe descriptions)
        phase_boundaries = [
            {
                "phase_id": p.get("phase_id", ""),
                "start_ep": p.get("start_ep"),
                "end_ep": p.get("end_ep"),
                "phase_trigger_event": p.get("phase_trigger_event", ""),
            }
            for p in phases
        ]

        # Load episode arc for emotional timeline
        episode_arc = ""
        arc_path = pp["project_dir"] / "bible" / "episode_arc.md"
        if arc_path.is_file():
            episode_arc = arc_path.read_text(encoding="utf-8")

        options = propose_character_theses(
            character_id=character_id,
            char_description=char_description,
            phase_boundaries=phase_boundaries,
            series_philosophy=series_philosophy,
            episode_arc=episode_arc,
        )
        self._json_response({"character_id": character_id, "options": options})

    def _api_wi_approve_thesis(self, project_name, project_dir, body):
        """POST /api/project/{name}/wardrobe-intent/approve-thesis

        Save the director's chosen thesis for a character.
        Body: { "character_id": "TORCH", "thesis": "...", "source": "auto|director|edited", "vision": "..." }
        """
        character_id = body.get("character_id", "").upper()
        thesis = body.get("thesis", "").strip()
        source = body.get("source", "auto")
        vision = body.get("vision", "").strip()

        if not character_id:
            self._json_response({"error": "character_id required"}, 400)
            return
        if not thesis:
            self._json_response({"error": "thesis required"}, 400)
            return

        pp = _paths_for_project(project_name)
        bible_path = pp["bible_path"]
        if not bible_path or not bible_path.is_file():
            self._json_response({"error": "Global bible not found"}, 404)
            return

        bible = json.loads(bible_path.read_text(encoding="utf-8"))
        char_data = bible.get("characters", {}).get(character_id)
        if not char_data:
            self._json_response(
                {"error": f"Character {character_id} not in bible"}, 404
            )
            return

        char_data["wardrobe_arc_thesis"] = thesis
        char_data["wardrobe_arc_thesis_approved"] = True
        char_data["wardrobe_arc_thesis_source"] = source
        if vision:
            char_data["wardrobe_arc_vision"] = vision

        bible_path.write_text(
            json.dumps(bible, indent=2, default=str), encoding="utf-8"
        )

        self._json_response(
            {
                "status": "approved",
                "character_id": character_id,
                "thesis": thesis,
                "source": source,
            }
        )

    def _api_wi_rewrite_phases(self, project_name, project_dir, body):
        """POST /api/project/{name}/wardrobe-intent/rewrite-phases

        Preview rewritten wardrobe descriptions from an approved thesis.
        Does NOT write to bible — returns preview for director review.
        Body: { "character_id": "TORCH", "director_hint": "..." }
        """
        from recoil.pipeline._lib.ref_selector import rewrite_wardrobe_phases

        character_id = body.get("character_id", "").upper()
        director_hint = body.get("director_hint", "").strip()

        if not character_id:
            self._json_response({"error": "character_id required"}, 400)
            return

        pp = _paths_for_project(project_name)
        bible_path = pp["bible_path"]
        if not bible_path or not bible_path.is_file():
            self._json_response({"error": "Global bible not found"}, 404)
            return

        bible = json.loads(bible_path.read_text(encoding="utf-8"))
        char_data = bible.get("characters", {}).get(character_id)
        if not char_data:
            self._json_response(
                {"error": f"Character {character_id} not in bible"}, 404
            )
            return

        thesis = char_data.get("wardrobe_arc_thesis", "")
        if not thesis:
            self._json_response(
                {
                    "error": f"No thesis approved for {character_id}. Approve a thesis first."
                },
                400,
            )
            return

        series_philosophy = bible.get("wardrobe_philosophy", "")
        char_description = char_data.get("visual_description", "")
        phases = char_data.get("phases", [])

        # Extract phase boundaries only (no wardrobe descriptions)
        phase_boundaries = [
            {
                "phase_id": p.get("phase_id", ""),
                "start_ep": p.get("start_ep"),
                "end_ep": p.get("end_ep"),
                "phase_trigger_event": p.get("phase_trigger_event", ""),
            }
            for p in phases
        ]

        # Load episode arc
        episode_arc = ""
        arc_path = pp["project_dir"] / "bible" / "episode_arc.md"
        if arc_path.is_file():
            episode_arc = arc_path.read_text(encoding="utf-8")

        result = rewrite_wardrobe_phases(
            character_id=character_id,
            char_description=char_description,
            phase_boundaries=phase_boundaries,
            thesis=thesis,
            series_philosophy=series_philosophy,
            episode_arc=episode_arc,
            director_hint=director_hint,
        )

        # Attach original descriptions for diff preview
        for rewritten in result.get("phases", []):
            pid = rewritten.get("phase_id", "")
            original = next((p for p in phases if p.get("phase_id") == pid), {})
            rewritten["original_description"] = original.get("wardrobe_description", "")

        self._json_response(result)

    def _api_wi_apply_rewrite(self, project_name, project_dir, body):
        """POST /api/project/{name}/wardrobe-intent/apply-rewrite

        Commit previewed rewrite to bible. Accepts director-edited fields.
        Body: { "character_id": "TORCH", "phases": [...edited phases...] }
        """
        character_id = body.get("character_id", "").upper()
        rewritten_phases = body.get("phases", [])

        if not character_id:
            self._json_response({"error": "character_id required"}, 400)
            return
        if not rewritten_phases:
            self._json_response({"error": "phases required"}, 400)
            return

        pp = _paths_for_project(project_name)
        bible_path = pp["bible_path"]
        if not bible_path or not bible_path.is_file():
            self._json_response({"error": "Global bible not found"}, 404)
            return

        bible = json.loads(bible_path.read_text(encoding="utf-8"))
        char_data = bible.get("characters", {}).get(character_id)
        if not char_data:
            self._json_response(
                {"error": f"Character {character_id} not in bible"}, 404
            )
            return

        bible_phases = char_data.get("phases", [])
        updated_count = 0
        for rewritten in rewritten_phases:
            pid = rewritten.get("phase_id", "")
            for bp in bible_phases:
                if bp.get("phase_id") == pid:
                    if rewritten.get("wardrobe_description") is not None:
                        bp["wardrobe_description"] = rewritten["wardrobe_description"]
                    if rewritten.get("wardrobe_arc_delta") is not None:
                        bp["wardrobe_arc_delta"] = rewritten["wardrobe_arc_delta"]
                    if rewritten.get("wardrobe_arc_carries") is not None:
                        bp["wardrobe_arc_carries"] = rewritten["wardrobe_arc_carries"]
                    updated_count += 1
                    break

        bible_path.write_text(
            json.dumps(bible, indent=2, default=str), encoding="utf-8"
        )

        self._json_response(
            {
                "status": "applied",
                "character_id": character_id,
                "phases_updated": updated_count,
            }
        )

    # ── Phase 2: Continuity / Phase-Aware Endpoints ──────────────────

    def _api_generate_phases(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/generate-phases

        Generate wardrobe or hair/makeup candidates for all (or specified) phases.
        Creates a ContinuitySession, dispatches background generation.
        """
        import threading
        import uuid as _uuid

        character_id = body.get("character_id", "").upper()
        asset_type = body.get("asset_type", "wardrobe")
        requested_phases = body.get("phases")  # optional: list of phase_ids to generate
        user_override = body.get("user_override", "")
        phase_overrides = body.get("phase_overrides", {})  # {phase_id: override_text}

        if not character_id:
            self._json_response({"error": "character_id required"}, 400)
            return

        if asset_type not in ("wardrobe", "hair_makeup"):
            self._json_response(
                {"error": "asset_type must be wardrobe or hair_makeup"}, 400
            )
            return

        # Load bible
        pp = _paths_for_project(project_name)
        bible_path = pp["bible_path"]
        if not bible_path or not bible_path.is_file():
            self._json_response({"error": "Global bible not found"}, 404)
            return

        bible = json.loads(bible_path.read_text(encoding="utf-8"))
        char_data = bible.get("characters", {}).get(character_id, {})
        if not char_data:
            self._json_response(
                {"error": f"Character {character_id} not in bible"}, 404
            )
            return

        phases = char_data.get("phases", [])
        if not phases:
            self._json_response({"error": f"No phases defined for {character_id}"}, 400)
            return

        # Filter to requested phases if specified
        if requested_phases:
            phases = [p for p in phases if p.get("phase_id") in requested_phases]

        # Get hero path
        casting_state = self._load_casting_state(project_dir)
        char_cast = casting_state.get("characters", {}).get(character_id, {})
        hero_rel = char_cast.get("hero_path", "")
        if not hero_rel:
            self._json_response(
                {"error": "No hero selected — cast character first"}, 400
            )
            return

        hero_abs = str(_resolve_output_rel(hero_rel)) if hero_rel else None
        if not hero_abs or not Path(hero_abs).is_file():
            self._json_response({"error": f"Hero image not found: {hero_rel}"}, 404)
            return

        # Create ContinuitySession (grid mode)
        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        session_id = str(_uuid.uuid4())[:8]
        char_slug = slugify_asset_id(character_id)
        num_grid_candidates = 3

        session = {
            "session_id": session_id,
            "session_type": "continuity",
            "generation_mode": "grid",
            "character_id": character_id,
            "asset_type": asset_type,
            "grid_candidates": [
                {"slot": i, "path": None, "state": "empty"}
                for i in range(num_grid_candidates)
            ],
            "selected_grid": None,
            "phase_ids": [p.get("phase_id", "") for p in phases],
            "status": "generating",
            "cost": 0.0,
        }

        # Persist in casting_state
        state = self._load_casting_state(project_dir)
        if "continuity_sessions" not in state:
            state["continuity_sessions"] = {}
        state["continuity_sessions"][session_id] = session
        self._save_casting_state(project_dir, state)

        # Return immediately, run generation in background
        self._json_response({"session": session})

        char_desc = char_data.get("casting_description") or char_data.get(
            "visual_description", ""
        )
        gender = char_data.get("gender")
        pp = _paths_for_project(project_name)
        out_dir = pp["character_refs_dir"] / char_slug

        def _run():
            try:
                from recoil.pipeline._lib.ref_selector import generate_continuity_grid

                def _on_grid_ready(slot_idx, grid_path):
                    """Callback: update session state as each grid candidate completes."""
                    s = self._load_casting_state(project_dir)
                    cs = s.get("continuity_sessions", {}).get(session_id)
                    if cs:
                        rel_path = _to_relative_output_path(grid_path)
                        cs["grid_candidates"][slot_idx] = {
                            "slot": slot_idx,
                            "path": rel_path,
                            "state": "new",
                        }
                        self._save_casting_state(project_dir, s)

                result = generate_continuity_grid(
                    asset_type=asset_type,
                    phases_data=phases,
                    output_dir=out_dir,
                    char_id=char_slug,
                    hero_image_path=hero_abs,
                    char_description=char_desc,
                    gender=gender,
                    user_override=user_override,
                    phase_overrides=phase_overrides,
                    num_candidates=num_grid_candidates,
                    on_grid_ready=_on_grid_ready,
                )

                # Mark session complete
                s = self._load_casting_state(project_dir)
                cs = s.get("continuity_sessions", {}).get(session_id)
                if cs:
                    cs["status"] = "complete"
                    cs["cost"] = result.get("cost", 0)
                    self._save_casting_state(project_dir, s)

                print(
                    f"[PHASE-GEN] {character_id} {asset_type}: "
                    f"{len(phases)} phases, ${result.get('cost', 0):.3f}"
                )

            except Exception as e:
                print(f"[PHASE-GEN] Error: {e}")
                s = self._load_casting_state(project_dir)
                cs = s.get("continuity_sessions", {}).get(session_id)
                if cs:
                    cs["status"] = "error"
                    cs["error"] = str(e)
                    self._save_casting_state(project_dir, s)

        threading.Thread(target=_run, daemon=True).start()

    def _api_continuity_session_get(self, project_name, project_dir, session_id):
        """GET /api/project/{name}/casting/continuity-session/{id}

        Returns current state of a continuity session (for polling).
        """
        state = self._load_casting_state(project_dir)
        session = state.get("continuity_sessions", {}).get(session_id)
        if not session:
            self._json_response(
                {"error": f"Continuity session {session_id} not found"}, 404
            )
            return
        self._json_response({"session": session})

    def _api_reroll_grid(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/reroll-grid

        Regenerate one grid candidate within a continuity session.
        """
        import threading

        session_id = body.get("session_id")
        slot = body.get("slot")
        user_override = body.get("user_override", "")
        phase_overrides = body.get("phase_overrides", {})

        if session_id is None or slot is None:
            self._json_response({"error": "session_id and slot required"}, 400)
            return

        state = self._load_casting_state(project_dir)
        session = state.get("continuity_sessions", {}).get(session_id)
        if not session:
            self._json_response({"error": f"Session {session_id} not found"}, 404)
            return

        if session.get("generation_mode") != "grid":
            self._json_response({"error": "Not a grid session"}, 400)
            return

        # Mark slot as generating
        if slot < len(session["grid_candidates"]):
            session["grid_candidates"][slot] = {
                "slot": slot,
                "path": None,
                "state": "generating",
            }
        session["status"] = "generating"
        self._save_casting_state(project_dir, state)

        self._json_response({"session": session})

        character_id = session["character_id"]
        asset_type = session["asset_type"]
        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        char_slug = slugify_asset_id(character_id)

        # Load bible
        project_bible = project_dir / "state" / STATE_NAMESPACE / "global_bible.json"
        bible_path = project_bible
        bible = json.loads(bible_path.read_text(encoding="utf-8"))
        char_data = bible.get("characters", {}).get(character_id, {})
        char_desc = char_data.get("casting_description") or char_data.get(
            "visual_description", ""
        )
        gender = char_data.get("gender")
        phases = char_data.get("phases", [])

        char_cast = state.get("characters", {}).get(character_id, {})
        hero_rel = char_cast.get("hero_path", "")
        hero_abs = str(_resolve_output_rel(hero_rel)) if hero_rel else None

        pp = _paths_for_project(project_name)
        out_dir = pp["character_refs_dir"] / char_slug

        def _run():
            try:
                from recoil.pipeline._lib.ref_selector import generate_continuity_grid

                def _on_grid_ready(slot_idx, grid_path):
                    s = self._load_casting_state(project_dir)
                    cs = s.get("continuity_sessions", {}).get(session_id)
                    if cs:
                        rel_path = _to_relative_output_path(grid_path)
                        cs["grid_candidates"][slot] = {
                            "slot": slot,
                            "path": rel_path,
                            "state": "new",
                        }
                        self._save_casting_state(project_dir, s)

                result = generate_continuity_grid(
                    asset_type=asset_type,
                    phases_data=phases,
                    output_dir=out_dir,
                    char_id=char_slug,
                    hero_image_path=hero_abs,
                    char_description=char_desc,
                    gender=gender,
                    user_override=user_override,
                    phase_overrides=phase_overrides,
                    num_candidates=1,
                    on_grid_ready=_on_grid_ready,
                )

                s = self._load_casting_state(project_dir)
                cs = s.get("continuity_sessions", {}).get(session_id)
                if cs:
                    cs["status"] = "complete"
                    cs["cost"] = cs.get("cost", 0) + result.get("cost", 0)
                    self._save_casting_state(project_dir, s)

            except Exception as e:
                print(f"[GRID-REROLL] Error: {e}")
                s = self._load_casting_state(project_dir)
                cs = s.get("continuity_sessions", {}).get(session_id)
                if cs:
                    cs["status"] = "error"
                    self._save_casting_state(project_dir, s)

        threading.Thread(target=_run, daemon=True).start()

    def _api_reroll_phase(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/reroll-phase

        Regenerate candidates for one phase within a continuity session.
        Only rerolls unlocked/rejected slots.
        """
        import threading

        session_id = body.get("session_id")
        phase_id = body.get("phase_id")
        override_text = body.get("override", "").strip()

        if not session_id or not phase_id:
            self._json_response({"error": "session_id and phase_id required"}, 400)
            return

        state = self._load_casting_state(project_dir)
        session = state.get("continuity_sessions", {}).get(session_id)
        if not session:
            self._json_response({"error": f"Session {session_id} not found"}, 404)
            return

        phase = session.get("phases", {}).get(phase_id)
        if not phase:
            self._json_response({"error": f"Phase {phase_id} not in session"}, 404)
            return

        # Mark phase as generating
        phase["status"] = "generating"
        if override_text:
            phase["override"] = override_text
        self._save_casting_state(project_dir, state)

        self._json_response(
            {"status": "generating", "session_id": session_id, "phase_id": phase_id}
        )

        character_id = session["character_id"]
        asset_type = session["asset_type"]
        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        char_slug = slugify_asset_id(character_id)

        # Load bible for phase data and character info
        pp = _paths_for_project(project_name)
        bible_path = pp["bible_path"]
        bible = json.loads(bible_path.read_text(encoding="utf-8"))
        char_data = bible.get("characters", {}).get(character_id, {})
        char_desc = char_data.get("casting_description") or char_data.get(
            "visual_description", ""
        )
        gender = char_data.get("gender")

        # Find the matching bible phase
        bible_phases = char_data.get("phases", [])
        target_phase = None
        for bp in bible_phases:
            if bp.get("phase_id") == phase_id:
                target_phase = bp
                break
        if not target_phase:
            return

        # Get hero path
        char_cast = state.get("characters", {}).get(character_id, {})
        hero_rel = char_cast.get("hero_path", "")
        hero_abs = str(_resolve_output_rel(hero_rel)) if hero_rel else None

        out_dir = pp["character_refs_dir"] / char_slug
        candidates_per_phase = 3

        def _run():
            try:
                from recoil.pipeline._lib.ref_selector import generate_phase_candidates

                # Identify slots to reroll (not locked)
                slots_to_fill = [
                    c["slot"]
                    for c in phase["candidates"]
                    if c.get("state") not in ("locked",)
                ]

                if not slots_to_fill:
                    s = self._load_casting_state(project_dir)
                    cs = s["continuity_sessions"][session_id]
                    cs["phases"][phase_id]["status"] = "ready"
                    self._save_casting_state(project_dir, s)
                    return

                def _on_ready(pid, slot_idx, img_path):
                    if pid != phase_id:
                        return
                    s = self._load_casting_state(project_dir)
                    cs = s.get("continuity_sessions", {}).get(session_id)
                    if cs and phase_id in cs["phases"]:
                        # Map to the actual slot being rerolled
                        if slot_idx < len(slots_to_fill):
                            actual_slot = slots_to_fill[slot_idx]
                            rel_path = _to_relative_output_path(img_path)
                            cs["phases"][phase_id]["candidates"][actual_slot] = {
                                "slot": actual_slot,
                                "path": rel_path,
                                "state": "new",
                            }
                        self._save_casting_state(project_dir, s)

                result = generate_phase_candidates(
                    asset_type=asset_type,
                    phases_data=[target_phase],
                    output_dir=out_dir,
                    char_id=char_slug,
                    hero_image_path=hero_abs,
                    char_description=char_desc,
                    gender=gender,
                    candidates_per_phase=len(slots_to_fill),
                    user_override=override_text,
                    on_candidate_ready=_on_ready,
                )

                s = self._load_casting_state(project_dir)
                cs = s.get("continuity_sessions", {}).get(session_id)
                if cs:
                    cs["phases"][phase_id]["status"] = "ready"
                    cs["cost"] = cs.get("cost", 0) + result.get("cost", 0)
                    self._save_casting_state(project_dir, s)

            except Exception as e:
                print(f"[PHASE-REROLL] Error: {e}")
                s = self._load_casting_state(project_dir)
                cs = s.get("continuity_sessions", {}).get(session_id)
                if cs:
                    cs["phases"][phase_id]["status"] = "error"
                    self._save_casting_state(project_dir, s)

        threading.Thread(target=_run, daemon=True).start()

    def _api_bin_get(self, project_name, project_dir, character_id, asset_type):
        """GET /api/project/{name}/casting/bin/{character_id}/{asset_type}

        Returns all candidate images for this character/asset_type that are NOT
        currently assigned to an active slot. The "bin" is derived from filesystem.
        """
        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        pp = _paths_for_project(project_name)
        char_slug = slugify_asset_id(character_id)
        candidates_dir = (
            pp["character_refs_dir"] / char_slug / "candidates" / asset_type
        )

        if not candidates_dir.is_dir():
            self._json_response({"bin_images": [], "total": 0})
            return

        IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
        all_images = set()
        for f in candidates_dir.rglob("*"):
            if f.is_file() and f.suffix.lower() in IMAGE_EXTS:
                all_images.add(_to_relative_output_path(str(f)))

        # Get actively assigned images from continuity sessions
        state = self._load_casting_state(project_dir)
        active_paths = set()
        for sid, cs in state.get("continuity_sessions", {}).items():
            if (
                cs.get("character_id", "").upper() == character_id.upper()
                and cs.get("asset_type") == asset_type
            ):
                for pid, phase in cs.get("phases", {}).items():
                    for c in phase.get("candidates", []):
                        if c.get("path"):
                            active_paths.add(c["path"])

        # Bin = all candidates minus active assignments
        bin_images = sorted(all_images - active_paths)

        self._json_response({"bin_images": bin_images, "total": len(bin_images)})

    def _api_bin_assign(self, project_name, project_dir, body):
        """POST /api/project/{name}/casting/bin-assign

        Swap a bin image into an active slot. Old slot image goes to bin.
        """
        session_id = body.get("session_id")
        phase_id = body.get("phase_id")
        slot = body.get("slot")
        bin_image_path = body.get("bin_image_path")

        if not all([session_id, phase_id, bin_image_path]) or slot is None:
            self._json_response(
                {"error": "session_id, phase_id, slot, bin_image_path required"}, 400
            )
            return

        state = self._load_casting_state(project_dir)
        session = state.get("continuity_sessions", {}).get(session_id)
        if not session:
            self._json_response({"error": f"Session {session_id} not found"}, 404)
            return

        phase = session.get("phases", {}).get(phase_id)
        if not phase:
            self._json_response({"error": f"Phase {phase_id} not in session"}, 404)
            return

        candidates = phase.get("candidates", [])
        if slot < 0 or slot >= len(candidates):
            self._json_response({"error": f"Invalid slot {slot}"}, 400)
            return

        # Swap: old path goes to bin (stays on filesystem), new path takes its place
        candidates[slot] = {
            "slot": slot,
            "path": bin_image_path,
            "state": "new",
        }

        self._save_casting_state(project_dir, state)
        self._json_response({"session": session, "swapped_slot": slot})

    def _api_screen_test_bible_synced(
        self, project_name, project_dir, character, phase_id
    ):
        """POST /api/project/{name}/screen-test/{character}/{phase}/bible-synced

        Mark a screen test phase as bible-synced after user confirms writeback.
        """
        from recoil.pipeline._lib.screen_test import (
            load_screen_test_state,
            save_screen_test_state,
        )

        char_id = character.upper()
        st_state = load_screen_test_state(project_dir)
        char_st = st_state.characters.get(char_id)

        if not char_st:
            self._json_response(
                {"error": f"No screen test state for character: {char_id}"}, 404
            )
            return

        phase_st = char_st.phases.get(phase_id)
        if not phase_st:
            self._json_response({"error": f"Phase not found: {phase_id}"}, 404)
            return

        phase_st.bible_synced = True
        save_screen_test_state(project_dir, st_state)
        self._json_response(
            {
                "ok": True,
                "character_id": char_id,
                "phase_id": phase_id,
                "bible_synced": True,
            }
        )

    # ══════════════════════════════════════════════════════════════════
    # MANUAL WORKBENCH HANDLERS
    # ══════════════════════════════════════════════════════════════════

    # _api_enhance_prompt RETIRED 2026-06-09 — enrichment superseded by prose_author.

    def _api_manual_escalate(self, body, project=None):
        """POST /api/manual/escalate — Flag a shot for manual intervention.

        Body: {"shot_id": "EP001_SH01", "failure_type": "artifacts"}
        Sets gate_results.manual_escalated = true without changing formal status.
        Optional failure_type pre-classifies the failure (1=composition, 2=artifacts,
        3=motion, 4=safety_filter, 5=character).
        """
        project = project or DEFAULT_PROJECT
        shot_id = body.get("shot_id")
        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return

        failure_type = body.get("failure_type")
        valid_failure_types = {
            "composition",
            "artifacts",
            "motion",
            "safety_filter",
            "character",
        }
        if failure_type and failure_type not in valid_failure_types:
            self._json_response(
                {"error": f"Invalid failure_type '{failure_type}'"}, 400
            )
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        gate = shot.get("gate_results", {})
        if gate.get("manual_escalated"):
            # If already escalated but now providing a failure_type, update it
            if failure_type and gate.get("failure_type") != failure_type:
                store.update_shot(
                    shot_id,
                    gate_results={
                        "failure_type": failure_type,
                    },
                )
                self._json_response(
                    {
                        "ok": True,
                        "shot_id": shot_id,
                        "already": True,
                        "failure_type_updated": True,
                    }
                )
            else:
                self._json_response({"ok": True, "shot_id": shot_id, "already": True})
            return

        gate_update = {
            "manual_escalated": True,
            "manual_escalated_at": time.time(),
        }
        if failure_type:
            gate_update["failure_type"] = failure_type

        store.update_shot(shot_id, gate_results=gate_update)

        self._json_response({"ok": True, "shot_id": shot_id})

    def _api_manual_shots(self, ep_id, project=None):
        """GET /api/manual/shots/{episode}?mode=flagged|all — Shots for manual intervention.

        ep_id can be an episode (EP001, ep_001) or "all" to get every shot.

        Query params:
          mode=flagged (default) — only manual_escalated shots
          mode=all — all shots with at least one take
        """
        import re as _re

        # Parse mode from query string
        qs = parse_qs(urlparse(self.path).query)
        mode = qs.get("mode", ["flagged"])[0]
        if mode not in ("flagged", "all"):
            mode = "flagged"

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        # Determine whether to load all or a single episode
        load_all = ep_id.lower() == "all"

        if load_all:
            all_shots = store.get_all_shots()
            episode_id = "ALL"
        else:
            ep_match = _re.match(r"(?:EP|ep[_]?)(\d+)", ep_id)
            if not ep_match:
                self._json_response({"error": f"Invalid episode format: {ep_id}"}, 400)
                return
            ep_num = int(ep_match.group(1))
            episode_id = f"EP{ep_num:03d}"
            all_shots = store.get_shots_by_episode(episode_id)

        # Build a plan cache for shot metadata (keyed by ep_num)
        _plan_cache = {}

        def _get_plan_shots(shot_ep_num):
            if shot_ep_num not in _plan_cache:
                plan_path = pp["plans_dir"] / f"ep_{shot_ep_num:03d}_plan.json"
                plan_shots = {}
                if plan_path.exists():
                    try:
                        plan = json.loads(plan_path.read_text(encoding="utf-8"))
                        for s in plan.get("shots", []):
                            plan_shots[s.get("shot_id", "")] = s
                    except (json.JSONDecodeError, IOError):
                        pass
                _plan_cache[shot_ep_num] = plan_shots
            return _plan_cache[shot_ep_num]

        result_shots = []
        for shot in all_shots:
            gate = shot.get("gate_results", {})
            takes = shot.get("takes", [])

            # Apply mode-specific filter
            if mode == "flagged":
                if not gate.get("manual_escalated"):
                    continue
            else:  # mode == "all"
                if len(takes) == 0:
                    continue

            # Determine episode number from shot_id
            sid_match = _re.match(r"EP(\d+)", shot.get("shot_id", ""))
            shot_ep_num = int(sid_match.group(1)) if sid_match else 0
            plan_data = _get_plan_shots(shot_ep_num).get(shot["shot_id"], {})

            # Find the latest output path for thumbnail
            latest_output = None
            if takes:
                latest_take = takes[-1]
                latest_output = latest_take.get("file_path") or latest_take.get("url")

            # Target frame: the "before" image for comparison.
            # Priority: hero/approved take → second-to-last take → output_path
            target_frame = None
            approved_take = next(
                (t for t in takes if t.get("approved") or t.get("is_hero")), None
            )
            if approved_take:
                target_frame = approved_take.get("file_path") or approved_take.get(
                    "url"
                )
            if not target_frame and len(takes) >= 2:
                # Use second-to-last take as the "before" (latest is the "after")
                prev_take = takes[-2]
                target_frame = prev_take.get("file_path") or prev_take.get("url")
            if not target_frame:
                target_frame = shot.get("output_path")

            shot_ep_id = shot.get("episode_id") or (
                f"EP{shot_ep_num:03d}" if shot_ep_num else ""
            )

            shot_obj = {
                "shot_id": shot["shot_id"],
                "episode_id": shot_ep_id,
                "status": shot.get("status", ""),
                "pipeline": shot.get("pipeline", ""),
                "model": shot.get("model", ""),
                "latest_output": latest_output,
                "target_frame": target_frame,
                "hero_frame": gate.get("hero_frame"),
                "video_path": gate.get("video_path"),
                "prompt": plan_data.get("prompt_data", {}).get("prompt_skeleton", {}),
                "shot_type": plan_data.get("shot_type", ""),
                "camera": plan_data.get("camera", ""),
                "characters": [
                    c.get("char_id", "")
                    for c in plan_data.get("asset_data", {}).get("characters", [])
                ],
                "action": plan_data.get("action_description", ""),
                "manual_escalated_at": gate.get("manual_escalated_at"),
                "manual_resolved": gate.get("manual_resolved", False),
                "manual_fixes": gate.get("manual_fixes", []),
                "failure_type": gate.get("failure_type"),
                "takes": takes,
            }

            # In all mode, expose manual_escalated flag for frontend badge
            if mode == "all":
                shot_obj["manual_escalated"] = gate.get("manual_escalated", False)

            result_shots.append(shot_obj)

        if mode == "flagged":
            # Sort by escalation time (most recent first)
            result_shots.sort(
                key=lambda s: s.get("manual_escalated_at", 0), reverse=True
            )

            self._json_response(
                {
                    "episode": episode_id,
                    "shots": result_shots,
                    "total": len(result_shots),
                    "unresolved": len(
                        [s for s in result_shots if not s.get("manual_resolved")]
                    ),
                }
            )
        else:  # mode == "all"
            # Sort by shot_id naturally (narrative order)
            result_shots.sort(key=lambda s: s.get("shot_id", ""))

            flagged_count = len([s for s in result_shots if s.get("manual_escalated")])

            self._json_response(
                {
                    "episode": episode_id,
                    "shots": result_shots,
                    "total": len(result_shots),
                    "flagged_count": flagged_count,
                    "unresolved": len(
                        [s for s in result_shots if not s.get("manual_resolved")]
                    ),
                }
            )

    def _api_manual_export(self, body, project=None):
        """POST /api/manual/export — Export bundle(s) for manual web UI work.

        Body: {"shot_ids": ["EP001_SH01"], "target_model": "kling-v3"}
        Wraps build_bundle() in a background thread.
        """
        import re as _re
        import threading

        project = project or DEFAULT_PROJECT

        shot_ids_raw = body.get("shot_ids", [])
        target_model = body.get("target_model", "kling-v3")
        target_model = LEGACY_MODEL_MAP.get(target_model, target_model)

        if not shot_ids_raw:
            self._json_response({"error": "Missing shot_ids"}, 400)
            return

        # Group shot IDs by episode for build_bundle calls
        episodes = {}
        for sid in shot_ids_raw:
            ep_match = _re.match(r"EP(\d+)_SH(\d+)", sid)
            if ep_match:
                ep_num = int(ep_match.group(1))
                shot_num = int(ep_match.group(2))
                episodes.setdefault(ep_num, []).append(shot_num)

        if not episodes:
            self._json_response({"error": "No valid shot IDs provided"}, 400)
            return

        try:
            from tools.build_upload_bundle import build_bundle
        except ImportError as e:
            self._json_response(
                {"error": f"build_upload_bundle not available: {e}"}, 503
            )
            return

        results = []

        def _bg_export():
            for ep_num, shot_nums in episodes.items():
                try:
                    bundle_path = build_bundle(
                        episode=ep_num,
                        shot_ids=shot_nums,
                        model=target_model,
                        project=project,
                    )
                    if bundle_path:
                        results.append(
                            {
                                "episode": ep_num,
                                "shots": shot_nums,
                                "bundle_path": str(bundle_path),
                            }
                        )
                except Exception as e:
                    print(f"  [ERR] Bundle export failed for EP{ep_num:03d}: {e}")
                    results.append(
                        {
                            "episode": ep_num,
                            "shots": shot_nums,
                            "error": str(e),
                        }
                    )

        thread = threading.Thread(target=_bg_export, daemon=True)
        thread.start()

        # Return immediately — bundles will appear on disk
        self._json_response(
            {
                "ok": True,
                "status": "exporting",
                "episodes": list(episodes.keys()),
                "model": target_model,
            }
        )

    def _api_manual_reimport(self, body, project=None):
        """POST /api/manual/reimport — Re-import a manually fixed asset.

        Accepts EITHER:
          - file_data (base64) + file_name: from browser drag & drop
          - file_path: absolute disk path (CLI/scripting use)

        Body: {"shot_id": "EP001_SH01", "file_data": "base64...", "file_name": "fix.png",
               "failure_type": "artifacts", "fix_type": "manual_intervention",
               "notes": "optional notes"}
        Writes file into correct output location and updates ExecutionStore.
        """
        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        shot_id = body.get("shot_id")
        file_data = body.get("file_data")  # base64 from browser
        file_name = body.get("file_name")  # original filename from browser
        file_path = body.get("file_path")  # absolute path (CLI fallback)
        failure_type = body.get("failure_type")

        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return
        if not file_data and not file_path:
            self._json_response({"error": "Missing file_data or file_path"}, 400)
            return
        if not failure_type:
            self._json_response(
                {
                    "error": "Missing failure_type — must be one of: composition, artifacts, motion, safety_filter, character"
                },
                400,
            )
            return

        valid_failure_types = {
            "composition",
            "artifacts",
            "motion",
            "safety_filter",
            "character",
        }
        if failure_type not in valid_failure_types:
            self._json_response(
                {
                    "error": f"Invalid failure_type '{failure_type}'. Must be one of: {', '.join(sorted(valid_failure_types))}"
                },
                400,
            )
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        # Determine episode from shot_id
        import re as _re

        ep_match = _re.match(r"EP(\d+)_SH(\d+)", shot_id)
        if not ep_match:
            self._json_response({"error": f"Invalid shot_id format: {shot_id}"}, 400)
            return
        ep_num = int(ep_match.group(1))

        # Type-routed destination
        VIDEO_EXTENSIONS = {".mp4", ".mov", ".webm", ".avi"}
        IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff"}

        # Determine suffix from original filename
        if file_data:
            suffix = Path(file_name).suffix if file_name else ".png"
        else:
            suffix = Path(file_path).suffix or ".png"

        suffix_lower = suffix.lower()
        if suffix_lower in VIDEO_EXTENSIONS:
            dest_dir = pp["video_dir"] / f"ep_{ep_num:03d}"
            asset_type = "video"
        elif suffix_lower in IMAGE_EXTENSIONS:
            dest_dir = pp["frames_dir"] / f"ep_{ep_num:03d}"
            asset_type = "frame"
        else:
            self._json_response({"error": f"Unsupported file type: {suffix}"}, 400)
            return

        dest_dir.mkdir(parents=True, exist_ok=True)

        # Dedup guard: reject rapid duplicate imports
        takes = shot.get("takes", [])
        if takes:
            last_take = takes[-1]
            if (
                last_take.get("layer") == "manual_fix"
                and time.time() - last_take.get("timestamp", 0) < 5
            ):
                self._json_response(
                    {
                        "error": "Duplicate import detected. Wait a moment before reimporting.",
                    },
                    409,
                )
                return

        # Incremental filename: count existing manual_fix takes
        existing_fixes = [t for t in takes if t.get("layer") == "manual_fix"]
        fix_num = len(existing_fixes) + 1
        dest_name = f"{shot_id}_manual_fix_{fix_num:02d}{suffix}"

        # Filesystem collision guard
        dest = dest_dir / dest_name
        while dest.exists():
            fix_num += 1
            dest_name = f"{shot_id}_manual_fix_{fix_num:02d}{suffix}"
            dest = dest_dir / dest_name

        if file_data:
            # Browser upload path: decode base64 and write
            import base64 as _b64

            try:
                raw = _b64.b64decode(file_data)
                dest.write_bytes(raw)
            except Exception as exc:
                self._json_response(
                    {"error": f"Failed to decode file_data: {exc}"}, 400
                )
                return
        else:
            # Disk path fallback (CLI/scripting)
            src = Path(file_path)
            if not src.exists():
                self._json_response(
                    {"error": f"Source file not found: {file_path}"}, 404
                )
                return
            # Path traversal guard
            try:
                src.resolve().relative_to(Path.home())
            except ValueError:
                self._json_response(
                    {"error": "File path must be within home directory"}, 400
                )
                return
            shutil.copy2(str(src), str(dest))

        # Build relative output path for store
        rel_path = str(dest.relative_to(pp["project_dir"]))

        # Infer fix type
        fix_type = body.get("fix_type", "manual_intervention")

        # Build manual_fixes entry
        fix_entry = {
            "timestamp": time.time(),
            "failure_type": failure_type,
            "fix_type": fix_type,
            "model_used": shot.get("pipeline") or shot.get("model") or "",
            "enrichment_used": False,
            "notes": body.get("notes", ""),
            "reimported_path": rel_path,
        }

        # Append to gate_results.manual_fixes
        gate = shot.get("gate_results", {})
        manual_fixes = gate.get("manual_fixes", [])
        manual_fixes.append(fix_entry)

        # Build take entry with take_id and asset_type
        take_entry = {
            "file_path": rel_path,
            "layer": "manual_fix",
            "asset_type": asset_type,  # "video" or "frame"
            "take_id": f"{shot_id}_MF{int(time.time()) % 100000:05d}",
            "timestamp": time.time(),
        }

        # Single atomic update: promote hero + append take
        store.update_shot(
            shot_id,
            gate_results={
                "manual_fixes": manual_fixes,
                "hero_frame": rel_path,
            },
            output_path=rel_path,
            append_take=take_entry,
        )

        self._json_response(
            {
                "ok": True,
                "shot_id": shot_id,
                "dest_path": rel_path,
                "fix_entry": fix_entry,
            }
        )

    def _api_manual_resolve(self, body, project=None):
        """POST /api/manual/resolve — Mark a shot as resolved from manual intervention.

        Body: {"shot_id": "EP001_SH01", "failure_type": "artifacts",
               "fix_type": "manual_intervention", "notes": "optional",
               "action": "return_to_pipeline" | "export_video_bundle"}
        """
        project = project or DEFAULT_PROJECT

        shot_id = body.get("shot_id")
        failure_type = body.get("failure_type")
        action = body.get("action", "return_to_pipeline")

        if not shot_id:
            self._json_response({"error": "Missing shot_id"}, 400)
            return
        if not failure_type:
            self._json_response({"error": "Missing failure_type"}, 400)
            return

        valid_failure_types = {
            "composition",
            "artifacts",
            "motion",
            "safety_filter",
            "character",
        }
        if failure_type not in valid_failure_types:
            self._json_response(
                {
                    "error": f"Invalid failure_type '{failure_type}'. Must be one of: {', '.join(sorted(valid_failure_types))}"
                },
                400,
            )
            return

        store = _get_store(project)
        if store is None:
            self._json_response({"error": "ExecutionStore not available"}, 503)
            return

        shot = store.get_shot(shot_id)
        if shot is None:
            self._json_response({"error": f"Shot not found: {shot_id}"}, 404)
            return

        gate = shot.get("gate_results", {})
        if not gate.get("manual_escalated"):
            self._json_response(
                {"error": f"Shot {shot_id} was not escalated for manual intervention"},
                400,
            )
            return

        if gate.get("manual_resolved"):
            self._json_response(
                {"ok": True, "shot_id": shot_id, "already_resolved": True}
            )
            return

        # Auto-infer fix type if not explicitly provided
        fix_type = body.get("fix_type")
        if not fix_type:
            if body.get("prompt_edited"):
                fix_type = "prompt_edit"
            elif body.get("model_changed"):
                fix_type = "model_switch"
            elif body.get("source") == "bundle":
                fix_type = "manual_intervention"
            else:
                fix_type = "unknown"

        # Collect overrides (shot_type, camera, action changes)
        overrides = body.get("overrides", {})

        fix_entry = {
            "timestamp": time.time(),
            "failure_type": failure_type,
            "fix_type": fix_type,
            "model_used": shot.get("pipeline") or shot.get("model") or "",
            "enrichment_used": body.get("enrichment_used", False),
            "notes": body.get("notes", ""),
            "auto_tagged": False,
        }
        if overrides:
            fix_entry["overrides"] = overrides
        if body.get("new_prompt"):
            fix_entry["new_prompt"] = body["new_prompt"]
        if body.get("new_model"):
            fix_entry["new_model"] = body["new_model"]

        manual_fixes = gate.get("manual_fixes", [])
        manual_fixes.append(fix_entry)

        # Determine hero asset: latest manual_fix or latest take, whichever is newer
        takes = shot.get("takes", [])
        last_manual_fix = manual_fixes[-1] if manual_fixes else None
        last_take = takes[-1] if takes else None

        hero_path = None
        hero_asset_type = "frame"  # default

        if last_manual_fix and last_take:
            mf_ts = last_manual_fix.get("timestamp", 0)
            tk_ts = last_take.get("timestamp", 0)
            if mf_ts > tk_ts:
                hero_path = last_manual_fix.get("reimported_path")
                hero_asset_type = last_manual_fix.get("asset_type", "frame")
            else:
                hero_path = last_take.get("file_path")
                hero_asset_type = last_take.get("asset_type", "frame")
        elif last_manual_fix:
            hero_path = last_manual_fix.get("reimported_path")
            hero_asset_type = last_manual_fix.get("asset_type", "frame")
        elif last_take:
            hero_path = last_take.get("file_path")
            hero_asset_type = last_take.get("asset_type", "frame")

        # Status transition matrix based on action + asset type
        if action == "return_to_pipeline":
            if hero_asset_type == "video":
                target_status = "video_complete"
            elif hero_path:
                target_status = "keyframe_approved"
            else:
                target_status = "keyframe_pending"  # prompt-only fix, re-generate
        elif action == "export_video_bundle":
            if hero_asset_type == "video":
                target_status = "video_complete"
            else:
                target_status = "video_pending"
        else:
            self._json_response({"error": f"Unknown action: {action}"}, 400)
            return

        # Atomic store update with status transition and hero promotion
        update_gate = {
            "manual_resolved": True,
            "manual_resolved_at": time.time(),
            "manual_fixes": manual_fixes,
        }
        if overrides:
            update_gate["manual_overrides"] = overrides
        if hero_path:
            update_gate["hero_frame"] = hero_path

        shot_updates = {}
        if hero_path:
            shot_updates["output_path"] = hero_path
        if overrides.get("shot_type"):
            shot_updates["shot_type_override"] = overrides["shot_type"]
        if body.get("new_model"):
            shot_updates["pipeline"] = body["new_model"]

        # Use force_reset_status for the status change (manual resolve transitions
        # from failed/escalated states which aren't in VALID_TRANSITIONS)
        store.force_reset_status(
            shot_id, target_status, reason=f"manual resolve: {action} ({failure_type})"
        )
        store.update_shot(shot_id, gate_results=update_gate, **shot_updates)

        self._json_response(
            {
                "ok": True,
                "shot_id": shot_id,
                "action": action,
                "status": target_status,
                "hero_path": hero_path,
                "fix_entry": fix_entry,
            }
        )

    # ── Assets API implementations ──────────────────────────────────

    def _api_assets_list(self, project=None):
        """Phase 1 rewrite: thin wrapper over ref_resolver.

        Returns the same shape as the previous implementation. The whole
        point of the Phase 1 refactor is that this endpoint and the
        pipeline-side callers see the same view of canonical refs. The
        ref_resolver monopoly property test enforces this.
        """
        result = _api_assets_list_sync(project or DEFAULT_PROJECT)
        self._json_response(result)

    def _api_asset_import(self, body, project=None):
        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        file_data = body.get("file_data")
        file_name = body.get("file_name", "import.png")
        asset_type = body.get("type")
        asset_name = body.get("name")
        asset_id = body.get("asset_id")
        description = body.get("description", "")

        if not file_data:
            self._json_response({"error": "Missing file_data (base64)"}, 400)
            return
        if not asset_type or asset_type not in ("character", "location", "prop"):
            self._json_response(
                {"error": "type must be character, location, or prop"}, 400
            )
            return
        if not asset_name:
            self._json_response({"error": "Missing name"}, 400)
            return

        if not asset_id:
            asset_id = asset_name.lower().replace(" ", "_")

        # Sanitize asset_id to prevent path traversal
        asset_id = (
            asset_id.replace("/", "").replace("\\", "").replace("..", "").strip(".")
        )
        if not asset_id:
            self._json_response(
                {"error": "Invalid asset name (sanitized to empty)"}, 400
            )
            return

        import base64 as _b64

        try:
            raw = _b64.b64decode(file_data)
        except Exception as exc:
            self._json_response({"error": f"Failed to decode file_data: {exc}"}, 400)
            return

        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        slug = slugify_asset_id(asset_id)
        type_plural = asset_type + "s"
        refs_dir = pp["output_dir"] / "refs" / type_plural / slug
        refs_dir.mkdir(parents=True, exist_ok=True)

        _IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
        existing_images = [
            f
            for f in refs_dir.iterdir()
            if f.is_file()
            and f.suffix.lower() in _IMG_EXTS
            and "concept" not in f.stem.lower()
        ]
        # Hero if no non-concept images exist yet, or no file named *_hero* exists
        has_hero = any("_hero" in f.stem.lower() for f in existing_images)
        is_hero = not has_hero
        idx = len(existing_images) + 1
        suffix = Path(file_name).suffix or ".png"

        try:
            from PIL import Image
            import io

            img = Image.open(io.BytesIO(raw)).convert("RGBA")
            processed_bytes = raw
            original_b64 = _b64.b64encode(raw).decode()

            if asset_type in ("character", "prop"):
                try:
                    from rembg import remove

                    nobg = remove(raw)
                    fg = Image.open(io.BytesIO(nobg)).convert("RGBA")

                    if asset_type == "character":
                        target_size = (1024, 1024)
                    else:
                        target_size = (512, 512)

                    canvas = Image.new("RGBA", target_size, (255, 255, 255, 255))
                    fg.thumbnail(target_size, Image.LANCZOS)
                    offset = (
                        (target_size[0] - fg.width) // 2,
                        (target_size[1] - fg.height) // 2,
                    )
                    canvas.paste(fg, offset, fg)
                    canvas = canvas.convert("RGB")

                    buf = io.BytesIO()
                    canvas.save(buf, format="PNG")
                    processed_bytes = buf.getvalue()
                except ImportError:
                    pass
            elif asset_type == "location":
                target_size = (1920, 1080)
                img_rgb = img.convert("RGB")
                img_rgb.thumbnail(target_size, Image.LANCZOS)
                buf = io.BytesIO()
                img_rgb.save(buf, format="PNG")
                processed_bytes = buf.getvalue()

            tag = "hero" if is_hero else f"ref_{idx:02d}"
            dest_name = f"{slug}_{tag}{suffix}"
            dest = refs_dir / dest_name
            dest.write_bytes(processed_bytes)

            processed_b64 = _b64.b64encode(processed_bytes).decode()

        except ImportError:
            tag = "hero" if is_hero else f"ref_{idx:02d}"
            dest_name = f"{slug}_{tag}{suffix}"
            dest = refs_dir / dest_name
            dest.write_bytes(raw)
            original_b64 = _b64.b64encode(raw).decode()
            processed_b64 = original_b64

        # Use same bible resolution as _api_assets_list: client_bible first, then global_bible
        bible_path = pp["state_dir"] / "client_bible.json"
        if not bible_path.exists():
            bible_path = pp.get("bible_path", pp["state_dir"] / "global_bible.json")
        if bible_path.exists():
            try:
                bible = json.loads(bible_path.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, OSError):
                bible = {"characters": {}, "locations": {}, "props": {}}
        else:
            bible = {"characters": {}, "locations": {}, "props": {}}

        if type_plural not in bible:
            bible[type_plural] = {}

        if asset_id not in bible[type_plural]:
            if asset_type == "character":
                bible[type_plural][asset_id] = {
                    "display_name": asset_name.upper(),
                    "visual_description": description,
                    "wardrobe_description": "",
                    "hair_makeup_description": "",
                    "height_cm": 170,
                    "distinguishing_marks": "",
                    "identity_type": "human",
                }
            elif asset_type == "location":
                bible[type_plural][asset_id] = {
                    "display_name": asset_name,
                    "description": description,
                    "atmosphere": "",
                    "lighting_notes": [],
                    "palette": [],
                }
            elif asset_type == "prop":
                bible[type_plural][asset_id] = {
                    "display_name": asset_name,
                    "description": description,
                }

        bible_path.parent.mkdir(parents=True, exist_ok=True)
        bible_path.write_text(
            json.dumps(bible, indent=2, ensure_ascii=False), encoding="utf-8"
        )

        self._json_response(
            {
                "status": "ok",
                "asset_id": asset_id,
                "asset_type": asset_type,
                "ref_path": f"/refs/{type_plural}/{slug}/{dest_name}",
                "is_hero": is_hero,
                "original_b64": original_b64,
                "processed_b64": processed_b64,
            }
        )

    def _api_asset_turnarounds(self, body, project=None):
        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)
        char_id = body.get("asset_id") or body.get("character_id")
        if not char_id:
            self._json_response({"error": "Missing asset_id"}, 400)
            return

        # Sanitize to prevent path traversal
        char_id = (
            char_id.replace("/", "").replace("\\", "").replace("..", "").strip(".")
        )
        if not char_id:
            self._json_response({"error": "Invalid asset_id (sanitized to empty)"}, 400)
            return

        hero_image = self._find_hero_image(char_id, project)

        # Get description from bible as fallback
        description = None
        if not hero_image:
            bible_path = pp.get("bible_path")
            if bible_path and bible_path.exists():
                try:
                    bible = json.loads(bible_path.read_text(encoding="utf-8"))
                    char_entry = bible.get("characters", {}).get(char_id.upper(), {})
                    description = char_entry.get(
                        "physical_description"
                    ) or char_entry.get("description")
                except Exception:
                    pass

        try:
            from tools.prep_character_angles import prep_character

            result = prep_character(
                project=project,
                character=char_id,
                hero_image=hero_image,
                description=description,
            )
            self._json_response({"status": "ok", "result": result})
        except Exception as exc:
            import traceback

            traceback.print_exc()
            self._json_response({"error": f"Turnaround generation failed: {exc}"}, 500)

    def _api_asset_delete(self, body, project=None):
        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        asset_type = body.get("type")
        asset_id = body.get("asset_id")
        ref_filename = body.get("ref_filename")

        if not asset_type or not asset_id:
            self._json_response({"error": "Missing type or asset_id"}, 400)
            return

        # Sanitize inputs to prevent path traversal
        asset_id = (
            asset_id.replace("/", "").replace("\\", "").replace("..", "").strip(".")
        )
        if not asset_id:
            self._json_response({"error": "Invalid asset_id (sanitized to empty)"}, 400)
            return
        if ref_filename:
            ref_filename = Path(ref_filename).name

        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        slug = slugify_asset_id(asset_id)
        type_plural = asset_type + "s" if not asset_type.endswith("s") else asset_type
        refs_dir = pp["output_dir"] / "refs" / type_plural / slug

        if ref_filename:
            target = refs_dir / ref_filename
            if target.exists():
                target.unlink()
            self._json_response({"status": "ok", "deleted": ref_filename})
        else:
            if refs_dir.exists():
                shutil.rmtree(refs_dir)
            # Use same bible resolution as _api_assets_list
            bible_path = pp["state_dir"] / "client_bible.json"
            if not bible_path.exists():
                bible_path = pp.get("bible_path", pp["state_dir"] / "global_bible.json")
            if bible_path.exists():
                try:
                    bible = json.loads(bible_path.read_text(encoding="utf-8"))
                    if type_plural in bible and asset_id in bible[type_plural]:
                        del bible[type_plural][asset_id]
                        bible_path.write_text(
                            json.dumps(bible, indent=2, ensure_ascii=False),
                            encoding="utf-8",
                        )
                except (json.JSONDecodeError, OSError):
                    pass
            self._json_response({"status": "ok", "deleted": asset_id})

    def _api_asset_set_hero(self, body, project=None):
        """Set a specific ref as the hero for an asset.

        Filesystem promotion: copies the chosen file to {slug}_hero.{ext}.
        Metadata update: records provenance in casting_state.json.
        Does NOT write _hero.json (eliminated).
        """
        import shutil
        import time as _time
        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        project = project or DEFAULT_PROJECT
        pp = _paths_for_project(project)

        asset_type = body.get("type")
        asset_id = body.get("asset_id")
        ref_filename = body.get("ref_filename")

        if not asset_type or not asset_id or not ref_filename:
            self._json_response(
                {"error": "Missing type, asset_id, or ref_filename"}, 400
            )
            return

        asset_id_clean = (
            asset_id.replace("/", "").replace("\\", "").replace("..", "").strip(".")
        )
        ref_filename = Path(ref_filename).name
        type_plural = asset_type + "s" if not asset_type.endswith("s") else asset_type
        slug = slugify_asset_id(asset_id_clean)
        refs_dir = pp["output_dir"] / "refs" / type_plural / slug

        source = refs_dir / ref_filename
        if not source.exists():
            self._json_response({"error": f"Ref {ref_filename} not found"}, 404)
            return

        # === FILESYSTEM PROMOTION ===
        hero_filename = f"{slug}_hero{source.suffix.lower()}"
        hero_dest = refs_dir / hero_filename
        if source.resolve() != hero_dest.resolve():
            shutil.copy2(str(source), str(hero_dest))
        # Remove any existing hero with a different extension to prevent masking
        # in _api_assets_list (which iterates .png before .jpg when detecting hero).
        for old_hero in refs_dir.glob(f"{slug}_hero.*"):
            if old_hero.resolve() != hero_dest.resolve():
                old_hero.unlink()

        # === CANONICAL FOLDER PROMOTION ===
        canonical_dir = pp["output_dir"] / "refs" / "_canonical" / type_plural / slug
        canonical_dir.mkdir(parents=True, exist_ok=True)
        # Remove any existing hero with a different extension to prevent masking
        for old_hero in canonical_dir.glob("hero.*"):
            old_hero.unlink()
        canonical_dest = canonical_dir / f"hero{source.suffix.lower()}"
        shutil.copy2(str(source), str(canonical_dest))

        # === METADATA UPDATE ===
        proj_root = projects_root() / project
        state_path = proj_root / "state" / STATE_NAMESPACE / "casting_state.json"
        if state_path.exists():
            try:
                state = json.loads(state_path.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, IOError):
                state = {"characters": {}, "locations": {}, "grid_sessions": {}}
        else:
            state = {"characters": {}, "locations": {}, "grid_sessions": {}}

        canonical_rel = (
            f"output/refs/_canonical/{type_plural}/{slug}/hero{source.suffix.lower()}"
        )

        if asset_type in ("character", "characters"):
            chars = state.setdefault("characters", {})
            cs = chars.setdefault(asset_id_clean.upper(), {})
            cs["status"] = "hero_selected"
            cs["hero_source"] = "console_assets"
            cs["hero_selected_at"] = _time.time()
            cs["bible_synced"] = False
            cs["hero_path"] = canonical_rel
        elif asset_type in ("location", "locations"):
            locs = state.setdefault("locations", {})
            ls = locs.setdefault(slug, {})
            ls["hero_source"] = "console_assets"
            ls["hero_selected_at"] = _time.time()
            ls["hero_path"] = canonical_rel

        state_path.parent.mkdir(parents=True, exist_ok=True)
        state_path.write_text(json.dumps(state, indent=2), encoding="utf-8")

        self._json_response(
            {"status": "ok", "hero": hero_filename, "promoted_to": str(hero_dest)}
        )

    def _api_asset_generate_views(self, body, project=None):
        """Generate multi-view refs for any asset type (non-blocking)."""
        project = project or DEFAULT_PROJECT
        asset_type = body.get("type")
        asset_id = body.get("asset_id")

        if not asset_type or not asset_id:
            self._json_response({"error": "Missing type or asset_id"}, 400)
            return

        asset_id = (
            asset_id.replace("/", "").replace("\\", "").replace("..", "").strip(".")
        )

        def _do_generate():
            if asset_type == "character":
                from tools.prep_character_angles import prep_character

                hero_image = self._find_hero_image(asset_id, project)
                description = None
                if not hero_image:
                    pp = _paths_for_project(project)
                    bible_path = pp.get("bible_path")
                    if bible_path and bible_path.exists():
                        try:
                            bible = json.loads(bible_path.read_text(encoding="utf-8"))
                            char_entry = bible.get("characters", {}).get(
                                asset_id.upper(), {}
                            )
                            description = char_entry.get(
                                "physical_description"
                            ) or char_entry.get("description")
                        except Exception:
                            pass
                return prep_character(
                    project=project,
                    character=asset_id,
                    hero_image=hero_image,
                    description=description,
                )
            elif asset_type == "location":
                from tools.prep_location_refs import generate_location_refs

                pp = _paths_for_project(project)
                bible = json.loads(pp["bible_path"].read_text(encoding="utf-8"))
                if asset_id in bible.get("locations", {}):
                    bible["locations"] = {asset_id: bible["locations"][asset_id]}
                return generate_location_refs(bible, project=project)
            elif asset_type == "prop":
                from tools.prep_prop_refs import generate_prop_refs

                pp = _paths_for_project(project)
                bible = json.loads(pp["bible_path"].read_text(encoding="utf-8"))
                return generate_prop_refs(bible, project=project, prop_filter=asset_id)
            else:
                raise ValueError(f"Unknown asset type: {asset_type}")

        task_id = _submit_task(
            entity_id=asset_id,
            action=f"{asset_type}_views",
            fn=_do_generate,
        )
        self._json_response({"task_id": task_id, "status": "submitted"}, 202)

    def _find_hero_image(self, char_id, project):
        """Find the hero image for a character in the refs directory.

        Searches by canonical {slug}_hero.{ext} naming convention,
        then falls back to the first non-concept image file.
        """
        from recoil.pipeline._lib.taxonomy import slugify_asset_id

        pp = _paths_for_project(project)
        slug = slugify_asset_id(char_id)
        char_refs = pp["character_refs_dir"] / slug
        if not char_refs.is_dir():
            return None

        _img_exts = (".png", ".jpg", ".jpeg", ".webp")

        # Check canonical hero naming: {slug}_hero.{ext}
        for ext in _img_exts:
            candidate = char_refs / f"{slug}_hero{ext}"
            if candidate.is_file():
                return candidate

        # First non-concept image
        for f in sorted(char_refs.iterdir()):
            if (
                f.is_file()
                and f.suffix.lower() in _img_exts
                and "concept" not in f.stem.lower()
            ):
                return f

        return None


# ── URSS module-level helpers ────────────────────────────────────────


def _asset_type_dir(asset_type):
    """Map asset type to output subdirectory."""
    return {
        "character": "characters",
        "location": "locations",
        "wardrobe": "characters",
        "hair_makeup": "characters",
        "props": "props",
    }.get(asset_type, asset_type)


def _resolve_output_rel(rel_path) -> Path:
    """Resolve an output/-relative path to absolute.
    Uses project output only — no cross-project fallback.
    For non-output/ paths, returns Path(rel_path) directly."""
    if rel_path.startswith("output/"):
        stripped = rel_path.replace("output/", "", 1)
        return _PROJECT_OUTPUT / stripped
    return Path(rel_path)


def _to_relative_output_path(abs_path):
    """Convert absolute path to output/-relative path for state storage.
    Uses project output only — no cross-project fallback."""
    abs_p = str(abs_path)
    if _PROJECT_OUTPUT:
        project_out = str(_PROJECT_OUTPUT)
        if abs_p.startswith(project_out):
            return "output/" + abs_p[len(project_out) :].lstrip("/")
    return abs_p


def main():
    import argparse

    parser = argparse.ArgumentParser(description="Starsend Review Server")
    parser.add_argument(
        "--project",
        default=None,
        help="Initial project (optional — auto-detects from projects dir)",
    )
    parser.add_argument(
        "--port", type=int, default=PORT, help=f"Port (default: {PORT})"
    )
    parser.add_argument("--host", default=HOST, help=f"Host (default: {HOST})")
    args = parser.parse_args()

    # Initialize project — auto-detect if not specified
    project = args.project
    if not project:
        # Pick first valid project from projects dir
        if projects_root().exists():
            candidates = sorted(
                [
                    d.name
                    for d in projects_root().iterdir()
                    if d.is_dir()
                    and not d.name.startswith((".", "_"))
                    and (d / "state" / STATE_NAMESPACE).is_dir()
                ]
            )
            if candidates:
                project = candidates[0]
                print(f"  [INFO] No --project specified, defaulting to '{project}'")
        if not project:
            print(
                "  [WARN] No --project and no valid projects found. Start with --project <name>."
            )
            project = "default"
    _init_project(project)

    # Startup sweep: reset orphaned generating states from crashed sessions
    store = _get_store()
    if store:
        orphaned_statuses = (
            "previs_generating",
            "keyframe_generating",
            "video_submitted",
            "video_processing",
            "video_downloading",
        )
        orphaned = store.get_shots_by_status(*orphaned_statuses)
        if orphaned:
            for shot in orphaned:
                # Reset to pending stage
                new_status = shot["status"].rsplit("_", 1)[0] + "_pending"
                if new_status == "video_pending":
                    new_status = "previs_approved"  # video stages reset to pre-video
                store.force_reset_status(
                    shot["shot_id"], new_status, reason="startup orphan recovery"
                )
            print(f"  [STARTUP] Reset {len(orphaned)} orphaned shots to pending")

    pp = _paths_for_project(DEFAULT_PROJECT)
    # Phase 2: start the fs_watcher before serving requests
    _ensure_watcher_started()
    server = http.server.ThreadingHTTPServer((args.host, args.port), ReviewHandler)
    server.timeout = 300  # 5 min — enrichment calls can take 30+ seconds
    print(f"\n  Starsend Review Server — {DEFAULT_PROJECT}")
    print(f"  http://{args.host}:{args.port}")
    print(f"  Project: {DEFAULT_PROJECT}")
    print(f"  Bible:   {pp['bible_path']}")
    print(f"  Frames:  {pp['frames_dir']}")
    print(f"  Episodes: {len(get_episode_dirs(frames_dir=pp['frames_dir']))} found")
    print("\n  Press Ctrl+C to stop.\n")

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n  Shutting down.")
        server.server_close()


if __name__ == "__main__":
    main()
