Screenplay ↔ Scene Studio integration — design doc
Status: design / not built. Decision doc for product_tasks
efbc2b48. Recommends Option C+ (labeled breakdown store + picker) as phase 1, with the full re-key (Option B) reserved as a later phase only if truly parallel rendering is ever needed. Background: REVERSE_SCREENPLAY_ENGINE.md (the "To Scene Studio" hand-off) + SCENE_STUDIO_ARCHITECTURE.md.
The problem
The Reverse Screenplay Engine re-segments one chapter into N screenplay
scenes. Scene Studio is built around one breakdown per chapter —
scene_projects.episode_id is UNIQUE. So when you hand a screenplay scene to
Scene Studio, it has to overwrite that chapter's breakdown.
We already softened this (PR #783, migration 414): the "To Scene Studio"
hand-off now confirms before replacing and snapshots the outgoing shots into
scene_projects.breakdown_versions. But that's a guard, not a model — you still
can't keep, say, a hand-made breakdown and breakdowns for two different
screenplay scenes of the same chapter, all addressable.
Goal: let a chapter hold multiple coexisting, labeled breakdowns; make the screenplay hand-off additive instead of destructive.
What the data already supports (and what doesn't)
- ✅
scene_shotsFKs toscene_project_id(the PK), notepisode_id— so multiple shot sets per chapter work at the row level for free. - ✅
scene_projects.breakdown_versions JSONB(migration 414) already stores snapshot breakdowns. - ❌
scene_projects.episode_id UNIQUEhardwires one project per chapter. - ❌ ~60 touch points assume it: ~22 exported
scene-studio.tsfunctions keyed byepisodeId, ~13.eq('episode_id', …).maybeSingle()/upsert(…, { onConflict: 'episode_id' })query sites, ~24/:episodeIdroutes, and the frontend (?ep=<episodeId>,getSceneProject(episodeId)).
Option B — full re-key (the "correct" but heavy path)
Drop episode_id UNIQUE; make project_id the first-class key; add
scene_projects.screenplay_scene_id (nullable FK). Each screenplay scene spawns
its own full project — its own shots, keyframes, clips, animatic — all live
simultaneously.
Touches (~40–60): rename/re-signature ~22 service functions
(getSceneProject(episodeId) → (projectId), etc.), rewrite ~13 query sites,
change ~24 routes from /:episodeId to /:projectId (breaking URL change,
?ep= → ?project=), a schema migration with a data backfill (assign every
existing project a stable key + a screenplay_scene_id = null), and a frontend
project-list picker. ~2–3 weeks; high blast radius on a central, heavily-used
feature.
When it's worth it: only if you need to render multiple breakdowns of the same chapter in parallel (keep N sets of live keyframes/clips at once). For the actual workflow — you work one animatic at a time — this is over-built.
Option C+ — labeled breakdown store + picker (recommended)
Keep episode_id UNIQUE and the entire existing plumbing. Promote the
already-present breakdown_versions column into a labeled multi-breakdown
store, and add a picker in Scene Studio to switch the active breakdown.
The one in scene_shots is "active" (rendered); the rest sit in
breakdown_versions. Switching is lossless because we snapshot each shot's
rendered assets, not just its text.
Schema (1 trivial additive migration — 415)
ALTER TABLE public.scene_projects
ADD COLUMN IF NOT EXISTS active_breakdown JSONB; -- metadata of the live breakdown
active_breakdown = { id, label, source, screenplay_scene_id } for whatever is
currently in scene_shots (null on legacy projects → treated as an unlabeled
"Hand-made" breakdown). No constraint changes, no re-key, no breaking URL.
Each breakdown_versions[] entry becomes a full, restorable breakdown:
{
"id": "uuid", // app-generated, stable
"label": "INT. SERVER ROOM - NIGHT",// human label
"source": "manual" | "screenplay",
"screenplay_scene_id": "uuid|null",
"style_anchor": "…",
"characters": [ … ], // canonical cast at snapshot time
"shots": [ { // FULL shot state, incl. rendered assets
"idx", "shot_type", "visual_prompt", "speaker_name", "dialogue", "duration_s",
"keyframe_url", "clip_url", "audio_url", "lip_sync_url",
"keyframe_versions", "clip_versions", "place_key", "camera_motion",
"transition", "trim_start_s", "trim_end_s"
} ],
"created_at", "updated_at"
}
The key upgrade vs. migration 414: the snapshot must capture *keyframe_url / clip_url / audio_url / _versions per shot — so flipping breakdowns preserves rendered work. (Today's snapshot grabs only shot text.)
Backend (scene-studio.ts + routes)
New functions (all keyed by episodeId, so they fit the existing model):
listBreakdowns(episodeId)→[{ id, label, source, screenplay_scene_id, shot_count, active, created_at }](the active one fromactive_breakdown+ ascene_shotscount; the rest frombreakdown_versions).switchBreakdown(episodeId, breakdownId)→ snapshot the current active intobreakdown_versions, restore the chosen entry's full shots + style + characters intoscene_shots/scene_projects, setactive_breakdown, and remove the chosen entry from the store. Non-destructive both ways.deleteBreakdown(episodeId, breakdownId)→ drop an inactive breakdown.- Enhance the existing
generateSceneBreakdownsnapshot to capture full rendered shot state + write alabel/source/id.
New routes: GET /projects/:episodeId/breakdowns, POST /projects/:episodeId/breakdowns/:breakdownId/switch, DELETE /projects/:episodeId/breakdowns/:breakdownId.
The screenplay hand-off becomes purely additive
sendSceneToStudio stops being destructive: instead of confirm-before-replace,
it always adds a new labeled breakdown (label = the scene's slug line,
source='screenplay', screenplay_scene_id set) and makes it active, snapshotting
the prior active into the store. The confirm dialog + conflict signal from PR
#783 go away — replaced by "added breakdown N to chapter X." (Optionally surface
"→ in Scene Studio (N breakdowns)" back on the screenplay scene.)
Frontend (scene-studio/page.tsx)
A Breakdowns picker at the top of the project view: a list/dropdown of the
chapter's breakdowns with a source badge (✋ Hand-made / 🎬 Screenplay: "<slug>")
and the active one marked. Selecting one calls switch and reloads. Everything
downstream (keyframes, clips, assemble) is unchanged — it always operates on the
active breakdown.
Touches (~8–12): 1 trivial migration, ~3 new service functions + an enhanced
snapshot, 3 new routes, a picker component + handlers, and the screenplay hand-off simplification. ~1 focused session. No breaking change.
Recommendation
Ship Option C+ as phase 1. It delivers coexisting, labeled, losslessly-
switchable breakdowns per chapter and makes the screenplay hand-off additive —
the actual ask — at ~15% of B's cost and risk, with no breaking change and a
one-column migration. Keep Option B in reserve: if a real need for
simultaneous parallel rendering of multiple breakdowns ever appears, the C+
breakdown_versions entries are already the seed of the per-project rows B would
create, so C+ is a stepping stone, not a dead end.
Open questions
- Active-asset capture completeness: confirm every render path writes its
asset URLs onto
scene_shots(not elsewhere) so the snapshot is faithful; auditgenerateSceneKeyframes/generateShotClip/lipSyncShot/assembleSceneAnimaticwrite targets. - Animatic per breakdown:
scene_projects.video_url/animatic_versionsare project-level (one chapter). Should a switched breakdown also stash/restore its assembledvideo_url? (Likely yes — addvideo_url+music_urlto the snapshot.) - Label source for manual breakdowns: default to the chapter title / "Hand-made"; let the admin rename.
- Cap:
breakdown_versionsis capped at 10 in the service — fine for this, but make the cap explicit in the UI (oldest drops).