# Open Edit

> Orchestrate a video run rendered by VEED's engine — stylized captions over footage, edits and reframes, layered motion graphics, or graphics with no footage at all. Takes any number of source videos, including none. Use when the user wants video made, edited, or captioned by an agent.

- Skill: `plyght/open-edit` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add plyght/open-edit`
- Raw SKILL.md: https://api.skillmd.com/api/skills/plyght/open-edit/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: plyght (https://skillmd.com/u/plyght)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/plyght/open-edit

---


# open-edit — video orchestrator

Renders video with `veed-engine-cli` (the veed render engine). Stylized captions over real footage —
subtitles across every spoken beat — is the best-travelled path and most of what follows details it, but
captions are one capability, not the boundary: edits, reframes, layered motion graphics, and compositions
with no footage at all are the same engine and the same gates.

The engine renders a `.wv` document, which **is an extension of CSS and can be treated as such**: an HTML
fragment plus a `<style>` block, standard CSS throughout, no JavaScript and no proprietary timeline —
`@keyframes` and `animation-delay` ARE the timeline. Your CSS knowledge transfers directly; only the
engine's unimplemented parts have to be learned (`pipeline/director-brief.md` § ENGINE LIMITS).

**INPUTS — any number of videos, INCLUDING NONE.** Footage is a layer inside that document — an optional
one. What the input count changes is **how much of the work arrives already
scripted, never whether the work is supported.**

- **One video** — recommended, and the best-travelled path. The transcript, the canvas (dims + fps) and
  the base frames are all derived from the file, which is what lets a compiled recipe run at zero tokens.
- **Several videos** — ONE batch, not one run each. `prep/transcribe.ts`, `veed/go.ts` and `prep/prep.ts`
  all take `<video.mp4> [...]` and write one `runs/<key>` per video, so the provider question, the sign-in
  and any install happen once; STYLE, DESIGN + RENDER and MUX then run per `runs/<key>`.
- **No video — FULLY SUPPORTED, not a degraded mode.** Motion graphics, stills, slides, generated
  imagery, audio-only sources. Author the `.wv` INLINE per `pipeline/director-brief.md` and run the SAME
  gates as every other run: `lint-template.ts` → `veed-engine-cli <dir> --verify` → `--record` (the DESIGN + RENDER step's
  RENDER + VERIFY block — none of it reads `meta.json`). Choose `<key>` from the ask, take the canvas and
  duration from the ask rather than from ffprobe, and drop only the steps that have no subject: the recipe
  draw (no footage to derive facets from), `probe-qa` (it diffs frames against source footage) and
  mux-audio (no audio track). `generate-recipe.ts` is the scripted convenience for 1+ videos, NOT the
  definition of a supported run — its absence costs you the shortcut, nothing else.

The captioned run is **fully scripted end to end**: recipes are COMPILED CODE (`refs/html/<id>/recipe.ts`),
so a recipe-backed pick generates, verifies, and renders with zero tokens. The only spawned agent left is
the OPT-IN vision-analysis pass (style-refine requests); the CREATIVE path face-1 (the user brought their
OWN reference/brand/concept — their materials are the design authority) is authored INLINE by the
orchestrator, and creative iteration on a delivered result is REMIXED inline (no subagent). There are **no per-shot intermediates and no
user-approval gate**. Read `docs/FLOW.md` for the map.

Default run (the FAST PATH) = PREFLIGHT → PREP → SAMPLE ONE STYLE → DESIGN + RENDER → MUX AUDIO (ANALYSE is SKIPPED). Vibe/genre/energy come from the
transcript; placement comes from the brief's safe margins; the style is SAMPLED by script; word reveal
timings are precomputed (`word-timings.json`). The runtime index is recipes-only, so a default run's DESIGN + RENDER step
is always `pipeline/scripts/generate-recipe.ts` — a SCRIPT, no model, no subagent: the recipe already did
the design thinking, offline, and the code does the assembly + the full gate chain (lint → verify → record
→ probe). Creative face-1 is authored INLINE by the orchestrator; the only spawned agent is the opt-in analyse pass.
REFINEMENT is declared by `analysis.json` existing (the ANALYSE step ran on user request) — placement then composes
from it instead of the safe margins.

## User-facing output — talk like a product, not a pipeline
The user asked for a video, not a pipeline tour. Internals are NEVER surfaced: run keys,
ref/style ids (`hook-…`), "recipe"/"recipe-backed", seeds, facets, energy scores, beat counts, frame
counts, gate names (lint / `--verify` / probe-qa), engine details. A fresh user has no idea what
any of that means. The CLASSIC POOL is equally internal: never say "classic", "preset", "route", or a
preset id (`simple`, `glass`, …) — "Classic route, 'simple' preset" is exactly the leak this section
bars. The user asked for clean captions; say you're on it, then deliver.
- **Never name the style — and never expose the mechanics of choosing it.** The ref id, its metadata,
  and the sampling machinery ("the sampled pick", "the draw", seeds, alternates) are all internal.
  Talk about "the style" as an abstract thing that exists for this video: "the style centres text
  mid-frame, so I'm switching to one that anchors low" — not "the sampled pick is…". Describe a
  delivered look only in plain visual terms (colour, size, placement).
- **No step-by-step progress.** Don't announce transcript/prep/sampling/verify/probe/mux as they
  happen. One line when starting, then the deliverable with the preview URL. Silence in
  between is fine.
- **"Render complete" = audio muxed.** Muxing is not a separate user-visible step; say the render is
  done only once `final/out.mp4` exists.
- **No recap.** The delivery message is the `out.mp4` path plus at most a sentence or two about the
  result (on creative runs, the look you committed). Never a "What happened" list of stages, gates,
  counts, or QA results.
- **Failures are the exception.** When a gate fails and you must stop, explain in plain terms what is
  wrong on screen and the options; quote raw FAIL lines only if the user asks.
- Questions you must ask (renderer update consent, coverage gaps, probe failures) also stay in plain
  language: what it means for their video, not exit codes.

## PREFLIGHT — ALWAYS run at session start
An installed skill contains this file plus `scripts/preflight.sh`; the full runtime may not exist yet.
Resolve **SKILL_ROOT** as the directory containing this `SKILL.md`. Then resolve **WORKSPACE** by the first
rule that applies:

1. **If SKILL_ROOT sits inside an Open Edit checkout, WORKSPACE is that checkout** — preflight reuses it,
   and the run exercises that code.
2. **Otherwise** WORKSPACE is the user's current project root, or the current directory outside a project —
   preflight creates its own runtime at `<WORKSPACE>/.open-edit/runtime` and every step below runs there.

Preflight names which of the two it resolved (`reusing the local checkout at …` or `will use a managed clone
at …`); read that line before trusting a run to be testing your changes. Resolve the supplied video to an
absolute path from WORKSPACE before changing working directories.

At the start of EVERY session, before doing Open Edit work, run:
```
bash "$SKILL_ROOT/scripts/preflight.sh" --dry --workspace "$WORKSPACE"
```
Then run bare preflight to perform all safe, first-time workspace-local setup automatically:
```
bash "$SKILL_ROOT/scripts/preflight.sh" --workspace "$WORKSPACE"
```
This installs the project-local SessionStart hooks, performs the first full runtime clone, installs pinned
repository dependencies, and installs the renderer when their prerequisites already exist. It is idempotent.

**Approval law — never weaken this:** machine-global dependencies and updates to existing code are never
applied by bare preflight. If `--dry` or bare preflight prints `APPROVAL REQUIRED`, communicate EVERY exact
action to the user and wait for an explicit affirmative response. Only when the user approves ALL reported
actions may you run:
```
bash "$SKILL_ROOT/scripts/preflight.sh" --auto-approve --workspace "$WORKSPACE"
```
`--auto-approve` means the user agreed to every currently proposed global install and clean update. Never infer
approval from the original render request. If the user approves only selected actions, perform only those exact
commands yourself, then rerun `--dry`. If nothing needs approval, do not mention preflight.

Exit **0** means stdout is **OPEN_EDIT_ROOT**; use it for every repo-relative command below. It does **not**
mean setup is finished — a `--dry` run exits 0 while listing the `WOULD APPLY LOCALLY` work that bare
preflight performs itself, and then ends on `not ready yet — run bare preflight …`. Read the final
`preflight:` line, not the exit code: `ready — OPEN_EDIT_ROOT=…` means go. Exit **10** means **only** that
`APPROVAL REQUIRED` was printed and the user must approve every listed action first. Exit **1** is a hard
invariant/install error.
For development, `--repository <URL-or-local-path> --ref <branch>` overrides the initial clone source. A managed
clone records its origin, branch, and commit and rejects conflicting later overrides. A clean checkout is offered
a fast-forward update; any local or untracked changes are reported and left untouched.

Immediately after resolving OPEN_EDIT_ROOT, read `$OPEN_EDIT_ROOT/AGENTS.md` completely and follow it before
running any repository command. Do this explicitly on every agent; never rely on Claude, Codex, Gemini, or another
client discovering instructions inside the newly cloned runtime automatically.

## The flow

Written for the footage case. PREFLIGHT, FOOTAGE, DESIGN + RENDER and PREVIEW hold for every run; **PREP**
(transcript, frames, meta), **SAMPLE ONE STYLE** (the style draw) and **MUX AUDIO** derive from a source
file, so a run with no video simply has no subject for them — see INPUTS: authoring, lint, `--verify` and
`--record` are unchanged.

### PREFLIGHT — completed above  · SCRIPT
Do not run a second dependency implementation. `pipeline/scripts/preflight.sh` is only a compatibility wrapper
around the skill-bundled preflight. The provider choice — and any sign-in or install it implies —
remains the interactive PREP step.

### FOOTAGE — a video to work from, generate one, or none  · SCRIPT (only when the user brought none; runs before PREP)
This step is about VIDEO only — stills, screenshots, slides, images and audio are inputs too, and a run can
have them with no video at all. If the user supplied a video, continue to the PREP step unchanged. Otherwise do
NOT assume a video is needed — read the ask first:
- **They have a clip, or will record one** → take the path, waiting for the filename if it is still coming,
  then continue to the PREP step unchanged.
- **VEED Fabric** (recommend this when they want a talking head) → a talking-head clip from a script, billed to one of their VEED
  workspaces. **Fabric REUSES VEED transcription's authentication** — the same veed.io account, the same
  OAuth login, the same stored token. There is no Fabric connector and no second sign-in: if they are
  already signed in for VEED transcription, they are signed in for this. Continue below.
- **Another model** (Veo, Kling, Luma, anything on fal) → their auth and their bill, not ours; take the
  finished file into the PREP step. Say this in the SAME BREATH as that option, every time: captions come from
  TRANSCRIBING the clip's audio, so the clip must contain SPEECH. Veo 3 does. Veo 2, Kling, Luma and most of
  fal's catalogue are SILENT, and a silent clip yields an empty transcript and no captions. This is a
  warning, not a decision — say it, then let them proceed.
- **No video — work with other sources** → raster graphics (stills, screenshots, photos), vector graphics
  (logos, shapes, SVG), motion graphics (titles, kinetic type, animation), or generated imagery, in any
  combination. Build the piece from those: go straight to the DESIGN + RENDER step, which reads no footage. If
  they have AUDIO it can still be transcribed for captions; PREP, the style draw and MUX are skipped for want
  of a video subject.

Only when the ask is FOR a video of something but none is attached is there a real question — and even then
"no video" sometimes just means they forgot to attach the file, so if it is ambiguous, ask which of these it
is rather than guessing; a no-video answer is as good as any clip.

On the Fabric path exactly three things stop and ask: this footage question, WHOSE credits, and the credit
approval. Everything else — logging in, generating, reporting the charge — is a step: do it, say what
happened, keep moving.

**LOG IN BEFORE THE FIRST FABRIC COMMAND.** Every command below needs the VEED token — the SAME token VEED
transcription uses, not a second one — so establish the login here rather than discovering it is missing
mid-flow. If a command reports "No VEED login found", run the
browser flow YOURSELF exactly as the PREP step's LOGIN block below describes — you launch it, the user never runs a
command and never pastes a token. It is skipped when a token is already stored; one login covers generation
AND transcription and lasts about a month.

Draft the script yourself from their prompt and show it for edit. This is **two commands, and the script is
typed only in the first one.**

**WHOSE credits.** Generation spends the AI Playground credits of ONE
workspace. With exactly one on the account there is nothing to decide, so it is used and NAMED with what it
holds; with several and no prior answer the CLI stops and asks, and never picks. Run the confirm
command with NO workspace flag first:
`node --import tsx veed/generate.ts --script "<the script>" --key <key>`
With no workspace chosen it stops having spent nothing (exit 1) and prints every workspace with its name and
credit balance. Put that choice to the user in plain terms (the names and what each has left, not ids if you
can avoid them), then re-run naming the one they picked — that re-run is the PREP step below. That choice is
remembered at `veed/.veed-workspace.json`, but a remembered choice is never a settled one: a spend pass whose
workspace was only remembered REFUSES until the command names it again. Put the remembered workspace and its
balance to the user, get a yes, and carry `--workspace <id>` on the spend command — the same flag switches it
whenever they want a different one.

**WHAT IT COSTS.** Generating draws AI Playground credits TWICE: the speech is synthesized first, then
handed to **Fabric One Lipsync** (`veed/fabric-one-lipsync`), and both debits land on the same credit
allowance.
- **Fabric One Lipsync** — ~4 credits per SECOND of finished video, measured.
- **Speech synthesis** — 2 credits per minute of generated audio, rounded up to the whole minute, so any
  read up to a minute costs 2.

The quoted figure is the SUM of both. The script LENGTH is the lever, because it decides how long the
read is — but how long is a property of the VOICE, and measured voices run from about 11 to 18 characters
a second. So a 900-character script is a minute of video in one voice and a minute and a half in another,
which is the difference between roughly 200 and 320 credits. The tool quotes at the rate it has measured
for that voice, and quotes a RANGE when it has never heard it; repeat the range rather than flattening it
to its low end, and never anchor the user on a small number. Too expensive → redraft a shorter script, or
any of the other answers to the footage question; never a different workspace. The figure quoted before
the spend is OUR estimate; VEED quotes no per-job price.

**THE PRESENTER CAN BE THEIRS.** The 24 presets are a menu, not the boundary — the model takes an image,
and it does not care where it came from. `--image <url|path>` uses the user's own still INSTEAD of a
preset: a URL is fetched by VEED, a local file is uploaded from here. Reach for it whenever they brought
a face, a logo, a character sheet or a frame they like. A preset carries a default voice and a user image
does not, so `--voice` is required with `--image`.

**A SET of images is ONE approval.** Several stills is one video made of several shots, so it is one
question, not N. Write a shots file — `[{ id, script, image | character, voice }, …]` — and confirm the
whole set at once:
`node --import tsx veed/generate-set.ts --shots shots.json --key <key> --workspace <id>`
It prints every shot with its own share of the cost and ONE total, then spends the lot on a single
`--yes`. The approval is hashed over the whole set: edit a line, reorder two shots, swap an image or a
voice, and it refuses rather than buying something nobody saw. Each shot still runs under its own key, so
a failure halfway leaves the shots already paid for alone and `--resume` collects them. Then join:
`node --import tsx pipeline/scripts/concat-videos.ts <out.mp4> <clip1.mp4> <clip2.mp4> [...]`
It fits each clip into one canvas and pads the rest rather than cropping, because stills of different
shapes produce clips of different sizes and nothing should lose its framing to a join. The result is an
ordinary source file: transcribe it, caption it, render it like any other footage.

**That joiner is for SOURCE clips that disagree, and only those.** It re-encodes and normalises the
frame rate, which is right for generated clips of different shapes and wrong for anything else. The
finished chapters of a long piece are joined by `pipeline/scripts/concat-chapters.ts`, which
stream-copies and refuses parts whose format differs rather than transcoding a whole film — see the
DESIGN + RENDER step. Reaching for the wrong one costs a re-encode and silently resamples a 24 or 25
fps film to 30.

**WHO presents it.** If the user has no opinion about the presenter, do not paste 24 thumbnails at them:
`node --import tsx veed/sample-presenter.ts --key <key> [--gender male|female] [--locale <locale>] [--portrait|--landscape]`
PROPOSES one character + voice, prints two or three alternates with thumbnail and audio-preview links, and
ends with the ready-to-run confirm command carrying that pair. `--portrait`/`--landscape` is how FRAMING gets
chosen (the character IS the framing — there is no aspect parameter), so pass the one the user's format needs.
It proposes, it never decides — it costs 0 credits, writes nothing, and the user overrules it with `--seed N`
or by editing the two ids. Show them the pick and the alternates and get a yes before you run the confirm
command.

1. CONFIRM (spends NOTHING):
   `node --import tsx veed/generate.ts --script "<the script>" --key <key> --workspace <id>`
   It prints the script, the character, voice, framing ("portrait 9:16"), the workspace being billed with its
   balance, and the exact credit cost, records that approval at `runs/<key>/.fabric-pending.json`, and prints
   the exact next command. Show the user the cost in plain terms and get an explicit yes.
   NOT ENOUGH CREDITS is checked HERE too, before anything is written: if the workspace's balance is below the
   quote this step refuses, names both figures, and records no approval — so it never hands you a "run exactly"
   command for the ANALYSE step that is guaranteed to fail.
2. SPEND (only after that yes) — copy the command it printed, **with no `--script`**:
   `node --import tsx veed/generate.ts --key <key> --yes`
   It re-confirms against the server and REFUSES to spend if the fresh quote is above the cost that was
   approved, if the recorded script no longer matches its hash, if the approval is over an hour old, or if a
   `--workspace` here disagrees with the one that was approved. In any of those cases nothing is charged:
   re-run the PREP step and get a fresh yes for the new figure.
   That yes binds the SCRIPT, the FIGURE (character, voice, framing, quoted cost) and the WORKSPACE together
   for one hour; if any of the three drifts the run refuses rather than charging something the user never saw.
   A quote that came in LOWER proceeds — only a rise refuses.
   NOT ENOUGH CREDITS is checked again HERE, against the balance as it stands right now (it can have moved
   since the PREP step) — the balance is what actually guards the money, and it refuses, names both figures, and
   charges nothing. That goes back to the workspace question — top the workspace up, shorten the script, or
   re-confirm against a workspace the user explicitly names. NEVER move the run to a richer workspace on their
   behalf; a balance that simply cannot be read is not a refusal and proceeds, on either step.

Passing `--script` together with `--yes` is an ERROR — re-typing the script is how the billed words drift
away from the priced ones, so pass 2 reads them off disk instead. A spent approval is deleted: one yes buys
one video.

**Say what it cost — and how much to trust the figure.** The number the run stands behind is OUR ESTIMATE
from the script's length — VEED quotes no per-job price and reports no per-job charge, so there is nothing
to confirm it against. That figure, and which workspace it came out of, go to the user in plain terms once
the video lands ("about 380 credits from <workspace>"), and never as a figure VEED confirmed. The run also
reads the workspace balance either side of the create call and offers the movement as CORROBORATION —
that balance is workspace-wide, so it moves for anything else billing the same workspace and can never be
stated as "this run cost N". Pass it on the same way the run prints it:
- The movement AGREES with the quote → give both, the quote as the figure and the movement as the check.
- The movement is BIGGER than the quote → say so, and say the observed number. A concurrent run billing that
  workspace is the likely cause; our estimate simply running low is the other, and neither can be ruled
  out. Tell the user to check that workspace — never quietly report the quote as if nothing had happened.
- The balance could not be read credibly → the run prints the ESTIMATE and labels it one; pass that
  on as an estimate, never as the charge.
A `--resume` reports on the same terms, and never re-decides a figure the spend pass already measured — a
balance read an hour later says nothing about a charge that landed then. Every attempt leaves its own audit
trail at `runs/<key>/.fabric-spend-<sessionId>.json`, so re-running a key never erases the earlier run's.
Never let a run that spent credits end silently about cost.

→ `runs/<key>/<key>.mp4`. Feed that path into the PREP step exactly like user-supplied footage. `<key>` names a
directory under `runs/`, so it must match letters, digits, `.`, `-`, `_` only, and may not be `.`, `..`,
or start with `-` (see `assertSafeKey` in `veed/generate.ts`).

**AFTER THE MONEY IS GONE.** The charge lands the moment the job is created, so nothing past that point is
ever retried automatically. Every attempt records itself at `runs/<key>/.fabric-charge-<sessionId>.json`
BEFORE it calls VEED, so an attempt that never came back is still visible. Three outcomes, and they are NOT
the same:
- **Generation FAILED** (VEED reports the job failed) — report plainly what VEED said. Do NOT re-run `--yes`
  to "retry": a retry is a SECOND charge for the same script. A fresh attempt needs a fresh confirm pass and
  a fresh explicit yes from the user; the dead job blocks nothing.
- **The run was interrupted** (transport blip, polling died, download stalled, closed laptop) — the video is
  already PAID FOR and nothing needs approving. Collect it with
  `node --import tsx veed/generate.ts --key <key> --resume`, which polls, downloads and spends NOTHING.
  Polling gives up after 15 minutes, or after a run of consecutive status-check failures — the job may still
  be finishing server-side, so always `--resume` before ever paying again.
- **The attempt vanished mid-charge** (`--yes` refuses saying a charge MAY have landed) — no job id was ever
  recorded, so nothing can collect it. Tell the user plainly that VEED may already have charged, and have
  them check that workspace's balance and videos around the time the refusal named. To free the key, run
  `node --import tsx veed/generate.ts --key <key> --abandon <sessionId>` with the id from the refusal; it
  clears that one record and nothing else, and any credits that attempt spent are gone.
`--yes` REFUSES while another run of the same key is charging, while a paid job is uncollected (it points at
`--resume`), and while an abandoned attempt is unresolved. Runs of DIFFERENT keys never block each other, and
running them at the same time is fine.

Defaults produce a 9:16 talking head. `--character` picks the presenter (this is ALSO how framing is
chosen — there is no aspect parameter) and `--voice` the accent; browse with the Fabric tools only if the
user asks. Generation takes several MINUTES for a short clip — tell them it is running, then go quiet.
The credit approval is the only gate here that SPENDS — never pass `--yes` without the user's explicit
approval — but the footage question and the workspace question are stop-and-ask too: three gates, and nothing
else in this step stops.

**On the "another model" path the bill and the craft are both yours.** Everything below applies to a
clip this repo did not commission — a generator on the user's own key, or footage the user brought.

**READ THE MODEL'S OWN DOCUMENTATION BEFORE THE FIRST CALL. Do not infer it from this file.** The
endpoint ids in `FAL_MODELS` are DEFAULTS, not a catalogue: `--model` reaches any endpoint on the
queue, and there are more of them than the defaults name — text-to-video as well as image-to-video,
reference-driven variants, background removal, upscales. What the model accepts, what it returns,
what its ceilings are on duration and resolution, and what it costs are stated on its own page and
nowhere in this repository. Guessing those costs a generation you pay for and throw away, and it is
how a run ends up building a whole step it did not need.

**Captions need words with times on them, and where those come from depends on the generator.** Some
video models return synced speech in the clip; some return picture only. CHECK THE MODEL rather than
assuming either — its own endpoint documentation says which, and a clip already on disk answers it in
one `ffprobe`. If the clip carries speech, transcribe it like any other footage. If it does not, the
words come from somewhere else: generate the voice track and map its times through
`prep/whisper.ts <json> <media>`, or author the caption windows directly from the script. Only the
second case is worth warning the user about, and only once you have established it is the case.

**A TAKE'S OWN AUDIO MUST NOT OWN THE CUT.** Laying a generated take's dialogue down as the soundtrack
pins the picture to that take's timecode: every pause it contains is now in the film, and no shot can
be shortened, reordered or dropped without breaking sync. One run made that choice in a single line and
then could not cut — 21 of its 24 "shots" were contiguous slices of one take, and its pace came out a
third slower than the reference it was copying. If the cut matters, carry the speech separately: keep
the take for its picture, generate or re-voice the line, and let the edit be free of it. Measure what
you kept — speech seconds against running time — before deciding the pauses are the performance.

**AGREE THE SUBJECT BEFORE YOU PAY FOR IT.** The first generation is a charge, and everything after it
is built on whatever concept that charge embodied. When the ask leaves the subject open, say what you
intend to make in one line and let the user answer before the first call, not after seventeen. That is
ONE question about spend — not a loop. A run that asked seven times in half an hour was not being
careful; it was handing back the work. Write the best thing you can, say what it costs, and go.

**A SOUNDTRACK IS NOT A STATISTIC.** Measuring a reference tells you what it does, not what to make. A
run measured its reference honestly — a bed 19 dB under the speech, a riser before a cut and an impact
after it on 51 of 62 cuts — then generated ONE riser and ONE impact and fired the same two samples at
every cut, sixteen events out of two files. The statistic was reproduced exactly and the result was
unlistenable, because a sample the ear hears eight times in thirty seconds stops being an accent and
becomes a tic. The delivered film that did work went the other way: six music cues, each with its own
mood written for its own passage, and eleven distinct effects; the short piece before it carried four
events in thirty seconds from three samples. So: **a sample used more than twice is a defect**, an
effect on every cut is a defect, and every generated cue gets a prompt written for ITS moment rather
than one generic description reused. And listen to what came back before you build on it — no gate in
this repo has ears, which makes the audition yours and not optional.

**ONE SCRIPT, RESEARCHED, AND THE INTERRUPTIONS ARE NOT PRINTED.** When the ask needs words, go and
find out what is actually being argued about in that field right now — the repository tells you what
the product is, not what makes a subject live. A script written only from a README comes out
plausible and inert. Then commit to one and write it well; offering versions is not collaboration
here, it is asking the user to do the writing.

And mind the punctuation, because the script is display text: a run marked its interruptions with an
em dash, the dash went into the generation prompt and then into the caption, and the delivered film
reads `for a living—` on screen. Where a line breaks off, break it off — the cut and the next speaker
carry the interruption. Nothing that exists to instruct the reader of the script belongs in the words
the viewer sees.

**Every generated asset lands in the manifest with its provenance**
(`pipeline/providers/assets.ts`): what made it, from what prompt, derived from what, and what it cost.
Report the spend unprompted when the run delivers, with `spendLine` — it says plainly when a figure is
a lower bound and when the RESPONSE carried no price. That is a statement about the inference response
and about this client, which does not ask for a price; it is not a statement that the endpoint has no
published price. If the user wants a real figure, its pricing is on the model's page — go and read it
rather than telling them the number cannot be had.

### PREP — transcript, then frames + meta  · SCRIPT
The transcript comes from the provider the user chose, and either way lands at
`runs/<key>/transcript.json` (**each chunk = one beat**; chunks carry REAL per-word timings in
`words: [{text, timestamp:[start,end]}]`). Nothing downstream cares which provider ran. **`<key>` is the
video's filename without its extension, whitespace replaced by `_`** — every step below takes the same
`runs/<key>`, and each entry point prints the path it wrote.

For a batch (see INPUTS above), pass every video to ONE call: a failure stops the batch with the finished
transcripts left in place.

PROVIDER CHOICE — this whole question exists to caption speech, so **when nothing has to be transcribed
(no footage, silent source, a graphics-only ask) do not ask it at all** and do not record anything.
Otherwise read `$OPEN_EDIT_ROOT/.open-edit-prefs.json` first (**the runtime root preflight
printed, not the user's project root** — under a managed clone those differ, and looking in the wrong
one re-asks on every run). **If it records a provider, use it and ask
nothing.** Only on a cold start (no file, or nothing usable in it) ask ONCE, offering exactly these four.
**There is no default: picking for the user is the failure mode this question exists to prevent.**

Not choosing for them is not the same as having no opinion, and collapsing the two is its own defect.
**The order below is a statement about quality — VEED transcribes best, and it is first and named as
best for that reason.** Keep the order and the wording when you put the question, whichever way you put
it; two runs read the no-default rule as a ban on saying so, flattened the four into equals, and then
led with the free local one because free and local is what reads as sensible in the absence of a view.
Say which is best, then let them choose.

> Before I can add captions I need a transcript. Four ways to get one:
>
> 1. **VEED** — best quality. One-time browser sign-in. A free account covers about 10 minutes of
>    transcription a month; beyond that it needs a plan (https://www.veed.io/pricing).
> 2. **WhisperX, better quality** — free, runs locally, nothing leaves your machine. Slower, and the
>    first run installs it plus a model — around 2 GB of disk.
> 3. **WhisperX, fastest** — same, but quicker; weaker on names and jargon, which captions show off.
> 4. **Your own transcription service** — point me at it and I'll wire that up instead.
>
> I'll remember your pick.

Record the answer with the command — never hand-author that JSON, and **always include the tier** for
WhisperX so a later run cannot drift onto a different model:

| They chose | Record it as |
| --- | --- |
| 1 · VEED | `node --import tsx prep/transcribe.ts --record veed` |
| 2 · WhisperX, better | `node --import tsx prep/transcribe.ts --record whisperx --model medium` |
| 3 · WhisperX, fastest | `node --import tsx prep/transcribe.ts --record whisperx --model small.en` |
| 4 · their own service | `node --import tsx prep/transcribe.ts --record custom` |

If they answer "WhisperX" without choosing a tier, take **fastest** (`small.en`), record it, and say which
one you took — they can switch later. Never record `whisperx` with no tier.

- **veed** → `node --import tsx veed/go.ts <video> [...]`, login flow below. When the browser opens, say
  exactly: "I've opened a VEED login tab in your browser — click Allow if it asks. I'll wait here;
  there's nothing to paste."
- **whisperx** → `node --import tsx prep/transcribe.ts <video> [...]` — the recorded tier applies; pass
  `--model medium|small.en` only to override it. If the binary is missing, ASK before installing: "WhisperX isn't installed. It's a local
  Python tool — the install pulls in PyTorch and the first run downloads a model, so expect a slow first
  pass and around 2 GB of disk. It goes in its own isolated environment, not your system Python and not
  this project, and `uv tool uninstall whisperx` removes it again. Install it now?" On yes run
  `bash pipeline/scripts/install-whisperx.sh` and stream its output.
- **custom** → the user's service is yours to drive: get a Whisper-family JSON out of it (their MCP,
  their CLI, their API — their credentials, never handled here), then
  `node --import tsx prep/whisper.ts <json> <video>` — one json per video, repeated in pairs for a
  batch. `prep/whisper.ts` IS the shipped mapper; what we ship no helper for is DRIVING the user's
  service, which is yours to do with their tool.

OFFERING THE ALTERNATIVE — once, and in these words, so the user hears the actual trade rather than a
second nag:

- VEED sign-in declined → "No problem, I'll leave VEED alone. I can run WhisperX locally instead: free,
  offline, nothing leaves your machine. It needs a one-off install that pulls in PyTorch, so the first
  pass is slow. Want that?"
- WhisperX install declined → "Then I'll skip the local route. VEED transcription needs a one-time
  browser sign-in and runs on your VEED account's limits. Shall I open that instead?"
- Both hosted routes declined → "Then I won't transcribe — every caption is built from a transcript and
  I won't invent one. Two routes are still open: point me at your own transcription service and I'll
  wire it up, or give me the caption copy and I'll place it by hand rather than synced to speech.
  Otherwise, say the word if you change your mind about VEED or WhisperX." Stop only if they decline
  those two as well.
- No audio track → "That clip has no audio track, so there's no speech to caption. I can still put text
  on it — titles, lower thirds, motion graphics — from copy you give me. Want that?" Transcription is
  the step with no subject here, not the run: with no transcript there is no recipe to route to, so
  author DESIGN + RENDER INLINE per `director-brief.md` exactly as the NO VIDEO case does, with the
  footage as the base layer and timings chosen by you rather than synced to speech. Only an ask for
  speech captions specifically has nothing left to do.

WHEN A RUN FAILS — classify it, because the right move differs and none of them is a silent retry:

- **Out of credits** (`veed/go.ts` says "out of transcription credits") → the account is the blocker, not
  the choice, so go back to the Q1 question with VEED still on the table: "VEED is out of transcription
  credits for this workspace — a free account covers about 10 minutes a month. You can add a plan at
  https://www.veed.io/pricing and I'll retry, or I can run WhisperX locally instead: free, offline, and
  it installs on first use. Which would you like?" Do not rewrite the recorded provider until something
  succeeds.
- **Login failed or expired** → run the login flow once more. If it fails again, treat it as declined and
  offer the alternative in the words above.
- **Anything else** (upload failure, poll timeout, network) → retry the command ONCE, then offer the
  alternative. A blip must not cost the user their provider choice.

Report the provider in ONE line once the transcript lands — "Transcribed with WhisperX (medium),
locally." or "Transcribed with VEED." — and relay any warning the run printed, e.g. "12 of 340 words came
back without timings, so those reveals are approximate; the text is complete." That single line is
allowed; step-by-step progress is not.

Re-ask only when the recorded provider is gone (token revoked, WhisperX uninstalled), when the user asks
to switch, or when a run failed and the alternative has not been offered yet; "switch transcription
provider" means rewrite that file.

LOGIN (if `go.ts` says "No VEED login found"): OAuth needs the user to authenticate in a browser once,
but you (the agent) launch it — do NOT just tell the user to run a command. Preferred flow (refreshable
token, ~30-day):
- Run `node --import tsx veed/login.ts` in the background — it starts a local catcher, prints an
  authorize URL, and OPENS THAT URL IN THE USER'S BROWSER ITSELF (`execFile('open', …)`). You open
  nothing: watch its output for `Logged in.` and meanwhile tell the user a VEED login tab has opened
  and to click "Allow" if a consent screen appears (usually it auto-approves — they're likely already
  signed into veed.io). The browser redirects to `http://127.0.0.1:8977/callback`, the running
  login.ts catches it, and stores the token. No pasting needed. If this box has no browser `open` can
  reach (headless/SSH), use `VEED_LOGIN_MANUAL=1 …` instead and paste the redirected URL back to it.
