# Three-Frame Killbox Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Evolve the keyframe pipeline from endpoint anchoring to decisive-moment anchoring with flexible frame extraction (1/2/3 frames per shot), plus inline LOCK buttons in the Previz tab.

**Architecture:** The locked keyframe becomes the "hero frame" (peak action). The director positions it in the shot timeline (FIRST, MIDDLE, LAST) and toggles which additional frames to generate. Backward extrapolation (hero → anticipation) uses kinetic override prompts to strip energy. Forward extrapolation (hero → aftermath) shows resolution. Auto-suggestion from shot plan data pre-fills the picker, director overrides freely.

**Tech Stack:** Python 3.14 backend (review_server.py), Gemini Flash text API (keyframe_context.py), vanilla JS frontend (previz.js), CSS (console.css)

**Consultation Reference:** `consultations/three_frame_killbox/SYNTHESIS.md` — all architectural decisions locked after 3-round Gemini consultation.

---

## Task 1: Decisive Moment Framing in `build_smart_prompt()`

**Files:**
- Modify: `lib/keyframe_context.py:188-194`

**Context:** `build_smart_prompt()` generates the Flash system instruction that rewrites previz compositions into 5-bracket NBP format. Currently, there's no guidance about making the [Action/Pose] tag capture peak action. We add "DECISIVE MOMENT FRAMING" guidance after the existing 5-bracket format block and before the director edit block.

**Step 1: Add decisive moment framing to system instruction**

In `lib/keyframe_context.py`, find the line (around 188):
```python
    if director_edit:
```

Insert BEFORE that line (after the 5-bracket format block ends):

```python
    system_instruction += """

DECISIVE MOMENT FRAMING:
This keyframe is the HERO FRAME — the decisive moment of this shot's action arc.
It captures the PEAK of the action: maximum visual energy, maximum emotional tension.

In the [Action/Pose] tag, use active, peak-action verbs:
- YES: wrenches, spins, slams, catches, lunges, grips, stares down, confronts
- NO: walks, stands, sits, waits, watches (these are anticipation/aftermath verbs)

The hero frame captures the character AT the peak — mid-action, not before or after."""

```

**Step 2: Verify no tests break**

Run: `cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend && python3 -c "from lib.keyframe_context import build_smart_prompt; print('import OK')"`
Expected: `import OK`

**Step 3: Commit**

```bash
git add lib/keyframe_context.py
git commit -m "feat(keyframe): add decisive moment framing to smart prompt"
```

---

## Task 2: Bidirectional Extrapolation in `build_extrapolation_prompt()`

**Files:**
- Modify: `lib/keyframe_context.py:272-426`

**Context:** `build_extrapolation_prompt()` currently accepts `anchor_role` as `"first_frame"` or `"last_frame"` and generates the opposite frame. We need to add `"hero_frame"` as an anchor role and a `target_frame` parameter so it can extrapolate in either direction from the hero. Legacy paths (first_frame, last_frame) must continue working unchanged.

**Step 1: Update function signature**

Change the signature at line 272 from:
```python
def build_extrapolation_prompt(
    shot: dict,
    all_shots: list[dict],
    bible: dict,
    anchor_role: str,  # "first_frame" or "last_frame"
    keyframe_image_path: Path,
    episode: int,
    project: str,
) -> dict:
```

To:
```python
def build_extrapolation_prompt(
    shot: dict,
    all_shots: list[dict],
    bible: dict,
    anchor_role: str,  # "first_frame", "last_frame", or "hero_frame"
    keyframe_image_path: Path,
    episode: int,
    project: str,
    target_frame: str | None = None,  # Required for hero_frame: "first_frame" or "last_frame"
) -> dict:
```

**Step 2: Update extrapolation direction logic**

Replace the line (around 311):
```python
    extrapolating = "last_frame" if anchor_role == "first_frame" else "first_frame"
```

With:
```python
    # Determine which frame we're generating
    if anchor_role == "hero_frame":
        if not target_frame or target_frame not in ("first_frame", "last_frame"):
            return {"error": "target_frame required for hero_frame anchor (must be 'first_frame' or 'last_frame')"}
        extrapolating = target_frame
    else:
        extrapolating = "last_frame" if anchor_role == "first_frame" else "first_frame"
```

**Step 3: Add hero_frame system instruction path**

After the existing system instruction block (around line 338, after the match action section), add the hero_frame path. Find the line:
```python
    system_instruction += """
```
that adds the OUTPUT FORMAT section. BEFORE that line, add the hero_frame branching:

