# Starsend Studio — PoC Implementation Plan

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

**Goal:** Validate two kill criteria for an autonomous 30-second microseries production box: (1) video quality floor, (2) autonomous workflow viability.

**Architecture:** Monorepo — add `starsend/studio/` to existing Starsend engine. Audio-first FFmpeg assembly (hard video cuts over continuous audio mix). Hybrid autonomy (dumb state machine + smart module-level recovery). See `consultations/starsend_studio_autonomous/SYNTHESIS.md` for locked decisions.

**Tech Stack:** Python 3.12+, FFmpeg, SQLite+WAL, python-telegram-bot, YouTube Data API v3, Instagram Graph API, ElevenLabs API, existing Starsend engine (API clients, cost tracker)

**Design Doc:** `consultations/starsend_studio_autonomous/SYNTHESIS.md`

---

## Pre-PoC Dependencies (Task 0)

These must be completed before any PoC tasks begin. Some are JT-driven (creative decisions), some are administrative.

### Task 0A: Create Test Premise

**Owner:** JT (creative decision — Claude assists)
**Files:**
- Create: `starsend/studio/test_data/test_premise.json`

**Step 1:** Write a minimal 30-second scene premise:
- 1 protagonist (name, 3 behavioral traits, voice pattern)
- 1 location (name, description, lighting)
- 5-7 shot breakdown: 2 environment, 2 character, 1 detail/cliffhanger
- 2-3 lines of dialogue with direction
- A poll question with 3 options

```json
{
  "premise": "A lone astronaut wakes in a crashed pod on an alien surface. The AI that landed her is talking, but the readouts don't match what she sees outside.",
  "protagonist": {
    "name": "Kira",
    "traits": ["methodical", "quietly defiant", "gallows humor"],
    "voice_pattern": "Short sentences. Dry. Trusts instruments over eyes."
  },
  "location": {
    "name": "Crash Site",
    "description": "Shattered escape pod half-buried in violet sand. Bioluminescent fungi on rocks. Thin atmosphere — sky is wrong color.",
    "lighting": "Golden hour alien — warm but unsettling. Two light sources (sun + bioluminescence)."
  },
  "shots": [
    {"number": 1, "type": "environment", "duration": 5, "description": "WS — Vast alien landscape. Crashed pod in distance. Violet sand, bioluminescent rocks."},
    {"number": 2, "type": "character", "duration": 5, "description": "MS — Kira climbs out of pod. Suit damaged. Looks at sky. Expression: confused."},
    {"number": 3, "type": "detail", "duration": 3, "description": "ECU — Pod readout screen. Atmosphere reads 'BREATHABLE' but numbers are wrong."},
    {"number": 4, "type": "environment", "duration": 4, "description": "WS — Camera pushes in on the horizon. Something massive moves in the distance, barely visible."},
    {"number": 5, "type": "character", "duration": 6, "description": "CU — Kira's face. She's made a decision. Reaches for helmet seal."},
    {"number": 6, "type": "cliffhanger", "duration": 4, "description": "MCU — Kira's hand on helmet seal. Freeze frame. Poll overlay appears."}
  ],
  "voice_lines": [
    {"character": "ai_voice", "text": "Atmosphere nominal. You can breathe.", "direction": "calm, clinical", "timing": 3.0},
    {"character": "kira", "text": "Then why does the spectrometer say otherwise?", "direction": "quiet, suspicious", "timing": 4.0}
  ],
  "poll": {
    "question": "Does Kira remove her helmet?",
    "options": ["She trusts the AI and removes it", "She keeps it sealed and walks", "She smashes the readout panel"]
  }
}
```

**Step 2:** JT reviews and adjusts the premise (or creates their own from scratch). This is a creative decision — the test content should be something JT would actually want to post.

### Task 0B: Generate Character References

**Files:**
- Output: `starsend/output/refs/characters/kira/` (or whatever character name)

**Step 1:** Use the existing Starsend casting pipeline to generate character reference images for the test protagonist. Run via Production Console casting endpoints or CLI.

**Step 2:** Select hero + turnaround refs. These become the NBP reference images for all character shots.

### Task 0C: Register Telegram Bot

**Step 1:** Message @BotFather on Telegram:
```
/newbot
Name: Starsend Studio
Username: starsend_studio_bot
```