- If the OAuth flow misbehaves, re-run it. NEVER read the user's browser cookies or local storage
  to obtain a token, and never ask them to paste one out of DevTools.

Then the rest of prep (needs the transcript above for the beat times, whichever provider wrote it):
`node --import tsx prep/prep.ts <video> [...]`
Auto-detects aspect from the source and writes, under `runs/<key>/`:
- `meta.json` — the single source of truth downstream: canvas `width/height/fps`, `durationSec`, and all paths
  (`videoPath`, `transcriptPath`, `wordTimingsPath`, `framesDir`). Canvas = the source's own dims
  (rotation-corrected) and fps, probed by prep; `aspect` = portrait (9:16) or landscape (16:9) label.
- `word-timings.json` — per beat: `cueDelayMs`/`cueDurMs` + every word's absolute-ms `delayMs`, synthesized
  from the VEED chunks' real per-word times (even split only if a transcript has none). The DESIGN + RENDER step pastes
  these VERBATIM — compiled recipes by construction, the inline creative passes per the director brief;
  timing is never re-derived.
- `frames/beat-N.png` — one clean still per beat at the chunk MID time, emitted at HALF canvas (×2 → canvas).

### ANALYSE — frames → analysis.json  · AGENT (vision) — OPT-IN

**A clip with no speech has no beats, and still has a composition.** `prep` samples one still per
transcript chunk, which is the right unit for a captioned run and no unit at all for a silent c

…(truncated)