```python
    # ── Hero frame anchor: bidirectional extrapolation with kinetic overrides ──
    if anchor_role == "hero_frame":
        # Spatial anchoring: only for static camera. Moving camera gets reframe instructions.
        camera_move = prompt_data.get("camera_movement", "static")
        is_static = camera_move in ("static", "", None)

        if is_static:
            spatial_rule = "Subject remains centered in frame."
        else:
            spatial_rule = f"Camera is {camera_move} — adjust subject position to reflect the reframe."

        if extrapolating == "first_frame":
            system_instruction += f"""

CRITICAL KINETIC OVERRIDE — ANTICIPATION FRAME:
The reference image shows PEAK ACTION (the decisive moment), but you are generating
the frame BEFORE the action — the ANTICIPATION / setup.

You MUST strip all kinetic energy from the hero reference:
- Physics: Force a resting state. Gravity is normal.
- Wardrobe/Hair: Hair falls naturally, clothing hangs loose. NO wind, NO motion blur, NO flying debris.
- Anatomy: Muscles are coiled but STATIC. Posture is grounded and planted. {spatial_rule}
- Expression: Focused but subdued. Mouth closed. NO extreme grimaces, yelling, or flared tension.

Use anticipation verbs: braces, reaches toward, positions, eyes locked on, coils, prepares, steadies."""

        else:  # extrapolating == "last_frame"
            system_instruction += f"""

CRITICAL KINETIC OVERRIDE — AFTERMATH FRAME:
The reference image shows PEAK ACTION (the decisive moment), but you are generating
the frame AFTER the action — the AFTERMATH / resolution.

The energy has DISSIPATED:
- Physics: Momentum is breaking. Subject is recovering.
- Wardrobe/Hair: Settling back to a resting state. Motion blur gone.
- Anatomy: Posture is lowering, dropping, or stumbling. Tension is releasing. {spatial_rule}
- Expression: Exhausted, shocked, relieved, or exhaling. Tension leaving the face.

Use aftermath verbs: stumbles back, catches breath, stares at, lowers, releases, drops, exhales, steadies."""
```

**Step 4: Update match action context for hero_frame**

Find the match action section (around lines 340-346 and 389-396). Add hero_frame handling. After the existing adjacent shot context block, add:

```python
    # Match action context for hero_frame extrapolation
    if anchor_role == "hero_frame":
        if extrapolating == "last_frame" and shot_idx >= 0 and shot_idx < len(all_shots) - 1:
            # Aftermath → add next-shot context for match action
            nxt = all_shots[shot_idx + 1]
            nxt_skel = nxt.get("prompt_data", {}).get("prompt_skeleton", {})
            user_parts.append(f"# NEXT SHOT ({nxt.get('shot_id', '?')})\nAction: {nxt_skel.get('subject_line', '')}")
        elif extrapolating == "first_frame" and shot_idx > 0:
            # Anticipation → add previous-shot context for match action
            prev = all_shots[shot_idx - 1]
            prev_skel = prev.get("prompt_data", {}).get("prompt_skeleton", {})
            user_parts.append(f"# PREVIOUS SHOT ({prev.get('shot_id', '?')})\nAction: {prev_skel.get('subject_line', '')}")
```

**Step 5: Update the keyframe image label for hero_frame**

Find the line (around 367):
```python
            label = f"LOCKED KEYFRAME — this is the {'FIRST' if anchor_role == 'first_frame' else 'LAST'} frame"
```

Replace with:
```python
            if anchor_role == "hero_frame":
                label = "LOCKED KEYFRAME — this is the HERO frame (decisive moment / peak action)"
            else:
                label = f"LOCKED KEYFRAME — this is the {'FIRST' if anchor_role == 'first_frame' else 'LAST'} frame"
```

**Step 6: Update the final user instruction**

Find the line (around 404):
```python
    user_parts.append(f"Generate the {'LAST' if extrapolating == 'last_frame' else 'FIRST'} frame prompt now. Remember: minimal delta only.")
```

Replace with:
```python
    if anchor_role == "hero_frame":
        direction = "ANTICIPATION (before the peak)" if extrapolating == "first_frame" else "AFTERMATH (after the peak)"
        user_parts.append(f"Generate the {direction} frame prompt now. Remember: minimal delta, strip kinetic energy from the hero reference.")
    else:
        user_parts.append(f"Generate the {'LAST' if extrapolating == 'last_frame' else 'FIRST'} frame prompt now. Remember: minimal delta only.")
```

**Step 7: Verify import works**

Run: `cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend && python3 -c "from lib.keyframe_context import build_extrapolation_prompt; print('import OK')"`
Expected: `import OK`

**Step 8: Commit**

```bash
git add lib/keyframe_context.py
git commit -m "feat(keyframe): bidirectional extrapolation with kinetic overrides for hero_frame"
```

---

## Task 3: `_api_lock_keyframe()` — Frame Position Support

**Files:**
- Modify: `editors/review_server.py:2354-2405`

**Context:** The lock-keyframe endpoint currently accepts `anchor_role` as `"first_frame"`, `"last_frame"`, or `"still_only"`. We need to add `"hero_frame"` as a valid anchor role and accept a `frame_position` parameter ("first", "middle", "last") that describes where the keyframe sits in the shot timeline. Both are stored in `gate_results`.

**Step 1: Update validation and body parsing**

Find (line 2362):
```python
        anchor_role = body.get("anchor_role", "first_frame")
```

Replace with:
```python
        anchor_role = body.get("anchor_role", "hero_frame")
        frame_position = body.get("frame_position", "middle")
```

Find (line 2367):
```python
        if anchor_role not in ("first_frame", "last_frame", "still_only"):
            self._json_response({"error": f"Invalid anchor_role: {anchor_role}"}, 400)
            return
```

Replace with:
```python
        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
```

**Step 2: Store frame_position in gate_results**

Find the two `store.update_shot` calls (around lines 2390-2398). In BOTH calls, add `frame_position` to the gate_results dict:

```python
        # Find the locked keyframe path for 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 ""

        gate_update = {
            "anchor_role": anchor_role,
            "frame_position": frame_position,
        }
        # Store the keyframe path under the appropriate key
        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

        if shot["status"] == "keyframe_approved":
            store.update_shot(shot_id, gate_results=gate_update)
        else:
            store.update_shot(shot_id, status="keyframe_approved", gate_results=gate_update)
```

**Step 3: Update response to include frame_position**

Find (lines 2401-2405):
```python
        self._json_response({
            "shot_id": shot_id,
            "status": "keyframe_approved",
            "anchor_role": anchor_role,
        })
```

Replace with:
```python
        self._json_response({
            "shot_id": shot_id,
            "status": "keyframe_approved",
            "anchor_role": anchor_role,
            "frame_position": frame_position,
        })
```

**Step 4: Commit**

```bash
git add editors/review_server.py
git commit -m "feat(api): lock-keyframe accepts hero_frame anchor and frame_position"
```

---

## Task 4: `_api_extract_frame()` — Target Frame Support

**Files:**
- Modify: `editors/review_server.py:2407-2582`

**Context:** The extract-frame endpoint currently takes `anchor_role` and derives which frame to generate (the opposite). We need to accept `target_frame` ("first_frame", "last_frame", or "both") so the frontend can explicitly request what to generate. For `"both"`, generate sequentially in the background thread.

**Step 1: Update body parsing**

Find (lines 2420-2421):
```python
        anchor_role = body.get("anchor_role")
        prompt_override = body.get("prompt_override")
```

Replace with:
```python
        anchor_role = body.get("anchor_role")
        target_frame = body.get("target_frame")
        prompt_override = body.get("prompt_override")
```

**Step 2: Read anchor_role from gate_results if not provided**

After the `store.get_shot()` call (around line 2444), add fallback logic:

```python
        # 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")
```

**Step 3: Update validation to accept hero_frame**

Find (line 2426):
```python
        if anchor_role not in ("first_frame", "last_frame"):
            self._json_response({"error": f"Invalid anchor_role: {anchor_role}"}, 400)
            return
```

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

**Step 4: Determine target frames to generate**

Find (line 2523):
```python
        generating_frame = "last_frame" if anchor_role == "first_frame" else "first_frame"
```

Replace with the new logic that handles `target_frame` and `"both"`:

```python
        # 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"]
```

**Step 5: Refactor background thread for multi-frame generation**

Replace the entire background generation section (from the `self._json_response` at line 2538 through the `threading.Thread` call at line 2582) with:

```python
        # Respond immediately
        self._json_response({
            "shot_id": shot_id,
            "targets": frames_to_generate,
            "status": "generating",
        })

        # 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():
            try:
                frames_dir = _frames_dir / ep_dir
                frames_dir.mkdir(parents=True, exist_ok=True)

                for gen_frame in _frames_to_gen:
                    # Build prompt for this specific target
                    if prompt_override:
                        frame_prompt = prompt_override
                    else:
                        if not shot_data:
                            print(f"  [WARN] No shot data for {shot_id}, skipping {gen_frame}")
                            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] Extrapolation prompt failed for {shot_id} {gen_frame}: {extrap_result['error']}")
                            continue
                        frame_prompt = extrap_result["prompt"]

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

                    if not gen_result.get("success"):
                        print(f"  [WARN] Frame extraction failed for {shot_id} {gen_frame}: {gen_result.get('error')}")
                        continue

                    output_path = frames_dir / f"shot_{shot_num:03d}_{gen_frame}.png"
                    output_path.write_bytes(gen_result["image_data"])

                    _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=NBP_COST,
                    )

                    print(f"  [OK] {shot_id} {gen_frame} extracted — ${NBP_COST}")

            except Exception as e:
                print(f"  [ERR] Background frame extraction for {shot_id}: {e}")
            finally:
                store.close()

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

**Step 6: Commit**

```bash
git add editors/review_server.py
git commit -m "feat(api): extract-frame supports target_frame param and multi-frame generation"
```

---

## Task 5: `_api_confirm_frame_pair()` — Flexible Frame Count

**Files:**
- Modify: `editors/review_server.py:2584-2665`

**Context:** The confirm endpoint currently requires both `first_frame` and `last_frame`. With the hero_frame model, we need to handle 3-frame (first+hero+last), 2-frame, and partial sets. The director can confirm with whatever frames exist.

**Step 1: Replace the frame validation logic**

Find (lines 2615-2647). Replace the entire section from `gate = shot.get("gate_results", {})` through the `store.update_shot` call with:

```python
        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
        # For now, 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)
```

**Step 2: Commit**

```bash
git add editors/review_server.py
git commit -m "feat(api): confirm-frame-pair handles 3-frame killbox and partial sets"
```

---

## Task 6: Frontend — State C1-C3 Label Updates

**Files:**
- Modify: `editors/tabs/previz.js:207-290`

**Context:** Rename "KEYFRAME" labels to "HERO FRAME" across states C1, C2, and C3 to match the new conceptual model.

**Step 1: Update C2 label (generating state)**

Find (line 211):
```javascript
            <div class="kf-section-header">KEYFRAME</div>
```

Replace with:
```javascript
            <div class="kf-section-header">HERO FRAME</div>
```

Find (line 213):
```javascript
              <div style="margin-top:8px;font-size:10px;color:var(--text-dim)">GENERATING KEYFRAME...</div>