**Step 2:** Save the bot token. Note JT's Telegram user ID (send a message to @userinfobot to get it).

**Step 3:** Store both values:
```bash
echo "TELEGRAM_BOT_TOKEN=xxx" >> ~/starsend-studio.env
echo "TELEGRAM_ALLOWED_USER_ID=xxx" >> ~/starsend-studio.env
chmod 600 ~/starsend-studio.env
```

### Task 0D: Start Meta App Review (Non-Blocking)

**Step 1:** Go to [developers.facebook.com](https://developers.facebook.com). Create a Meta App with Instagram Content Publishing permissions.

**Step 2:** Submit for App Review. This takes 2-4 weeks. Don't block the PoC on this — YouTube and TikTok work without it.

---

## PoC Step 1: Visual Benchmark (Manual)

**Goal:** Prove the model stack can produce a watchable 30-second episode.
**Go/No-Go:** JT watches the video and says "I'd post this." If not, project is dead.
**Duration:** 1 day of focused work.

### Task 1.1: Generate Environment Shots

**Files:**
- Output: `starsend/studio/test_data/raw_assets/shot_1_env.mp4`, `shot_4_env.mp4`

**Step 1:** Write Veo 3.1 T2V prompts for the 2 environment shots using the test premise descriptions. Use the existing Starsend API client.

```python
from lib.api_client import get_client

client = get_client("veo-3.1")
# Shot 1: WS alien landscape
result = client.submit({
    "prompt": "Vast alien landscape. Crashed escape pod half-buried in violet sand. Bioluminescent fungi on rocks. Golden hour lighting with two light sources. Cinematic. 9:16 vertical. 5 seconds.",
    "duration_seconds": 5,
    "aspect_ratio": "9:16"
})
```

**Step 2:** Review output. If quality is unacceptable, retry with adjusted prompt (up to 3 attempts). Save best results.

### Task 1.2: Generate Character Keyframes + Video

**Files:**
- Output: `starsend/studio/test_data/raw_assets/shot_2_char_keyframe.png`, `shot_2_char.mp4`, `shot_5_char_keyframe.png`, `shot_5_char.mp4`

**Step 1:** Generate NBP keyframes for the 2 character shots, injecting character reference images.

```python
from lib.api_client import get_client

client = get_client("gemini-3-pro-image-preview")
# Shot 2: Kira climbing out of pod
result = client.submit({
    "prompt": "MS — Young woman astronaut climbing out of crashed escape pod. Damaged spacesuit. Looking up at alien sky. Confused expression. Violet sand. Golden hour. 9:16 vertical.",
    "references": ["starsend/output/refs/characters/kira/hero.png"],
    "aspect_ratio": "9:16"
})
```

**Step 2:** Send keyframes to Kling 3.0 I2V for 4-6 second video clips.

**Step 3:** Review. Retry if needed (up to 3 attempts per shot).

### Task 1.3: Generate Detail/Cliffhanger Stills

**Files:**
- Output: `starsend/studio/test_data/raw_assets/shot_3_detail.png`, `shot_6_cliffhanger.png`

**Step 1:** Generate NBP stills for the detail shot and cliffhanger freeze frame.

### Task 1.4: Generate Voiceover

**Files:**
- Output: `starsend/studio/test_data/raw_assets/vo_ai.wav`, `vo_kira.wav`

**Step 1:** Use ElevenLabs API to generate the 2 voice lines from the test premise.

**Step 2:** If JT hasn't set up a custom voice, use a stock ElevenLabs voice that fits the character description.

### Task 1.5: Manual Assembly in Premiere Pro

**Owner:** JT (manual edit)
**Files:**
- Output: `starsend/studio/test_data/reference_edit.mp4`

**Step 1:** JT imports all raw assets into Premiere Pro.

**Step 2:** Assemble the 30-second episode:
- Hard cuts between shots (timed to VO)
- VO as primary audio
- Music bed (stock or Suno-generated) at -18dB
- Captions burned in (bottom third)
- Poll overlay on final 3 seconds

**Step 3:** Export as 1080x1920 H.264, 30fps.

**Step 4: GO/NO-GO GATE.** JT watches the video. "Would I post this?" If yes, proceed to Step 2. If no, diagnose what's wrong (which shots? audio? pacing?) and decide whether to retry or kill the project.

---

## PoC Step 2: FFmpeg Assembler

**Goal:** Replicate JT's Premiere Pro edit perfectly using only Python + FFmpeg.
**Go/No-Go:** FFmpeg output is indistinguishable from Premiere AND renders in under 2 minutes.
**Duration:** 3 days (with 1-day buffer).

### Task 2.1: Create Studio Module Skeleton

**Files:**
- Create: `starsend/studio/__init__.py`
- Create: `starsend/studio/assembler.py`
- Create: `starsend/studio/test_data/episode_script.json` (from test premise)
- Create: `tests/studio/test_assembler.py`

**Step 1: Write the failing test**

```python
# tests/studio/test_assembler.py
import json
from pathlib import Path
from studio.assembler import assemble_episode

TEST_DATA = Path(__file__).parent.parent.parent / "studio" / "test_data"

def test_assemble_produces_mp4():
    """Assembler produces a valid MP4 file."""
    script = json.loads((TEST_DATA / "episode_script.json").read_text())
    clips = sorted(TEST_DATA.glob("raw_assets/shot_*.mp4"))
    stills = sorted(TEST_DATA.glob("raw_assets/shot_*.png"))
    vo_files = sorted(TEST_DATA.glob("raw_assets/vo_*.wav"))

    output = TEST_DATA / "output" / "test_episode.mp4"
    output.parent.mkdir(exist_ok=True)

    result = assemble_episode(
        script=script,
        video_clips=clips,
        still_images=stills,
        voice_tracks=vo_files,
        music_bed=None,  # Optional for test
        output_path=output,
    )

    assert result.exists()
    assert result.suffix == ".mp4"
    assert result.stat().st_size > 100_000  # At least 100KB
```

**Step 2: Run test to verify it fails**

Run: `cd ~/Dropbox/CLAUDE_PROJECTS/starsend && python3 -m pytest tests/studio/test_assembler.py -v`
Expected: FAIL (module not found)

**Step 3: Write minimal assembler skeleton**

```python
# starsend/studio/assembler.py
"""
Audio-first FFmpeg assembler for 30-second vertical episodes.

Architecture (from Gemini consultation):
- Video track: hard cuts via concat demuxer (no crossfades)
- Audio track: continuous master mix (VO + music + SFX via amix)
- Shot timing derived from VO script
- ASS subtitles for captions
- Poll overlay on final N seconds
- AI watermark burned into corner
"""
import json
import subprocess
import tempfile
from pathlib import Path


def assemble_episode(
    script: dict,
    video_clips: list[Path],
    still_images: list[Path],
    voice_tracks: list[Path],
    music_bed: Path | None,
    output_path: Path,
    watermark_text: str = "AI Generated",
    poll_overlay: Path | None = None,
) -> Path:
    """Assemble a 30-second vertical episode from raw assets."""
    # Phase 1: Normalize all video clips to 1080x1920 30fps
    normalized = _normalize_clips(video_clips, still_images, script)

    # Phase 2: Build concat file for hard cuts
    concat_file = _build_concat_file(normalized)

    # Phase 3: Build master audio mix
    audio_mix = _build_audio_mix(voice_tracks, music_bed)

    # Phase 4: Combine video + audio + captions + watermark
    _final_render(concat_file, audio_mix, output_path, script, watermark_text)

    return output_path
```

**Step 4:** Implement each sub-function with real FFmpeg commands. This is the core work — roughly:
- `_normalize_clips()`: scale + pad to 1080x1920, convert stills to 4-sec video
- `_build_concat_file()`: write concat demuxer txt file
- `_build_audio_mix()`: merge VO + music with volume levels (-6dB VO, -18dB music)
- `_final_render()`: overlay concat video + audio mix + ASS captions + watermark

**Step 5:** Run the test. Compare FFmpeg output visually against Premiere Pro reference.

**Step 6: Commit**
```bash
git add starsend/studio/ tests/studio/
git commit -m "feat(studio): add FFmpeg assembler for 30-second vertical episodes"
```

### Task 2.2: Subtitle Rendering

**Files:**
- Modify: `starsend/studio/assembler.py`
- Create: `starsend/studio/assets/caption_style.ass`
- Create: `tests/studio/test_subtitles.py`

**Step 1: Write failing test**

```python
def test_assemble_with_captions():
    """Assembler burns in styled subtitles from voice_lines."""
    # Same as test_assemble_produces_mp4 but verify subtitle track exists
    # Use ffprobe to check for subtitle stream or burned-in text
```

**Step 2:** Create ASS style file with:
- Font: Bold sans-serif, white with black outline
- Position: bottom third, 100px from bottom edge (safe zone)
- Timing: derived from `voice_lines[].timing_seconds` in script

**Step 3:** Add subtitle burn-in to `_final_render()`:
```bash
ffmpeg ... -vf "ass=caption_style.ass" ...
```

**Step 4: Commit**

### Task 2.3: Poll Overlay + Watermark

**Files:**
- Modify: `starsend/studio/assembler.py`
- Create: `starsend/studio/assets/poll_template.py` (generates poll overlay PNG from script data)
- Create: `tests/studio/test_poll_overlay.py`

**Step 1:** Write a function that generates a poll overlay PNG from the script's `poll` data (question + 3 options). Use Pillow.

**Step 2:** Add poll overlay to final 3 seconds of video via FFmpeg overlay filter.

**Step 3:** Add watermark text ("AI Generated") to bottom-left corner via FFmpeg `drawtext` filter.

**Step 4: GO/NO-GO GATE.** Run `time python3 -m studio.assembler ...` — must complete in under 2 minutes. Compare output frame-by-frame against Premiere reference.

**Step 5: Commit**

---

## PoC Step 3: Dry-Run Daemon

**Goal:** Prove the state machine + Telegram bot can run unattended for 48 hours.
**Go/No-Go:** Zero crashes AND survives mid-cycle kill + restart.
**Duration:** 2 days.

### Task 3.1: State Machine + SQLite Persistence

**Files:**
- Create: `starsend/studio/orchestrator.py`
- Create: `starsend/studio/models.py` (Pydantic models for data schemas)
- Create: `tests/studio/test_orchestrator.py`

**Step 1: Write failing test**

```python
def test_state_machine_transitions():
    """Orchestrator transitions through all phases in order."""
    orch = Orchestrator(db_path=":memory:", mock=True)
    orch.start_cycle()
    assert orch.phase == CyclePhase.HARVESTING
    orch.advance()
    assert orch.phase == CyclePhase.ANALYZING
    # ... through all 10 phases back to IDLE

def test_state_persists_across_restart():
    """Orchestrator resumes from SQLite after crash."""
    db_path = tmp_path / "test.db"
    orch1 = Orchestrator(db_path=db_path, mock=True)
    orch1.start_cycle()
    orch1.advance()  # Now at ANALYZING
    del orch1  # Simulate crash

    orch2 = Orchestrator(db_path=db_path, mock=True)
    assert orch2.phase == CyclePhase.ANALYZING  # Resumed correctly
```

**Step 2:** Implement `Orchestrator` class:
- `CyclePhase` enum (all 10 states from design doc)
- SQLite `studio_cycles` table: `id, episode, phase, started_at, updated_at, cost_so_far`
- `advance()`: hardcoded valid transitions (no LLM)
- `mock=True` flag: skips API calls, copies test data instead

**Step 3:** Implement `models.py` — Pydantic models for the 3 locked data schemas:
- `AudienceBrief` (from `audience_brief.json` contract)
- `EpisodeScript` (from `episode_script.json` contract)
- `SeriesState` (from `series_state.json` contract)

**Step 4: Commit**

### Task 3.2: Telegram Bot

**Files:**
- Create: `starsend/studio/notifier.py`
- Create: `tests/studio/test_notifier.py`

**Step 1: Write failing test**

```python
def test_bot_rejects_unknown_user():
    """Bot silently drops messages from non-JT users."""
    # Mock telegram update with wrong user_id
    # Assert no response sent

def test_approve_command():
    """Bot /approve advances orchestrator from AWAITING_SCRIPT_REVIEW."""
    # Mock orchestrator in AWAITING_SCRIPT_REVIEW
    # Send /approve from JT's user ID
    # Assert orchestrator advanced to PRODUCING
```

**Step 2:** Implement `notifier.py`:
- `python-telegram-bot` in polling mode
- Hardcoded `ALLOWED_USER_ID` from .env
- All 9 commands from design doc
- All notification templates
- Silently drop messages from unknown user IDs

**Step 3: Commit**

### Task 3.3: launchd Plist + 48-Hour Soak Test

**Files:**
- Create: `starsend/studio/com.starsend.studio.plist`
- Create: `starsend/studio/install.sh`

**Step 1:** Write launchd plist:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.starsend.studio</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/python3</string>
        <string>/Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend/studio/orchestrator.py</string>
        <string>--mock</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>EnvironmentVariables</key>
    <dict>
        <key>STARSEND_ENV_FILE</key>
        <string>/Users/joeturnerlin/starsend-studio.env</string>
    </dict>
    <key>StandardOutPath</key>
    <string>/Users/joeturnerlin/logs/starsend-studio/stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/joeturnerlin/logs/starsend-studio/stderr.log</string>
</dict>
</plist>
```

**Step 2:** Install and start:
```bash
mkdir -p ~/logs/starsend-studio
cp starsend/studio/com.starsend.studio.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.starsend.studio.plist
```

**Step 3:** Let it run for 48 hours in mock mode (cycling through phases with test data).

**Step 4:** Mid-soak, kill the process:
```bash
kill $(pgrep -f "studio/orchestrator.py")
```
Verify launchd restarts it AND it resumes from the correct phase (check SQLite).

**Step 5:** Verify Telegram bot handles /status, /approve, /pause, /resume during the soak.

**Step 6: GO/NO-GO GATE.** Zero crashes in 48 hours. Successful kill + resume. All Telegram commands work.

**Step 7: Commit**

---

## PoC Step 4: End-to-End Test

**Goal:** Full production cycle with live APIs, posting to private YouTube.
**Go/No-Go:** Full cycle completes, posts successfully, comments harvested, /regenerate works mid-cycle.
**Duration:** 2-3 days (with OAuth buffer).

### Task 4.1: YouTube API Integration

**Files:**
- Create: `starsend/studio/harvester.py`
- Create: `starsend/studio/distributor.py`
- Create: `tests/studio/test_harvester.py`

**Step 1:** Implement YouTube comment harvesting (Data API v3):
- `harvest_youtube_comments(video_id)` → list of comments
- `count_votes(comments)` → regex first-pass + Haiku batch for ambiguous

**Step 2:** Implement YouTube Shorts posting (Data API v3):
- `post_youtube_short(video_path, title, description)` → video_id
- Include `#AI #MadeWithAI` in description (AI disclosure)

**Step 3: Commit**

### Task 4.2: Wire Live APIs into Orchestrator

**Files:**
- Modify: `starsend/studio/orchestrator.py` (add `--live` mode)

**Step 1:** Add `--live` flag that replaces mock API calls with real ones:
- HARVEST phase calls `harvester.py`
- WRITE phase calls Claude API (via existing engine)
- PRODUCE phase calls Starsend engine (NBP, Kling, Veo)
- ASSEMBLE phase calls `assembler.py`
- DISTRIBUTE phase calls `distributor.py`

**Step 2:** Add cost tracking integration (use existing engine's `CostTracker`).

**Step 3:** Add $15/cycle kill switch and 24-hour posting cooldown.

**Step 4: Commit**

### Task 4.3: Full Cycle Test

**Step 1:** Run one complete cycle with `--live`:
```bash
python3 starsend/studio/orchestrator.py --live --once
```
(`--once` = run one cycle then stop, don't loop)

**Step 2:** Verify:
- Episode generates correctly (5-7 shots)
- FFmpeg assembly produces watchable video
- Video posts to private YouTube channel
- AI disclosure watermark visible
- After 24 hours, comments are harvested and votes counted

**Step 3:** Test /regenerate: During the PRODUCE phase, send `/reshoot 3` via Telegram. Verify shot 3 is regenerated and the cycle continues.

**Step 4: GO/NO-GO GATE.** Full cycle completes. Video posts. Comments harvested. /regenerate works.

**Step 5: Commit**

---

## Post-PoC: Next Steps (Not Part of This Plan)

If all 4 go/no-go gates pass:
1. Add Instagram posting (once Meta App Review completes)
2. Add TikTok posting (Content Posting API)
3. Build the narrative engine adapter (`writer.py` with Thematic Anchor + Mystery Stack)
4. Develop the actual Starsend series (character, world, narrative through Recoil `/develop`)
5. Run the daemon in production on Mac Studio
6. First public episode
