Script Editor — Concept
Status: Concept / brainstorm · Owner: matti
A script as a first-class resource: text plus the audio takes voiced from it, authored in an ElevenLabs-Studio-style editor, importable into a timeline sequence for video. This document scopes the idea against what already exists in the codebase.
The pitch
Today the only place to write and voice narration is inside a timeline: the
Studio transcript layer (TimelineTranscriptStore) projects a document view
over voiceover clips, and the clips are the source of truth. That is the right
model for post-production — word-accurate ripple cuts on media that already
exists — but wrong for pre-production. A writer wants to draft, restructure,
assign speakers, and audition voices before any timeline exists, and wants the
same script reusable across several videos (a 60s cut and a 15s teaser).
The Script editor inverts the transcript’s ownership: text is the source of truth, audio is derived. A script lives on its own, line by line, each line voiced into one or more takes (audio assets with word timings). “Send to timeline” assembles the current takes into a sequence — the same move the storyboard already makes for images and video.
What already exists (the substrate)
The concept is mostly assembly of parts that are in main:
| Piece | Where | What it gives the script editor |
|---|---|---|
| First-class-resource pattern | packages/models/src/schema/storyboards.ts, StoryboardStore, StoryboardSurface |
Table shape (id, user_id, project_id, name, document, timeline_id), tRPC CRUD, workspace tab, autosave |
| Assemble → timeline | web/src/components/storyboard/assembleTimeline.ts |
Resource → sequence creation with linkage keys on clips |
| Back-sync after regen | web/src/stores/storyboard/timelineSync.ts |
Re-voiced line updates the assembled timeline clip (CAS update, never throws) |
| Voicing pipeline | TimelineTranscriptStore.generateBeat |
text → generate_media (TTS) → probe duration → transcribe_audio (word timings) → reflow |
| Word-timing model | captionWord / clipCaption in packages/protocol/src/api-schemas/timeline.ts |
Clip-local word timings that survive re-flow |
| Take/version model | clipVersion (same file), ClipVersionHistory, ShotTakesGallery |
Versioned generations with param snapshots, favorites, cost |
| Document editor infra | web/src/components/timeline/transcript/ (Lexical: WordNode, SceneBreakNode, slash commands) |
The text-editing surface, word-level selection, per-word playback highlight |
| Providers | packages/runtime/src/providers/elevenlabs-provider.ts + every other TTS-capable provider |
Voice generation; ElevenLabs also returns native timestamps |
What does not exist: the script document schema, the scripts table, the
standalone editor surface, a cast/speaker→voice binding, and the
script↔timeline linkage keys.
Document model
Zod schema in packages/protocol/src/api-schemas/ next to the timeline and
storyboard schemas. Sketch:
Script
├─ id, name
├─ cast: Speaker[] { id, name, color,
│ voice: { provider, model, voice, settings? } }
└─ sections: Section[] { id, title? } // scene / chapter breaks
└─ lines: Line[]
├─ id, speakerId?, text
├─ direction? // freeform performance note ("whispering,
│ // tired"), passed through to providers
│ // that accept it — no structured tag schema
├─ pauseAfterMs? // authored silence between lines
├─ takes: Take[]
│ { id, assetId, durationMs, words: CaptionWord[],
│ textSnapshot, voiceSnapshot, createdAt, favorite?, costCredits? }
└─ currentTakeId?
Derived, not stored:
- Line status:
draft(no takes),voiced(current take’stextSnapshotandvoiceSnapshotmatch the line),stale(text or voice changed since the take — show a re-voice affordance, exactly like the timeline’sdependencyHashstaleness). - Script duration: sum of current-take durations plus pauses. Placeholder
duration for unvoiced lines (the transcript layer’s
PLACEHOLDER_BEAT_MSmove).
CaptionWord is reused as-is; timings stay take-local so re-ordering lines
never rewrites word timings — the same invariant the timeline already relies
on.
Takes are the clipVersion idea relocated onto the script: textSnapshot +
voiceSnapshot play the role of paramOverridesSnapshot, and the take gallery
is the shot-takes gallery with an audio player instead of a thumbnail.
Storage
scripts table mirroring storyboards byte for byte in shape: document JSON
column, timeline_id back-pointer once assembled, indexes on user/project/
updated. tRPC router with the same get/list/create/update(CAS)/delete surface,
autosave hook copied from the timeline’s.
Audio takes are ordinary assets — the script references them by assetId, so
asset lifecycle, storage backends, and .nodetool bundle export (graph +
referenced asset bytes) all work unchanged. The script itself is a DB resource,
not an asset, matching workflows/storyboards/sequences.
Editor surface
A new workspace tab type (WorkspaceTabsStore already routes storyboard /
timeline / sketch surfaces), laid out like ElevenLabs Studio:
- Document pane — the script as continuous prose, one paragraph per line, speaker chip in the gutter. Reuse the Lexical transcript editor: it already has word nodes, scene breaks, and slash commands. Enter splits a line, backspace at start merges, drag reorders.
- Cast panel — speakers with voice pickers (provider/model/voice) and a “preview voice” button. Assigning a speaker to a line inherits the voice; per-line overrides allowed.
- Line affordances — voice / re-voice button with per-line spinner
(
clipStatuspattern), take gallery popover, stale badge, per-line playback. - Play-through — sequential playback of current takes with the word
highlight the transcript editor already renders; unvoiced lines are skipped
or beep-placeholded. This is a plain
Audio-element chain, no compositor needed. - Voice all — batch-generate every draft/stale line, bounded concurrency, per-speaker voices respected.
Voicing pipeline
Identical to generateBeat, minus the timeline coupling:
- line text (+ direction) →
generate_mediaRPC (modeaudio, speaker’s provider/model/voice) → asset id - probe duration
- word timings onto the take: providers that declare a
ttsTimestampscapability return them natively with the synthesis (ElevenLabswith-timestampsendpoints — more accurate, one call fewer); everything else falls back to thetranscribe_audioRPC (best-effort, as today — a failed transcription still leaves a playable take) - append take, set
currentTakeId
Timeline import (“Send to timeline”)
The storyboard’s assemble move, for audio:
- Create (or update) a sequence: one voiceover clip per line on an audio track
— optionally one track per speaker — laid end to end with
pauseAfterMsgaps. Each clip carries the take’s asset, itsCaptionWord[]asclip.caption,speaker, and two linkage keys:scriptId+scriptLineId(the storyboard’sstoryboardBoardId/storyboardShotIdpattern). - Set
script.timeline_id. The assembled sequence opens in the timeline editor, where the existing transcript layer takes over for word-level post-production — the imported clips are indistinguishable from beats voiced in-timeline. - Re-voice after assembly: mirror
syncShotClipToTimeline— when a linked line gets a new current take, patch the matching clip’scurrentAssetId, duration, and caption via a CAS document update. Log-and-continue on failure, never block the take. - Structural drift: adding/removing/reordering lines after assembly is the hard case. V1 answer: sync only per-line asset swaps; structural changes prompt “re-assemble” (which re-lays the voiceover track but preserves other tracks). Anything cleverer (three-way merge against the timeline’s own edits) is explicitly out of scope until the simple thing proves limiting.
- Reverse import: “Extract as script” from a timeline — project the
transcript document (
buildTranscriptDoc) into a new script resource. Cheap to build because the projection already exists, and it closes the loop for recorded/imported media: transcribe a recording in the timeline, extract the script, re-voice it with a cast.
Relation to the in-timeline transcript
Keep both, with distinct jobs: the script is pre-production (authoring, casting, auditioning — text owns audio), the transcript is post-production (ripple cuts on real media — clips own words). The linkage keys are what stop this from becoming two competing sources of truth: a clip either belongs to a script line (script wins for text/voice, timeline wins for placement/trims) or it doesn’t (transcript behaves exactly as today). Unifying them into one model was considered and rejected for now — the transcript’s clips-as-truth invariant is load-bearing for ripple editing and undo, and inverting it underneath the existing editor is a rewrite, not a feature.
Automation
- Agent tools:
ui_script_*mirroring the timeline set —get_state,add_line,set_line_text,set_speaker,set_speaker_voice,voice_line,voice_all,send_to_timeline. Same bridge pattern (timelineAgentBridge), same assistant panel. This is what makes “paste a topic, get a voiced script” a chat interaction. - Graph nodes:
ScriptRefin the protocol type system, plus a small node family —LoadScript,VoiceScript(batch TTS over a cast),ScriptToTimeline. Enables headless pipelines: LLM writes script → voice → assemble → render. - Exports: SRT/VTT straight from take word timings; audio-only mixdown
(concatenate takes + pauses) as an asset; scripts included in
.nodetoolbundles with their take assets.
Open questions
- Dialogue-mode models. ElevenLabs v3 renders multi-speaker dialogue in one call with better prosody than line-by-line synthesis, but produces one asset spanning many lines — it breaks the line↔take 1:1. Likely modeled as a section-level “dialogue take” whose word timings are split back onto lines. Phase 4 at the earliest.
- Take retention. Takes are cheap to keep (assets already exist); surface
costCreditsper take like clip versions do, revisit pruning only if it hurts. - Collaboration. Same last-write-wins CAS as timeline and storyboard; nothing script-specific.
Phasing
- Author + voice (implemented) — protocol schema
(
api-schemas/scripts.ts),scriptstable + tRPC (scriptsrouter), store- autosave (
ScriptStore,useScriptServerSync), workspace surface (ScriptSurface,scripttab type), cast panel, per-line voicing (scriptVoicing, reusinggenerate_media/transcribe_audio), take gallery, play-through. The document pane is a plain per-line editor rather than the Lexical transcript editor — that reuse is deferred to a later pass.
- autosave (
- To timeline (implemented) — assemble the current takes into a
voiceover sequence with
scriptId/scriptLineIdlinkage keys (assembleScriptTimeline,useAssembleScriptTimeline, “Send to timeline”), per-line back-sync on re-voice / take switch (stores/script/timelineSync), re-assemble in place on structural drift (preserving foreign tracks), and extract-as-script from a timeline transcript (extractScript,useExtractScript, the transcript panel’s “Extract as script”). - Automation (implemented) —
ui_script_*agent tools (scriptAgentBridge,useScriptAgentBridge,builtin/script:get_state,add_speaker,set_speaker_voice,add_line,set_line_text,set_speaker,voice_line,voice_all,send_to_timeline) + an in-surface assistant panel (ScriptAgentPanel, a Cast/Assistant dock toggle),ScriptRefin the protocol type system with ascriptdata type and property editor, and a graph node family (ConstantScript,LoadScript,VoiceScript— batch TTS over a cast,ScriptToTimeline) reading/writing scripts through newProcessingContextscript methods. - Depth — SRT/VTT export (implemented): a shared, framework-agnostic
subtitle codec (
@nodetool-ai/timelineassembleSubtitleCues/cuesToSrt/cuesToVtt) that lays each line’s current take end to end with the authored pauses and renders line- or word-granularity cues from the take word timings, surfaced as a graph node (ScriptToSubtitles), an agent tool (ui_script_export_subtitles), and an “Export SRT” button on the script surface (exportScriptSubtitles). Still open: dialogue-mode rendering, provider-native timestamps, audio-only mixdown, and.nodetoolbundle support.
Phase 1 is deliberately shippable alone: a script you can write, cast, voice, and listen to is already the ElevenLabs-Studio use case, before any timeline integration.