```

Replace with:
```javascript
              <div style="margin-top:8px;font-size:10px;color:var(--text-dim)">GENERATING HERO FRAME...</div>
```

**Step 2: Update C3 labels (review state)**

Find (line 265):
```javascript
            <div class="kf-section-header">KEYFRAME <span class="kf-take-badge">${kfTakes.length} take${kfTakes.length !== 1 ? 's' : ''}</span></div>
```

Replace with:
```javascript
            <div class="kf-section-header">HERO FRAME <span class="kf-take-badge">${kfTakes.length} take${kfTakes.length !== 1 ? 's' : ''}</span></div>
```

Find (line 271):
```javascript
              <button class="btn btn-sm btn-success" onclick="PrevizTab.lockKeyframe('${shotId}')">LOCK KEYFRAME</button>
```

Replace with:
```javascript
              <button class="btn btn-sm btn-success" onclick="PrevizTab.lockKeyframe('${shotId}')">LOCK HERO</button>
```

**Step 3: Update C1 labels (smart prompt state)**

Find (line 279):
```javascript
            <div class="kf-section-header">KEYFRAME</div>
```

Replace with:
```javascript
            <div class="kf-section-header">HERO FRAME</div>
```

Find (line 288):
```javascript
              <button class="btn btn-sm" onclick="PrevizTab.generateKeyframe('${shotId}')" style="border-color:var(--accent-magenta);color:var(--accent-magenta)">GENERATE KEYFRAME ($0.134)</button>
```

Replace with:
```javascript
              <button class="btn btn-sm" onclick="PrevizTab.generateKeyframe('${shotId}')" style="border-color:var(--accent-magenta);color:var(--accent-magenta)">GENERATE HERO ($0.134)</button>
```

**Step 4: Commit**

```bash
git add editors/tabs/previz.js
git commit -m "feat(ui): relabel KEYFRAME → HERO FRAME across previz states C1-C3"
```

---

## Task 7: Frontend — Inline LOCK Button in State B

**Files:**
- Modify: `editors/tabs/previz.js:341-367`

**Context:** Add a LOCK button to State B (has takes, pending review) so directors can approve previz without switching to the Dailies tab. Calls the existing `/api/dailies/approve` endpoint.

**Step 1: Add LOCK button to State B footer**

Find the State B footer (lines 363-366):
```javascript
          <div class="shot-card-footer">
            <div class="shot-cost">$${(exec.cost_incurred || 0).toFixed(3)}</div>
            <button class="btn" onclick="PrevizTab.generatePreviz('${shotId}')" style="border-color:var(--accent-blue);color:var(--accent-blue)">REGENERATE</button>
          </div>
```

Replace with:
```javascript
          <div class="shot-card-footer">
            <div class="shot-cost">$${(exec.cost_incurred || 0).toFixed(3)}</div>
            <div style="display:flex;gap:6px">
              <button class="btn" onclick="PrevizTab.generatePreviz('${shotId}')" style="border-color:var(--accent-blue);color:var(--accent-blue)">REGENERATE</button>
              <button class="btn btn-success" onclick="PrevizTab.lockPreviz('${shotId}')">LOCK</button>
            </div>
          </div>
```

**Step 2: Add lockPreviz function**

In the `window.PrevizTab = {` section (around line 488), add the new function:

```javascript
    lockPreviz: async (shotId) => {
      const res = await ConsoleApp.starsendPost('/api/dailies/approve', { shot_id: shotId });
      if (res.error) {
        ConsoleApp.Toast.error(res.error);
        return;
      }
      ConsoleApp.Toast.success(`${shotId} locked`);
      await render();
      ConsoleApp.pollStatus();
    },
```

**Step 3: Commit**

```bash
git add editors/tabs/previz.js
git commit -m "feat(ui): add inline LOCK button to previz State B cards"
```

---

## Task 8: Frontend — State C4: Frame Picker UI

**Files:**
- Modify: `editors/tabs/previz.js:243-259`

**Context:** Replace the current anchor role radio buttons (FIRST FRAME / LAST FRAME / STILL ONLY) with the full frame picker: position radios (FIRST/MIDDLE/LAST), frame generation checkboxes, auto-suggestion, and extract button.

**Step 1: Add suggestFrameConfig function**

Add this function inside the IIFE (before the `window.PrevizTab` section, around line 487):

```javascript
  function suggestFrameConfig(shot) {
    const routing = shot.routing_data || {};
    const prompt = shot.prompt_data || {};
    const skeleton = prompt.prompt_skeleton || {};
    const cameraMove = prompt.camera_movement || 'static';
    const isEnv = routing.is_env_only;
    const hasDialogue = !!(shot.dialogue || (shot.audio_data || {}).dialogue);
    const action = (skeleton.subject_line || '').toLowerCase();

    // ENV shots → still only
    if (isEnv) return { position: 'first', frames: [], stillOnly: true, reason: 'ENV shot — still only' };

    // High-action verbs → full killbox (3 frames)
    const actionVerbs = /wrenches|slams|lunges|spins|grabs|tears|crashes|fights|runs|chases|falls|jumps|strikes|throws|catches|tackles|dives/;
    if (actionVerbs.test(action) || (cameraMove !== 'static' && cameraMove)) {
      return { position: 'middle', frames: ['first', 'last'], stillOnly: false, reason: '3-frame killbox (high action)' };
    }

    // Dialogue/reaction → 2 frames from first
    if (hasDialogue || /reacts|responds|listens|speaks|says|tells|watches|turns/i.test(action)) {
      return { position: 'first', frames: ['last'], stillOnly: false, reason: '2-frame (dialogue/reaction)' };
    }

    // Default: 2 frames from first position
    return { position: 'first', frames: ['last'], stillOnly: false, reason: '2-frame (default)' };
  }
```

**Step 2: Replace State C4 HTML**

Find the entire State C4 block (lines 243-259, starting with `} else if (isKeyframeLocked) {`). Replace with:

```javascript
      // ── State C4: Keyframe locked, frame picker ──
      } else if (isKeyframeLocked) {
        // Get shot plan data for auto-suggestion
        const manifestShots = panel.querySelectorAll ? [] : [];  // placeholder
        const suggestion = suggestFrameConfig(shot);
        const currentPosition = gate.frame_position || suggestion.position;
        const isStillOnly = gate.anchor_role === 'still_only' || suggestion.stillOnly;

        // Determine which checkboxes are disabled/checked based on position
        const posFirst = currentPosition === 'first';
        const posMid = currentPosition === 'middle';
        const posLast = currentPosition === 'last';

        // Calculate cost based on checked frames
        const firstChecked = posMid || posLast;  // auto-check if keyframe isn't first
        const lastChecked = posMid || posFirst;   // auto-check if keyframe isn't last

        keyframeSection = `
          <div class="kf-section">
            <div class="kf-section-header">HERO FRAME LOCKED</div>
            ${keyframeImage ? `<img class="kf-thumb kf-hero-highlight" src="${keyframeImage}" alt="Hero frame">` : ''}
            <div class="kf-frame-picker" id="kf-picker-${shotId}" ${isStillOnly ? 'style="display:none"' : ''}>
              <div class="kf-anchor-label">POSITION:</div>
              <div class="kf-position-radios">
                <label class="kf-radio"><input type="radio" name="pos-${shotId}" value="first" ${posFirst ? 'checked' : ''} onchange="PrevizTab.setFramePosition('${shotId}', 'first')"> FIRST</label>
                <label class="kf-radio"><input type="radio" name="pos-${shotId}" value="middle" ${posMid ? 'checked' : ''} onchange="PrevizTab.setFramePosition('${shotId}', 'middle')"> MIDDLE</label>
                <label class="kf-radio"><input type="radio" name="pos-${shotId}" value="last" ${posLast ? 'checked' : ''} onchange="PrevizTab.setFramePosition('${shotId}', 'last')"> LAST</label>
              </div>
              <div class="kf-anchor-label" style="margin-top:6px">GENERATE:</div>
              <label class="kf-frame-picker-check"><input type="checkbox" id="kf-gen-first-${shotId}" ${firstChecked ? 'checked' : ''} ${posFirst ? 'disabled' : ''}> First frame (anticipation) <span style="color:var(--text-dim)">$0.134</span></label>
              <label class="kf-frame-picker-check"><input type="checkbox" id="kf-gen-last-${shotId}" ${lastChecked ? 'checked' : ''} ${posLast ? 'disabled' : ''}> Last frame (aftermath) <span style="color:var(--text-dim)">$0.134</span></label>
              <div class="kf-suggestion">${suggestion.reason}</div>
            </div>
            <div class="kf-actions">
              <label class="kf-radio" style="margin-right:auto"><input type="checkbox" id="kf-still-${shotId}" ${isStillOnly ? 'checked' : ''} onchange="PrevizTab.toggleStillOnly('${shotId}', this.checked)"> STILL ONLY</label>
              ${isStillOnly
                ? `<button class="btn btn-sm btn-success" onclick="PrevizTab.confirmFramePair('${shotId}')">CONFIRM \u2192 VIDEO</button>`
                : `<button class="btn btn-sm" onclick="PrevizTab.extractFrames('${shotId}')" style="border-color:var(--accent-magenta);color:var(--accent-magenta)" id="kf-extract-btn-${shotId}">EXTRACT SELECTED</button>`}
            </div>
          </div>`;
```

**Step 3: Add new JS functions**

Add these to the `window.PrevizTab` object:

```javascript
    setFramePosition: (shotId, position) => {
      const picker = document.getElementById(`kf-picker-${shotId}`);
      if (!picker) return;

      const firstCb = document.getElementById(`kf-gen-first-${shotId}`);
      const lastCb = document.getElementById(`kf-gen-last-${shotId}`);

      // Update checkbox states based on position
      if (position === 'first') {
        firstCb.checked = false; firstCb.disabled = true;
        lastCb.checked = true; lastCb.disabled = false;
      } else if (position === 'middle') {
        firstCb.checked = true; firstCb.disabled = false;
        lastCb.checked = true; lastCb.disabled = false;
      } else if (position === 'last') {
        firstCb.checked = true; firstCb.disabled = false;
        lastCb.checked = false; lastCb.disabled = true;
      }
    },

    toggleStillOnly: (shotId, isStill) => {
      const picker = document.getElementById(`kf-picker-${shotId}`);
      if (picker) picker.style.display = isStill ? 'none' : '';

      // If toggling to still, re-lock as still_only
      if (isStill) {
        ConsoleApp.starsendPost('/api/lock-keyframe', {
          shot_id: shotId,
          anchor_role: 'still_only',
          frame_position: 'first',
        });
      } else {
        // Restore hero_frame
        const pos = document.querySelector(`input[name="pos-${shotId}"]:checked`)?.value || 'middle';
        const anchorMap = { first: 'first_frame', middle: 'hero_frame', last: 'last_frame' };
        ConsoleApp.starsendPost('/api/lock-keyframe', {
          shot_id: shotId,
          anchor_role: anchorMap[pos],
          frame_position: pos,
        });
      }
      render();
    },

    extractFrames: async (shotId) => {
      const firstCb = document.getElementById(`kf-gen-first-${shotId}`);
      const lastCb = document.getElementById(`kf-gen-last-${shotId}`);
      const genFirst = firstCb && firstCb.checked && !firstCb.disabled;
      const genLast = lastCb && lastCb.checked && !lastCb.disabled;

      if (!genFirst && !genLast) {
        ConsoleApp.Toast.error('Select at least one frame to generate');
        return;
      }

      // Determine anchor_role from position
      const pos = document.querySelector(`input[name="pos-${shotId}"]:checked`)?.value || 'first';
      const anchorMap = { first: 'first_frame', middle: 'hero_frame', last: 'last_frame' };
      const anchorRole = anchorMap[pos];

      // Update anchor role + position on server first
      await ConsoleApp.starsendPost('/api/lock-keyframe', {
        shot_id: shotId,
        anchor_role: anchorRole,
        frame_position: pos,
      });

      // Determine target_frame
      let targetFrame;
      if (genFirst && genLast) targetFrame = 'both';
      else if (genFirst) targetFrame = 'first_frame';
      else targetFrame = 'last_frame';

      ConsoleApp.Toast.show(`Extracting frames for ${shotId}...`);

      const res = await ConsoleApp.starsendPost('/api/extract-frame', {
        shot_id: shotId,
        target_frame: targetFrame,
      });

      if (res.error) {
        ConsoleApp.Toast.error(res.error);
        return;
      }

      // Poll for completion — check gate_results for ALL expected frames
      const ep = ConsoleApp.state.selectedEpisode;
      let attempts = 0;
      const poll = setInterval(async () => {
        attempts++;
        const boardData = await ConsoleApp.starsendGet(`/api/board/${ep}`);
        if (!boardData.error && boardData.shots) {
          const s = boardData.shots.find(s => s.shot_id === shotId);
          if (s) {
            const g = s.gate_results || {};
            const firstDone = !genFirst || !!g.first_frame;
            const lastDone = !genLast || !!g.last_frame;
            if (firstDone && lastDone) {
              clearInterval(poll);
              ConsoleApp.Toast.success(`Frames extracted for ${shotId}`);
              await render();
              ConsoleApp.pollStatus();
            }
          }
        }
        if (attempts > 240) { // 120 seconds for both frames
          clearInterval(poll);
          ConsoleApp.Toast.error(`Timed out waiting for frame extraction`);
          await render();
        }
      }, 500);
    },

    reExtract: async (shotId, targetFrame) => {
      ConsoleApp.Toast.show(`Re-extracting ${targetFrame} for ${shotId}...`);

      const res = await ConsoleApp.starsendPost('/api/extract-frame', {
        shot_id: shotId,
        target_frame: targetFrame,
      });

      if (res.error) {
        ConsoleApp.Toast.error(res.error);
        return;
      }

      // Poll for completion
      const ep = ConsoleApp.state.selectedEpisode;
      let attempts = 0;
      const poll = setInterval(async () => {
        attempts++;
        const boardData = await ConsoleApp.starsendGet(`/api/board/${ep}`);
        if (!boardData.error && boardData.shots) {
          const s = boardData.shots.find(s => s.shot_id === shotId);
          if (s && (s.gate_results || {})[targetFrame]) {
            clearInterval(poll);
            ConsoleApp.Toast.success(`${targetFrame} re-extracted for ${shotId}`);
            await render();
            ConsoleApp.pollStatus();
          }
        }
        if (attempts > 120) {
          clearInterval(poll);
          ConsoleApp.Toast.error(`Timed out waiting for re-extraction`);
          await render();
        }
      }, 500);
    },
```

**Step 4: Update the existing lockKeyframe function**

Find (lines 753-765):
```javascript
    lockKeyframe: async (shotId) => {
      const res = await ConsoleApp.starsendPost('/api/lock-keyframe', {
        shot_id: shotId,
        anchor_role: 'first_frame',  // Default anchor
      });
```

Replace with:
```javascript
    lockKeyframe: async (shotId) => {
      const res = await ConsoleApp.starsendPost('/api/lock-keyframe', {
        shot_id: shotId,
        anchor_role: 'hero_frame',
        frame_position: 'middle',
      });
```

**Step 5: Commit**

```bash
git add editors/tabs/previz.js
git commit -m "feat(ui): frame picker with position radios, auto-suggestion, and extract controls"
```

---

## Task 9: Frontend — State C5: Flexible Frame Viewer

**Files:**
- Modify: `editors/tabs/previz.js:217-240`

**Context:** State C5 currently shows a two-pane viewer (FIRST ← → LAST). We need to detect how many frames exist (1, 2, or 3) and render the appropriate layout with dramaturgical labels (ANTICIPATION / PEAK ACTION / AFTERMATH).

**Step 1: Add hero_frame detection to state variables**

Find (around lines 159-161):
```javascript
    const hasFirstFrame = !!gate.first_frame;
    const hasLastFrame = !!gate.last_frame;
    const hasFramePair = anchorRole === 'still_only' || (hasFirstFrame && hasLastFrame);
```

Replace with:
```javascript
    const hasFirstFrame = !!gate.first_frame;
    const hasLastFrame = !!gate.last_frame;
    const hasHeroFrame = !!gate.hero_frame;
    const framePosition = gate.frame_position || 'first';
    const frameCount = [hasFirstFrame, hasHeroFrame, hasLastFrame].filter(Boolean).length;
    const hasFramePair = anchorRole === 'still_only' || frameCount >= 2;
```

**Step 2: Replace State C5 HTML**

Find the entire State C5 block (lines 217-240). Replace with:

```javascript
      // ── State C5: Frame viewer (flexible: 1/2/3 panes) ──
      } else if (isKeyframeLocked && hasFramePair && anchorRole !== 'still_only') {
        const firstSrc = gate.first_frame ? ConsoleApp.outputUrl(`/${gate.first_frame}`, true) : '';
        const heroSrc = gate.hero_frame ? ConsoleApp.outputUrl(`/${gate.hero_frame}`, true) : '';
        const lastSrc = gate.last_frame ? ConsoleApp.outputUrl(`/${gate.last_frame}`, true) : '';

        let framesHtml = '';
        if (frameCount === 3) {
          // Three-pane: ANTICIPATION → PEAK ACTION → AFTERMATH
          framesHtml = `
            <div class="kf-frame-triplet">
              <div class="kf-frame-slot">
                <div class="kf-frame-label">ANTICIPATION</div>
                ${firstSrc ? `<img class="kf-frame-img" src="${firstSrc}" alt="First frame">` : '<div class="kf-frame-placeholder">PENDING</div>'}
                <button class="btn btn-xs" onclick="PrevizTab.reExtract('${shotId}', 'first_frame')" style="margin-top:4px;border-color:var(--text-dim);color:var(--text-dim)">RE-EXTRACT</button>
              </div>
              <div class="kf-frame-arrow">\u2192</div>
              <div class="kf-frame-slot kf-frame-hero">
                <div class="kf-frame-label" style="color:var(--accent-magenta)">PEAK ACTION</div>
                ${heroSrc ? `<img class="kf-frame-img kf-hero-highlight" src="${heroSrc}" alt="Hero frame">` : '<div class="kf-frame-placeholder">ANCHOR</div>'}
                <div style="font-size:9px;color:var(--text-dim);margin-top:4px">(LOCKED)</div>
              </div>
              <div class="kf-frame-arrow">\u2192</div>
              <div class="kf-frame-slot">
                <div class="kf-frame-label">AFTERMATH</div>
                ${lastSrc ? `<img class="kf-frame-img" src="${lastSrc}" alt="Last frame">` : '<div class="kf-frame-placeholder">PENDING</div>'}
                <button class="btn btn-xs" onclick="PrevizTab.reExtract('${shotId}', 'last_frame')" style="margin-top:4px;border-color:var(--text-dim);color:var(--text-dim)">RE-EXTRACT</button>
              </div>
            </div>`;
        } else {
          // Two-pane: FIRST ← → LAST (legacy or 2-frame shots)
          framesHtml = `
            <div class="kf-frame-pair">
              <div class="kf-frame-slot">
                <div class="kf-frame-label">FIRST${anchorRole === 'first_frame' ? ' (ANCHOR)' : ''}</div>
                ${firstSrc ? `<img class="kf-frame-img" src="${firstSrc}" alt="First frame">` : '<div class="kf-frame-placeholder">PENDING</div>'}
                ${anchorRole !== 'first_frame' ? `<button class="btn btn-xs" onclick="PrevizTab.reExtract('${shotId}', 'first_frame')" style="margin-top:4px;border-color:var(--text-dim);color:var(--text-dim)">RE-EXTRACT</button>` : ''}
              </div>
              <div class="kf-frame-arrow">\u2192</div>
              <div class="kf-frame-slot">
                <div class="kf-frame-label">LAST${anchorRole === 'last_frame' ? ' (ANCHOR)' : ''}</div>
                ${lastSrc ? `<img class="kf-frame-img" src="${lastSrc}" alt="Last frame">` : '<div class="kf-frame-placeholder">PENDING</div>'}
                ${anchorRole !== 'last_frame' ? `<button class="btn btn-xs" onclick="PrevizTab.reExtract('${shotId}', 'last_frame')" style="margin-top:4px;border-color:var(--text-dim);color:var(--text-dim)">RE-EXTRACT</button>` : ''}
              </div>
            </div>`;
        }

        keyframeSection = `
          <div class="kf-section">
            <div class="kf-section-header">${frameCount === 3 ? 'KILLBOX' : 'FRAME PAIR'}</div>
            ${framesHtml}
            <div class="kf-actions">
              <button class="btn btn-sm btn-success" onclick="PrevizTab.confirmFramePair('${shotId}')">CONFIRM \u2192 VIDEO</button>
              <button class="btn btn-xs" onclick="PrevizTab.useStartFrameOnly('${shotId}')" style="border-color:var(--text-dim);color:var(--text-dim);margin-left:auto" title="Fallback: use first frame only for Kling">FIRST ONLY</button>
            </div>
          </div>`;
```

**Step 3: Commit**

```bash
git add editors/tabs/previz.js
git commit -m "feat(ui): flexible frame viewer — 2-pane and 3-pane with dramaturgical labels"
```

---

## Task 10: CSS — Frame Picker + Three-Pane Layout

**Files:**
- Modify: `editors/styles/console.css:1265` (before the scrollbar section)

**Context:** Add CSS for the new frame picker controls, three-pane layout, hero highlight, and suggestion annotation.

**Step 1: Add new CSS rules**

Insert before the `/* ── Scrollbar ── */` comment (line 1266):

```css
/* ── Frame Picker (Three-Frame Killbox) ── */
.kf-frame-picker { margin: 8px 0; }
.kf-frame-picker-check {
  display: flex;
  align-items: center;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-secondary);
  padding: 2px 0;
  cursor: pointer;
}
.kf-frame-picker-check input[type="checkbox"] {
  accent-color: var(--accent-magenta);
}
.kf-frame-picker-check input:disabled {
  opacity: 0.3;
}
.kf-position-radios {
  display: flex;
  gap: 12px;
  margin: 4px 0;
}
.kf-suggestion {
  font-family: var(--font-mono);
  font-size: 9px;
  color: var(--text-dim);
  font-style: italic;
  margin-top: 4px;
  padding: 3px 6px;
  background: var(--bg-tertiary);
  border-radius: var(--radius-sm);
  border-left: 2px solid var(--border-default);
}

/* ── Three-Pane Frame Viewer ── */
.kf-frame-triplet {
  display: flex;
  align-items: flex-start;
  gap: 3px;
  margin: 6px 0;
}
.kf-frame-triplet .kf-frame-slot {
  flex: 1;
  text-align: center;
}
.kf-frame-triplet .kf-frame-hero {
  flex: 1.15;
}
.kf-hero-highlight {
  border: 2px solid var(--accent-magenta) !important;
  box-shadow: 0 0 8px rgba(255, 0, 128, 0.3);
}

```

**Step 2: Commit**

```bash
git add editors/styles/console.css
git commit -m "feat(css): frame picker controls and three-pane killbox layout"
```

---

## Task 11: Remove Old extractFrame Function + Update References

**Files:**
- Modify: `editors/tabs/previz.js:767-818`

**Context:** The old `extractFrame` function passes `anchor_role` directly from radio buttons. Replace it with the new `extractFrames` (Task 8) and update the `useStartFrameOnly` function. The old `extractFrame` can remain as a thin wrapper for backward compat if any other code references it.

**Step 1: Update the old extractFrame to delegate**

Find the existing `extractFrame` function (lines 767-818). Replace its body with a delegation to the new function:

```javascript
    extractFrame: async (shotId, anchorRole) => {
      // Legacy wrapper — delegate to new extractFrames
      if (!anchorRole || anchorRole === 'still_only') {
        ConsoleApp.Toast.error('Cannot extract frames for still_only');
        return;
      }
      // Use the new target_frame API
      const targetFrame = anchorRole === 'first_frame' ? 'last_frame' : 'first_frame';
      await PrevizTab.reExtract(shotId, targetFrame);
    },
```

**Step 2: Commit**

```bash
git add editors/tabs/previz.js
git commit -m "refactor(ui): delegate old extractFrame to new reExtract function"
```

---

## Task 12: Backward Compatibility Verification

**Files:** None to modify — this is a verification task.

**Step 1: Verify old anchor_role values still work**

The backend changes in Tasks 3-5 must still accept old `first_frame` and `last_frame` anchor roles. Verify by reading through:

- `_api_lock_keyframe()`: accepts `first_frame`, `last_frame`, `hero_frame`, `still_only` ✓
- `_api_extract_frame()`: accepts `first_frame`, `last_frame`, `hero_frame` ✓
- `_api_confirm_frame_pair()`: reads `anchor_role` from gate_results, handles all values ✓
- `build_extrapolation_prompt()`: `target_frame=None` falls back to old behavior ✓

**Step 2: Verify frontend detects frame_position**

The frontend reads `gate.frame_position` but falls back to `'first'` if missing. Old shots without `frame_position` will render in 2-pane mode (State C5 legacy path). ✓

**Step 3: Start review server and verify console loads**

Run:
```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend
python3 editors/review_server.py --project tartarus
```

Open `http://127.0.0.1:8430/console` and verify:
1. Previz tab loads without JS errors (check browser console)
2. State B cards show LOCK button alongside REGENERATE
3. State C1 shows "HERO FRAME" header and "GENERATE HERO ($0.134)" button
4. State C3 shows "HERO FRAME" header and "LOCK HERO" button
5. State C4 shows frame picker with position radios and checkboxes
6. State C5 renders 2-pane for old shots, would render 3-pane for killbox shots

**Step 4: Commit tag**

```bash
git tag -a v0.killbox-v1 -m "Three-Frame Killbox v1 complete"
```
