diff --git a/.claude/skills/open-edit/SKILL.md b/.claude/skills/open-edit/SKILL.md index 4876dbc..9618b0c 100644 --- a/.claude/skills/open-edit/SKILL.md +++ b/.claude/skills/open-edit/SKILL.md @@ -126,7 +126,7 @@ client discovering instructions inside the newly cloned runtime automatically. ## The flow -Written for the footage case. PREFLIGHT, DESIGN + RENDER and PREVIEW hold for every run; **PREP** +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. @@ -136,6 +136,179 @@ Do not run a second dependency implementation. `pipeline/scripts/preflight.sh` i 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 "" --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 ` 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 ` 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 --workspace ` +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 [...]` +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. + +**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 [--gender male|female] [--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 "" --key --workspace ` + 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//.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 --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 "), 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//.fabric-spend-.json`, so re-running a key never erases the earlier run's. +Never let a run that spent credits end silently about cost. + +→ `runs//.mp4`. Feed that path into the PREP step exactly like user-supplied footage. `` 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//.fabric-charge-.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 --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 --abandon ` 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. + ### PREP — transcript, then frames + meta · SCRIPT The transcript comes from the provider the user chose, and either way lands at `runs//transcript.json` (**each chunk = one beat**; chunks carry REAL per-word timings in @@ -383,7 +556,7 @@ index id; anything outside the index is not a runtime pick and the script reject "another style", "make 5 different versions" (that's N draws — run them as N `--seed`s through DESIGN + RENDER variant A, parallel when N>1). Re-roll serves the user who never had an idea in the first place; the moment the ask carries ANY creative direction it is a REMIX, never a re-roll. - - Defect repairs (typo, overlap, out-of-sync word) are neither — fix at the SOURCE, then re-run the + - Defect repairs (typo, overlap, out-of-sync word) are neither — fix at the source, then re-run the gates. Creative-run output (face-1/REMIX — agent-authored) → patch the run's template directly. Recipe-run output → NEVER hand-edit the generated .wv document (generate-recipe.ts owns it): a text/timing defect = fix transcript/word-timings and re-run the script; placement-vs-footage = the refine path diff --git a/.claude/skills/open-edit/scripts/preflight.sh b/.claude/skills/open-edit/scripts/preflight.sh index d40db64..80ad6e1 100755 --- a/.claude/skills/open-edit/scripts/preflight.sh +++ b/.claude/skills/open-edit/scripts/preflight.sh @@ -155,12 +155,6 @@ else REPOSITORY="${REPOSITORY_ARG:-$DEFAULT_REPOSITORY}"; REF="${REF_ARG:-$DEFAULT_REF}" fi -brew_has() { have brew && brew list "$1" >/dev/null 2>&1; } -jpeg_ready() { - brew_has jpeg-turbo || [ -f /opt/homebrew/opt/jpeg-turbo/lib/libjpeg.8.dylib ] || - [ -f /usr/local/opt/jpeg-turbo/lib/libjpeg.8.dylib ] -} - install_brew_formula() { local formula="$1" have brew || { say "Homebrew is required to install $formula; install Homebrew first"; FAILED=1; return; } @@ -168,25 +162,22 @@ install_brew_formula() { } handle_global_dependencies() { - local missing_git=0 missing_node=0 missing_pnpm=0 missing_ffmpeg=0 missing_jpeg=0 + local missing_git=0 missing_node=0 missing_pnpm=0 missing_ffmpeg=0 have git || missing_git=1 have node || missing_node=1 pnpm_ok || missing_pnpm=1 { have "${VEED_ENGINE_FFMPEG:-ffmpeg}" && have "${VEED_ENGINE_FFPROBE:-ffprobe}"; } || missing_ffmpeg=1 - jpeg_ready || missing_jpeg=1 if [ "$missing_git" -eq 1 ]; then need_approval "install Git globally: brew install git"; fi if [ "$missing_node" -eq 1 ]; then need_approval "install Node globally: brew install node"; fi if [ "$missing_pnpm" -eq 1 ]; then need_approval "install pnpm ${MIN_PNPM} or newer globally: npm install --global pnpm@${MIN_PNPM}"; fi if [ "$missing_ffmpeg" -eq 1 ]; then need_approval "install FFmpeg globally: brew install ffmpeg"; fi - if [ "$missing_jpeg" -eq 1 ]; then need_approval "install jpeg-turbo globally: brew install jpeg-turbo"; fi [ "$MODE" = "auto" ] || return NEEDS_APPROVAL=0 [ "$missing_git" -eq 0 ] || install_brew_formula git [ "$missing_node" -eq 0 ] || install_brew_formula node [ "$missing_ffmpeg" -eq 0 ] || install_brew_formula ffmpeg - [ "$missing_jpeg" -eq 0 ] || install_brew_formula jpeg-turbo if [ "$missing_pnpm" -eq 1 ]; then have npm || { say "npm is unavailable after installing Node"; FAILED=1; return; } npm install --global "pnpm@${MIN_PNPM}" || FAILED=1 @@ -195,7 +186,6 @@ handle_global_dependencies() { have node || FAILED=1 pnpm_ok || FAILED=1 { have "${VEED_ENGINE_FFMPEG:-ffmpeg}" && have "${VEED_ENGINE_FFPROBE:-ffprobe}"; } || FAILED=1 - jpeg_ready || FAILED=1 } handle_global_dependencies @@ -276,7 +266,6 @@ handle_renderer() { [ "$ROOT_KIND" != "missing" ] || return engine="$(engine_path)" if [ ! -x "$engine" ]; then - if ! jpeg_ready; then say "renderer installation is waiting for approved jpeg-turbo installation"; return; fi if [ "$MODE" = "dry" ]; then say "WOULD APPLY LOCALLY — install the renderer in $ROOT/.veed-engine"; return; fi say "installing the renderer locally" bash "$ROOT/pipeline/scripts/install-veed-engine.sh" || die "renderer installation failed" diff --git a/.gitignore b/.gitignore index 0c20d5d..73863ed 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,11 @@ preview/transcribe-video.ts # VEED login token caches (owner-only, never committed) veed/.veed-token.json veed/.veed-client.json +# The workspace the user chose to have billed (local state, names an account resource) +veed/.veed-workspace.json +# Speaking rates measured on THIS machine's paid runs. Local evidence, not shared state: the rates worth +# committing are seeded in veed/voice-rates.ts. +veed/.veed-voice-rates.json # Harness installs skills per-checkout; track ONLY the product skill so a stray skill can't be # committed (and then mirrored). The already-tracked SKILL.md stays tracked regardless. diff --git a/AGENTS.md b/AGENTS.md index b667673..2bf22b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,7 +171,8 @@ Run the isolated VEED tests without network access: ```sh node --import tsx --test \ - tests/cli-entry.test.ts tests/login-page.test.ts tests/oauth.test.ts tests/orchestrate.test.ts \ - tests/token-store.test.ts \ - tests/transcript-mapper.test.ts tests/workspace.test.ts + tests/charge-records.test.ts tests/cli-entry.test.ts tests/fabric.test.ts \ + tests/generate.test.ts tests/generate-charge.test.ts tests/generate-set.test.ts tests/generate-validation.test.ts tests/login-page.test.ts tests/oauth.test.ts tests/orchestrate.test.ts tests/sample-presenter.test.ts \ + tests/go.test.ts tests/token-store.test.ts \ + tests/transcript-mapper.test.ts tests/voice-rates.test.ts tests/workspace.test.ts ``` diff --git a/README.md b/README.md index a78b5a5..7e5a519 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ or MCP server when it helps. Source video is optional: stills, slides, generated motion graphics are enough when the brief calls for it. Be creative and fluid — try new ideas rather than collapsing every ask onto captions. +OpenEdit edits footage. Supply your own source files, or ask it to generate a talking-head clip with +VEED Fabric when you have none — that spends credits, and never without your approval. + OpenEdit launch video *This launch video was made in OpenEdit — click it to watch with sound.* @@ -42,7 +45,8 @@ rather than collapsing every ask onto captions. | Intel Macs | Not supported — the renderer ships macOS-arm64 only | | Older macOS | Built and tested on Tahoe 26.0. Nothing checks the version, so earlier releases may work — untested | | Windows, Linux | Planned; prioritisation depends on demand | -| Transcription | needed only to caption speech — OpenEdit asks once and remembers: VEED ([sign up](https://www.veed.io/signup) / [login](https://www.veed.io/login)), WhisperX locally, or your own service | +| Transcription | needed only to caption speech — OpenEdit asks once and remembers: VEED ([sign up](https://www.veed.io/signup) / [login](https://www.veed.io/login)), WhisperX locally, or your own service. **VEED transcription consumes VEED credits**; WhisperX runs locally on your machine, and your own service is billed by whoever provides it | +| Generation | optional — a veed.io account, only if you ask OpenEdit to generate footage you do not have | ## Installation Download this repo, or install via the command line with: diff --git a/SETUP.md b/SETUP.md index dba964d..53edf3b 100644 --- a/SETUP.md +++ b/SETUP.md @@ -19,15 +19,23 @@ Preflight has three modes: bare applies safe local setup, `--dry` reports withou ## Requirements 1. **macOS arm64**, **Git**, **Node**, and the pnpm version declared in `package.json`. -2. **jpeg-turbo** — `brew install jpeg-turbo`. It supplies `libjpeg.8.dylib`, which the renderer loads at launch. -3. **ffmpeg/ffprobe** (frame extraction + audio mux). On PATH or set `VEED_ENGINE_FFMPEG ` / `VEED_ENGINE_FFPROBE`. -4. **veed-engine-cli** — the render engine. Its first install is automatic and local; later updates require +2. **ffmpeg/ffprobe** (frame extraction + audio mux). On PATH or set `VEED_ENGINE_FFMPEG ` / `VEED_ENGINE_FFPROBE`. +3. **veed-engine-cli** — the render engine. Its first install is automatic and local; later updates require approval. `pipeline/scripts/install-veed-engine.sh` downloads and verifies the latest macOS-arm64 release into `.veed-engine/`. Set `VEED_ENGINE_BIN` to use an existing installation. -5. **A transcription provider** — the skill asks once and remembers the answer. Either +4. **A transcription provider** — the skill asks once and remembers the answer. Either **VEED** (`node --import tsx veed/login.ts`, one-time OAuth, ~30-day refreshable token; a free account covers about 10 minutes a month) or **WhisperX** locally (`bash pipeline/scripts/install-whisperx.sh`, free and offline). You can also point it at your own service. See `README.md`. +5. **VEED credits.** **VEED transcription consumes VEED credits** (WhisperX runs locally on your + machine; your own service is billed by whoever provides it). Separately, with no source video the skill can GENERATE a talking-head clip + (`veed/generate.ts`), which spends a workspace's AI Playground credits. **Fabric reuses VEED + transcription's authentication** — same account, same login, same token; no connector, no second sign-in. Generating is TWO charges on two allowances: AI Playground + credits for Fabric One Lipsync (~4 a second of finished video) and text-to-speech SECONDS for the speech. The + script sets the duration at roughly 1,080 characters a minute, so a 60-second read is a couple of + hundred credits plus about a minute of TTS. It never picks the workspace for you and never spends + without your explicit approval of the estimate; what the balance moved by is reported and recorded + under `runs//`. 6. **config.ts** — optional machine-path overrides. Videos live anywhere: invoke the skill with the video's path (absolute, or relative to your CWD). diff --git a/docs/FLOW.md b/docs/FLOW.md index 0ddf551..6e44a53 100644 --- a/docs/FLOW.md +++ b/docs/FLOW.md @@ -4,11 +4,14 @@ SCRIPT steps are deterministic; AGENT steps need an LLM. The default (recipe-bac end — the only agent is the opt-in analysis pass; creative face-1 (the user brought their own reference/brand/concept) and creative iteration on a delivered result are authored inline by the orchestrator. The whole thing is encoded executably in `.claude/skills/open-edit/SKILL.md` (the golden master) — this doc -is the conceptual map. One video → ONE captioned MP4. No per-shot intermediates, no user-approval gate. +is the conceptual map. One video → ONE captioned MP4. No per-shot intermediates, and no user-approval gate on +the captioning itself — the only gates are the FOOTAGE step's three, and they exist only when the user brought no +footage. | step | kind | what | |------|------|------| | preflight | SCRIPT | check `veed-engine-cli` vs the latest release; offer install if stale/missing | +| footage | SCRIPT (the only step that STOPS to ask) | **only when the user supplied no footage** — generate one with VEED Fabric (`veed/generate.ts`, same VEED login as prep) → `runs//.mp4`, which the PREP step then treats as ordinary footage. Three gates, in order: FOOTAGE (generate a video, take a clip they have/will record, use another model, or build from other sources instead — a non-Fabric model gets the speech caveat, since silent clips transcribe to nothing) → WORKSPACE (whose AI Playground credits; never picked for the user) → the CREDIT APPROVAL (script + presenter + voice + framing + quoted cost in ONE yes — the only gate that spends; cost tracks the finished DURATION at ~4 credits a second, and how long a script runs depends on the voice). Confirm and spend are two passes; a charged job is collected with `--resume`, never re-paid. Procedure: the FOOTAGE step of SKILL.md | | prep | SCRIPT | transcript from the chosen provider (VEED `veed/go.ts`, local WhisperX `prep/transcribe.ts`, or the user's own via `prep/whisper.ts`; chunks = beats, real per-word timings) → `word-timings.json` (per-word absolute-ms reveal delays, synthesized by `prep/prep.ts`) + base frames; auto-detect aspect → write `meta.json` (canvas + duration + paths) | | analyse | AGENT (vision) | **OPT-IN, skipped by default** — run only when the user asks to refine the style; then ONE nameless bg subagent reads the frames + transcript → `analysis.json` (per-beat shot/bboxes/neg-space/brightness in CANVAS px), and the DESIGN + RENDER step re-runs with it. The **only** vision pass. `analysis.json` existing IS the fast-path/refinement switch. | | sample ONE style | SCRIPT | `pipeline/scripts/sample-style.ts` — facet-scored seeded draw of one aspect-matched ref from `refs/tags.json` (v3 RUNTIME INDEX, recipes only; `fit` = aspect SOT; transcript energy weights the draw) → `style.json` (facets, energy, coverage, alternates). Zero tokens. The CREATIVE PASS routes here too: face-1 (user brought materials) → the DESIGN + RENDER step, from-scratch; face-2 REMIX (any creative iteration) / RE-ROLL (variants asks) / patch (defects). | @@ -17,18 +20,22 @@ is the conceptual map. One video → ONE captioned MP4. No per-shot intermediate | preview | SCRIPT (parallel) | `preview/server.ts runs/` — localhost preview opened for the user (read-only): watch and scrub the footage, follow the transcript, preview the subtitles; auto-swaps to the new `final/out.mp4` when an amend re-render lands. Launched in the background; the pipeline never waits on it | ## Where quality lives (do not let these drift) -- **Prep (1)** transcribes with whichever provider the user chose — asked once, recorded in +- **Footage** is the only step that spends the user's money, so it is the only one with gates: nothing + generates without the footage answer, no workspace is ever picked for the user, and ONE approval covers the + script, the presenter, the framing and the quoted cost. The charge is always reported, and a job already + paid for is resumed, never re-bought. +- **Prep** transcribes with whichever provider the user chose — asked once, recorded in `.open-edit-prefs.json` at the runtime root (real per-word timings either way) — then extracts the base frames and fixes the canvas from the source aspect; every downstream step reads `runs//meta.json` (never re-derives dims). -- **Analysis (2)** is opt-in — the default run derives vibe from the transcript and places by safe margins. +- **Analysis** is opt-in — the default run derives vibe from the transcript and places by safe margins. When the user asks to refine the style it becomes the sole vision pass: structured per-beat facts in CANVAS px so the design pass composes from numbers, not pixels — the design pass never re-reads the frames. -- **Style selection (3)** is a seeded SCRIPT — same run key → same ref, `--seed/--style` to override +- **Style selection** is a seeded SCRIPT — same run key → same ref, `--seed/--style` to override (`--style` only takes index ids — the runtime pool is recipes-only); recipe coverage grows offline. On creative face-1 the USER'S materials are the design authority and the DIRECTION covers content/mood/placement only. Keep the engagement seed copy **verbatim** (wording changes output). -- **Design (4)** commits ONE design system in a single pass (no aesthetic re-litigation). Recipes exist to +- **Design** commits ONE design system in a single pass (no aesthetic re-litigation). Recipes exist to ELIMINATE per-run design analysis — and being compiled code, the per-run assembly is exact by construction (paging, sizing, gate/fade math, verbatim `word-timings.json` delays; the arithmetic-slip failure class is gone). Verification is engine-analytic (`--verify`) — the only fix is the mechanical ladder step-down; no diff --git a/pipeline/scripts/concat-videos.ts b/pipeline/scripts/concat-videos.ts new file mode 100644 index 0000000..e874c67 --- /dev/null +++ b/pipeline/scripts/concat-videos.ts @@ -0,0 +1,256 @@ +// Joins several clips into one, end to end. +// +// node --import tsx pipeline/scripts/concat-videos.ts [...] +// node --import tsx pipeline/scripts/concat-videos.ts --canvas 1080x1920 --fit crop +// node --import tsx pipeline/scripts/concat-videos.ts --fit open [...] +// +// Generated clips do NOT share a canvas: a Fabric still decides its own aspect, so a portrait presenter +// and a landscape one produce different dimensions. ffmpeg's concat demuxer refuses that outright, and +// the stream-copy path people reach for first fails with a size-mismatch that reads like a codec problem. +// So this re-encodes every input onto one canvas, and a clip whose aspect differs from that canvas has to +// lose something: --fit letterbox (the default) pads it and keeps the whole frame behind black bars; +// --fit crop fills the canvas and cuts the overflow; --fit open decides nothing in code and instead +// reports which clips differ, so the agent can reframe each shot itself. Re-encoding is the price of +// accepting mixed inputs at all. +// +// The canvas defaults to the LARGEST input by area, so the sharpest clip keeps its resolution and the +// others are upscaled to meet it rather than everything collapsing to the smallest. +import { parseFlags } from '../../veed/args.ts'; +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { promisify } from 'node:util'; +import { FFMPEG, FFPROBE } from '../../config.ts'; + +const run = promisify(execFile); + +export interface Size { + width: number; + height: number; +} + +// letterbox pads a mismatched clip (keeps all framing); crop fills the canvas (cuts the overflow); open +// runs no encode and reports the mismatches for the agent to reframe by hand. +export type FitMode = 'letterbox' | 'crop' | 'open'; + +// Only these two reach the encoder — open never does, so the filter graph does not model it. +type EncodeFit = 'letterbox' | 'crop'; + +// Two aspects count as the same shape when they are within this FRACTION of each other — relative, so +// the threshold means the same thing in portrait and landscape. Even-dimension rounding moves a ratio by +// well under this, while a visibly different frame exceeds it. +const ASPECT_TOLERANCE = 0.01; + +export function aspectOf(size: Size): number { + return size.width / size.height; +} + +// Even dimensions: yuv420p chroma is subsampled 2x, and an odd width or height makes the encoder fail +// with a message that says nothing about the real cause. +export function evenSize(size: Size): Size { + return { width: Math.max(2, size.width - (size.width % 2)), height: Math.max(2, size.height - (size.height % 2)) }; +} + +export function largestByArea(sizes: Size[]): Size { + if (sizes.length === 0) throw new Error('no inputs to size a canvas from'); + return evenSize(sizes.reduce((a, b) => (b.width * b.height > a.width * a.height ? b : a))); +} + +export function parseCanvas(text: string): Size { + const m = /^(\d+)x(\d+)$/.exec(text.trim()); + if (!m) throw new Error(`--canvas expects WIDTHxHEIGHT, got "${text}"`); + return evenSize({ width: Number(m[1]), height: Number(m[2]) }); +} + +export function parseFit(text: string | undefined): FitMode { + if (text === undefined) return 'letterbox'; + if (text === 'letterbox' || text === 'crop' || text === 'open') return text; + throw new Error(`--fit expects letterbox, crop or open, got "${text}"`); +} + +export interface AspectReport { + size: Size; + matchesCanvas: boolean; +} + +// Which clips differ in shape from the canvas — the ones a fit strategy actually acts on. A clip that +// already matches gets the same result from crop or letterbox, so only these are worth reporting. +export function aspectReport(sizes: Size[], canvas: Size): AspectReport[] { + const target = aspectOf(canvas); + return sizes.map((size) => ({ + size, + matchesCanvas: Math.abs(aspectOf(size) - target) <= target * ASPECT_TOLERANCE, + })); +} + +// One filter chain per input: fit or fill the canvas, force a common timebase and pixel aspect. setsar +// stops a clip with a non-square pixel aspect from concatenating at the wrong shape; a clip that already +// matches the canvas is unaffected by either shape (nothing to pad or crop). +// Per-input audio facts the concat needs: whether the clip HAS an audio track, and how long it runs so a +// silent stand-in can be cut to match when it does not. +export interface AudioTrack { + hasAudio: boolean; + duration: number; +} + +export interface InputMeta extends AudioTrack { + size: Size; +} + +export function filterGraph(canvas: Size, fit: EncodeFit, audio: AudioTrack[]): string { + const { width: W, height: H } = canvas; + // crop scales to COVER then cuts; letterbox scales to FIT then pads. Without force_original_aspect_ratio + // ffmpeg stretches and every face goes oval. + const shape = fit === 'crop' + ? `scale=${W}:${H}:force_original_aspect_ratio=increase:flags=lanczos,crop=${W}:${H}` + : `scale=${W}:${H}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2:color=black`; + const parts: string[] = []; + audio.forEach((track, i) => { + parts.push(`[${i}:v]${shape},setsar=1,fps=30[v${i}]`); + // A clip with no audio track cannot be mapped as [i:a]; ffmpeg aborts the whole concat with an opaque + // "Stream specifier :a matches no streams". Synthesise silence of the clip's own length instead, so + // concat's a=1 always has a stream and a silent motion-graphics shot joins like any other. + parts.push(track.hasAudio + ? `[${i}:a]aresample=48000,asetpts=N/SR/TB[a${i}]` + : `anullsrc=r=48000:cl=stereo,atrim=duration=${track.duration},asetpts=N/SR/TB[a${i}]`); + }); + const count = audio.length; + const streams = Array.from({ length: count }, (_, i) => `[v${i}][a${i}]`).join(''); + parts.push(`${streams}concat=n=${count}:v=1:a=1[v][a]`); + return parts.join(';'); +} + +export function ffmpegArgs(inputs: string[], out: string, canvas: Size, fit: EncodeFit, audio: AudioTrack[]): string[] { + return [ + '-loglevel', 'error', '-y', + ...inputs.flatMap((i) => ['-i', i]), + '-filter_complex', filterGraph(canvas, fit, audio), + '-map', '[v]', '-map', '[a]', + '-c:v', 'libx264', '-crf', '18', '-preset', 'medium', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-b:a', '192k', + '-movflags', '+faststart', + out, + ]; +} + +export async function probeSize(file: string): Promise { + const { stdout } = await run(FFPROBE, [ + '-v', 'error', '-select_streams', 'v:0', + '-show_entries', 'stream=width,height', '-of', 'csv=p=0:s=x', file, + ]); + const [width, height] = stdout.trim().split('x').map(Number); + if (!width || !height) throw new Error(`could not read the dimensions of ${file}`); + return { width, height }; +} + +// One probe for everything the encode path needs: video size, whether an audio track is present, and the +// container duration (used only to size synthesised silence for an audio-less clip). +export async function probeInput(file: string): Promise { + const { stdout } = await run(FFPROBE, [ + '-v', 'error', + '-show_entries', 'stream=codec_type,width,height:format=duration', + '-of', 'json', file, + ]); + const data = JSON.parse(stdout) as { + streams?: { codec_type?: string; width?: number; height?: number }[]; + format?: { duration?: string }; + }; + const streams = data.streams ?? []; + const video = streams.find((s) => s.codec_type === 'video'); + if (!video?.width || !video?.height) throw new Error(`could not read the dimensions of ${file}`); + const duration = Number(data.format?.duration); + if (!Number.isFinite(duration)) throw new Error(`could not read the duration of ${file}`); + return { + size: { width: video.width, height: video.height }, + hasAudio: streams.some((s) => s.codec_type === 'audio'), + duration, + }; +} + +export interface ConcatOptions { + canvas?: Size; + fit?: EncodeFit; +} + +// Shared by the encode and report paths so the "clip not found" message lives in exactly one place. +function assertClipsExist(inputs: string[]): void { + const missing = inputs.filter((i) => !existsSync(i)); + if (missing.length > 0) throw new Error(`clip not found:\n ${missing.join('\n ')}`); +} + +export async function concatVideos(inputs: string[], out: string, opts: ConcatOptions = {}): Promise { + if (inputs.length < 2) throw new Error('concat needs at least two clips; one clip is already the answer'); + assertClipsExist(inputs); + // Every input is probed now — the audio plan (present vs silent, and each clip's length) is needed even + // when --canvas pins the size, so there is nothing to skip. + const metas = await Promise.all(inputs.map(probeInput)); + const target = opts.canvas ?? largestByArea(metas.map((m) => m.size)); + await run(FFMPEG, ffmpegArgs(inputs, out, target, opts.fit ?? 'letterbox', metas)); + return target; +} + +// The report path, a sibling to concatVideos that encodes nothing: probe, choose the canvas, and say which +// clips differ in shape — the --fit open answer, exported so it is testable without a subprocess. +export async function reportAspects(inputs: string[], canvas?: Size): Promise<{ canvas: Size; report: AspectReport[] }> { + assertClipsExist(inputs); + const sizes = await Promise.all(inputs.map(probeSize)); + const target = canvas ?? largestByArea(sizes); + return { canvas: target, report: aspectReport(sizes, target) }; +} + +// Prints the --fit open report: the chosen canvas, then each clip's shape against it. The report is in +// input order, so the clip path is taken by position. +function printAspectReport(inputs: string[], canvas: Size, report: AspectReport[]): void { + console.log(`[concat] canvas ${canvas.width}x${canvas.height} (aspect ${aspectOf(canvas).toFixed(3)})`); + report.forEach((r, i) => { + const tag = r.matchesCanvas ? 'matches ' : 'MISMATCH'; + console.log(` ${tag} ${r.size.width}x${r.size.height} (${aspectOf(r.size).toFixed(3)}) ${inputs[i]}`); + }); + const off = report.filter((r) => !r.matchesCanvas).length; + console.log( + off === 0 + ? '[concat] every clip already matches the canvas — --fit letterbox or crop will both join cleanly.' + : `[concat] ${off}/${report.length} clips differ from the canvas aspect — reframe them per shot, ` + + 'or fall back to --fit crop (fills, cuts overflow) or --fit letterbox (pads, keeps all framing).', + ); +} + +async function main(): Promise { + // Strict, with the clip paths as positionals: a mistyped --canvas or --fit must not silently fall + // through and be treated as one of the files to join. + const { values, positionals } = parseFlags({ + args: process.argv.slice(2), + options: { canvas: { type: 'string' }, fit: { type: 'string' } }, + allowPositionals: true, + }); + const canvas = values.canvas === undefined ? undefined : parseCanvas(values.canvas); + const fit = parseFit(values.fit); + + if (fit === 'open') { + // open reports on the inputs and writes nothing, so every positional is a clip — there is no out path. + const clips = positionals; + if (clips.length < 2) { + console.error('usage: node --import tsx pipeline/scripts/concat-videos.ts --fit open [...]'); + process.exit(1); + } + const { canvas: target, report } = await reportAspects(clips, canvas); + printAspectReport(clips, target, report); + return; + } + + const [out, ...inputs] = positionals; + if (!out || inputs.length < 2) { + console.error( + 'usage: node --import tsx pipeline/scripts/concat-videos.ts [--canvas WxH] [--fit letterbox|crop] [...]', + ); + process.exit(1); + } + const used = await concatVideos(inputs, out, { canvas, fit }); + console.log(`[concat] ${inputs.length} clips → ${out} at ${used.width}x${used.height} (--fit ${fit})`); +} + +if (process.argv[1]?.endsWith('concat-videos.ts')) { + main().catch((e: unknown) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); + }); +} diff --git a/pipeline/scripts/install-veed-engine.sh b/pipeline/scripts/install-veed-engine.sh index 09e420b..55a5d43 100644 --- a/pipeline/scripts/install-veed-engine.sh +++ b/pipeline/scripts/install-veed-engine.sh @@ -69,10 +69,7 @@ mv "$DEST/$UPSTREAM_BIN" "$DEST/$LOCAL_BIN" [ -f "$WORK/feature-support.md" ] && mv "$WORK/feature-support.md" "$DEST/feature-support.md" [ -f "$WORK/LICENSE-binary.md" ] && mv "$WORK/LICENSE-binary.md" "$DEST/LICENSE-binary.md" -# 5. Clear any quarantine flag so the ad-hoc-signed bundle runs without a Gatekeeper prompt -xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true - -# 6. Verify the binary actually runs (catches a wrong-arch download or an unexpected tarball layout) +# 5. Verify the binary actually runs (catches a wrong-arch download or an unexpected tarball layout) if ! VER="$("$DEST/$LOCAL_BIN" --version 2>/dev/null)"; then echo "install-veed-engine: ERROR — installed $DEST/$LOCAL_BIN but it did not run (--version failed)." >&2 exit 1 diff --git a/prep/transcribe.ts b/prep/transcribe.ts index 8fdae66..f068278 100644 --- a/prep/transcribe.ts +++ b/prep/transcribe.ts @@ -23,6 +23,7 @@ import { FFMPEG, FFPROBE, REPO_ROOT, WHISPERX_BIN, WHISPERX_MODEL } from '../con import { resolveVideoArg, runKeyOf } from '../pipeline/scripts/resolve-video.ts'; export { runKeyOf }; import { mapWhisperTranscript, type Transcript, type WhisperJson } from './whisper-mapper.ts'; +import { parseFlags } from '../veed/args.ts'; export const PREFS_PATH = join(REPO_ROOT, '.open-edit-prefs.json'); export const PROVIDERS = ['veed', 'whisperx', 'custom'] as const; @@ -234,35 +235,31 @@ export interface Args { record?: Provider; } -// Both `--flag value` and `--flag=value`. An unknown flag is an ERROR: a misspelled --language would -// otherwise leave English-only weights on non-English audio, transcribing it as confident nonsense. +// Both `--flag value` and `--flag=value`, via the shared strict parser — one flag grammar for every entry +// point in the repo, so a misspelled --language is refused the same way here as everywhere else (leaving +// English-only weights on non-English audio would transcribe it as confident nonsense). export function parseArgs(argv: string[]): Args { - const known = ['--model', '--language', '--record']; - const videos: string[] = []; - const out: Record = {}; - for (let i = 0; i < argv.length; i += 1) { - const token = argv[i]; - if (!token.startsWith('--')) { - videos.push(token); - continue; - } - const eq = token.indexOf('='); - const flag = eq === -1 ? token : token.slice(0, eq); - if (!known.includes(flag)) throw new Error(`unknown flag ${flag}\n${USAGE}`); - const value = eq === -1 ? argv[++i] : token.slice(eq + 1); - if (value === undefined || value === '') throw new Error(`${flag} needs a value\n${USAGE}`); - out[flag.slice(2)] = value; + const { values, positionals } = parseFlags({ + args: argv, + options: { model: { type: 'string' }, language: { type: 'string' }, record: { type: 'string' } }, + allowPositionals: true, + }); + // parseFlags accepts `--model=` as an empty string; an empty value is a mistake, not a selection, so + // refuse it up front rather than letting '' flow into tier/language/provider selection as a silent blank. + for (const flag of ['model', 'language', 'record'] as const) { + if (values[flag] === '') throw new Error(`--${flag} needs a value\n${USAGE}`); } - if (out.record !== undefined && !(PROVIDERS as readonly string[]).includes(out.record)) { - throw new Error(`unknown provider "${out.record}" — expected one of ${PROVIDERS.join(', ')}\n${USAGE}`); + const record = values.record; + if (record !== undefined && !(PROVIDERS as readonly string[]).includes(record)) { + throw new Error(`unknown provider "${record}" — expected one of ${PROVIDERS.join(', ')}\n${USAGE}`); } // Recording a choice is not a transcription run, so it needs no video. - if (out.record === undefined && videos.length === 0) throw new Error(`no video given\n${USAGE}`); + if (record === undefined && positionals.length === 0) throw new Error(`no video given\n${USAGE}`); return { - videos, - ...(out.record === undefined ? {} : { record: out.record as Provider }), - ...(out.model === undefined ? {} : { model: out.model }), - ...(out.language === undefined ? {} : { language: out.language }), + videos: positionals, + ...(record === undefined ? {} : { record: record as Provider }), + ...(values.model === undefined ? {} : { model: values.model }), + ...(values.language === undefined ? {} : { language: values.language }), }; } diff --git a/prep/whisper-mapper.ts b/prep/whisper-mapper.ts index eb874ec..4267610 100644 --- a/prep/whisper-mapper.ts +++ b/prep/whisper-mapper.ts @@ -182,10 +182,14 @@ function timeAll( /** The window must cover its own words: synth-word-timings discards a beat whose words fall outside. */ function chunkOf(words: TranscriptWord[], start?: number, end?: number): TranscriptChunk | undefined { if (words.length === 0) return undefined; - const firstWord = words[0].timestamp[0]; - const lastWord = words[words.length - 1].timestamp[1]; - const from = Math.min(finite(start) ? start : firstWord, firstWord); - const to = Math.max(finite(end) ? end : lastWord, lastWord); + // The MIN start and MAX end across every word, not the first/last by start-order: overlaps are normal + // in ASR, so an interior word can start after the first yet end after the last, and a window bounded by + // the last word's end would leave that word's midpoint outside it — synth-word-timings then drops the beat. + // reduce, not Math.min(...spread): a pathologically long single cue would blow the argument/stack limit. + const earliest = words.reduce((min, w) => Math.min(min, w.timestamp[0]), Infinity); + const latest = words.reduce((max, w) => Math.max(max, w.timestamp[1]), -Infinity); + const from = Math.min(finite(start) ? start : earliest, earliest); + const to = Math.max(finite(end) ? end : latest, latest); return { text: words.map((w) => w.text).join(' '), timestamp: [from, to], words }; } diff --git a/preview/server.ts b/preview/server.ts index 2a722a0..4cb2a48 100644 --- a/preview/server.ts +++ b/preview/server.ts @@ -70,14 +70,19 @@ async function streamMedia(res: ServerResponse, path: string, rangeHeader: strin function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let data = ''; + let aborted = false; req.on('data', (chunk) => { + if (aborted) return; // stop accumulating past the cap, but do not tear down the socket data += chunk; if (data.length > 65536) { + aborted = true; + // Drain the rest rather than destroy(): the handler still needs to send a 413 the client can read, + // and destroying the socket here reaches the client as a dropped connection, not a status. + req.resume(); reject(new Error('request body too large')); - req.destroy(); } }); - req.on('end', () => resolve(data)); + req.on('end', () => { if (!aborted) resolve(data); }); req.on('error', reject); }); } @@ -95,6 +100,16 @@ export async function createPreviewServer( }); await hub.start(); + // /wcag/choice is a read-modify-write on one shared file; serialise the writes so two overlapping POSTs + // cannot both read the same base and clobber each other's group (last-write-wins would silently drop a + // choice). One tail promise per server is enough — this process is the only writer. + let choiceTail: Promise = Promise.resolve(); + const serialiseChoice = (fn: () => Promise): Promise => { + const done = choiceTail.then(fn, fn); + choiceTail = done.catch(() => {}); + return done; + }; + const server: Server = createServer(async (req, res) => { const url = new URL(req.url ?? '/', 'http://127.0.0.1'); const path = url.pathname; @@ -155,9 +170,18 @@ export async function createPreviewServer( return; } if (req.method === 'POST' && path === '/wcag/choice') { + let raw: string; + try { + raw = await readBody(req); + } catch (e) { + // readBody rejects an over-cap body specifically; report THAT, not a JSON error it never reached. + const tooLarge = e instanceof Error && e.message === 'request body too large'; + json(res, tooLarge ? 413 : 400, { error: tooLarge ? 'request body too large' : 'could not read request body' }); + return; + } let entry: unknown; try { - entry = JSON.parse(await readBody(req)); + entry = JSON.parse(raw); } catch { json(res, 400, { error: 'invalid JSON body' }); return; @@ -167,15 +191,19 @@ export async function createPreviewServer( json(res, 400, { error: err }); // reject unknown kinds / malformed hex loudly return; } - // Read-modify-write, last-write-wins per group. A malformed existing file - // is replaced (the server owns this file); a valid one is preserved. + // Read-modify-write, last-write-wins per group. A malformed existing file is replaced (the server + // owns this file); a valid one is preserved. Serialised so a concurrent POST cannot read the same + // base and drop this group's write. const choicePath = join(runDir, 'final', 'wcag-choice.json'); - let file: ChoiceFile = emptyChoiceFile(); - try { - file = parseChoiceFile(JSON.parse(await readFile(choicePath, 'utf8'))); - } catch { /* missing or corrupt -> start fresh */ } - const next = upsertChoice(file, entry as Parameters[1]); - await writeFile(choicePath, JSON.stringify(next, null, 2) + '\n'); + const next = await serialiseChoice(async () => { + let file: ChoiceFile = emptyChoiceFile(); + try { + file = parseChoiceFile(JSON.parse(await readFile(choicePath, 'utf8'))); + } catch { /* missing or corrupt -> start fresh */ } + const merged = upsertChoice(file, entry as Parameters[1]); + await writeFile(choicePath, JSON.stringify(merged, null, 2) + '\n'); + return merged; + }); json(res, 200, next); return; } diff --git a/veed/allowances.ts b/veed/allowances.ts index 40f2226..47952c0 100644 --- a/veed/allowances.ts +++ b/veed/allowances.ts @@ -11,12 +11,11 @@ import { type VeedHttp, unwrap } from './api.ts'; // travels as a header rather than in the path. const BUCKETS = ['ALLOWANCE', 'FEATURE_BALANCE', 'OPEN_BALANCE', 'REVENUECAT_BALANCE'] as const; -// A workspace holds TWO separate allowances, measured in different units, and a feature can draw on -// either: TEXT_TO_SPEECH is billed in SECONDS, AI_PLAYGROUND_CREDITS in credits. They are never summed — -// reporting one of them as "the balance" understates what a workspace can and cannot afford. +// The one balance a generation draws on: AI_PLAYGROUND_CREDITS, summed across the buckets. Speech used +// to bill a separate TEXT_TO_SPEECH seconds allowance and now bills these same credits, so there is a +// single figure to check and observe. export interface Allowances { aiPlaygroundCredits: number; - textToSpeechSeconds: number; } export async function getAllowances(http: VeedHttp, workspaceId: string): Promise { @@ -27,5 +26,5 @@ export async function getAllowances(http: VeedHttp, workspaceId: string): Promis const data = unwrap>>(report); const sum = (meter: string): number => BUCKETS.reduce((total, bucket) => total + (data[bucket]?.[meter]?.amount ?? 0), 0); - return { aiPlaygroundCredits: sum('AI_PLAYGROUND_CREDITS'), textToSpeechSeconds: sum('TEXT_TO_SPEECH') }; + return { aiPlaygroundCredits: sum('AI_PLAYGROUND_CREDITS') }; } diff --git a/veed/api.ts b/veed/api.ts index f9ff241..66f1498 100644 --- a/veed/api.ts +++ b/veed/api.ts @@ -8,8 +8,8 @@ export const VEED_ORIGIN = (process.env.VEED_ORIGIN ?? 'https://www.veed.io').re export const VEED_API_BASE = (process.env.VEED_API_BASE ?? `${VEED_ORIGIN}/api/v1`).replace(/\/$/, ''); export interface VeedHttp { - // `headers` is for the few routes that take their scope out-of-band: the usage report reads the - // workspace from a header rather than from the path or the body. + // `headers` is for the few routes that take a scope out-of-band rather than in the path or body — + // the usage report reads the workspace from a header rather than the path or body. getJson(path: string, headers?: Record): Promise; // Like getJson but resolves null on a 404, for routes where 404 is a state // (GET /asset/:id only serves UPLOADED assets), not an error. @@ -42,6 +42,8 @@ export interface Asset { id: string; uploadState: 'UPLOADING' | 'UPLOADED' | 'FAILED'; cdnUrl?: string | null; + // Set on generated assets (a Fabric render); the transcription path only ever reads cdnUrl. + sourceUrl?: string | null; } export interface StartedTranscription { @@ -64,18 +66,34 @@ export async function listWorkspaces(http: VeedHttp): Promise { export async function createUploadableAsset( http: VeedHttp, - args: { mimeType: string; extension: string }, + args: { + mimeType: string; + extension: string; + // Defaults reproduce the transcription upload; the Fabric TTS asset overrides them. + assetType?: string; + // Must be a real AssetGroup enum value; source uploads are 'srcVideo', TTS audio is 'TTS'. + group?: string; + assetSubType?: string; + storage?: string; + // Owner keys. Omitted entirely when absent — the schema rejects an explicit null, and a + // workspaceId-ONLY upload is refused by a permission check, so pass both or neither. + // Transcription uploads unscoped and lets the transcribe call carry the billing workspace; + // the Fabric TTS asset is scoped to the project it is synthesized into. + workspaceId?: string; + projectId?: string; + }, ): Promise { - // The upload carries NO owner keys. They must be omitted rather than sent as null (the schema - // rejects explicit null), and a workspaceId-only upload is refused by a permission check anyway. - // Unscoped upload + workspace-billed transcribe is the only shape this client uses. return unwrap( await http.postJson('/asset', { - assetType: 'VIDEO', + assetType: args.assetType ?? 'VIDEO', mimeType: args.mimeType, extension: args.extension, - // Must be a real AssetGroup enum value; source uploads are 'srcVideo'. - group: 'srcVideo', + group: args.group ?? 'srcVideo', + ...(args.assetSubType ? { assetSubType: args.assetSubType } : {}), + ...(args.storage ? { storage: args.storage } : {}), + ...(args.workspaceId && args.projectId + ? { workspaceId: args.workspaceId, projectId: args.projectId } + : {}), }), ); } diff --git a/veed/args.ts b/veed/args.ts index 91228c3..45347c7 100644 --- a/veed/args.ts +++ b/veed/args.ts @@ -1,4 +1,5 @@ -// Strict flag parsing for every entry point in this repo that reads flags from a shell. +// Strict flag parsing for every entry point in this repo that reads flags from a shell, plus the small +// input guards the commands share: path-segment safety, and a describer for a value that failed a check. // // The parsing is node:util's parseArgs, which is strict by default: an unrecognised flag, a flag missing // its value, and a stray positional are each an error. That is the whole point — the hand-rolled @@ -22,3 +23,34 @@ export function parseFlags(config: T & { strict?: nev throw new Error(`${(cause as Error).message}\n${valid}`, { cause }); } } + +// Named for what the value IS: "expected a number" without the value it found cannot be diagnosed from +// the message alone, and this is the message a user reads instead of getting their result. +export function describeValue(value: unknown): string { + if (value === undefined) return 'absent'; + if (typeof value === 'string') return `the string ${JSON.stringify(value)}`; + if (typeof value === 'object') return value === null ? 'null' : JSON.stringify(value); + return String(value); +} + +// --key and a session id each name a path segment under runs/, so both are fenced the same way: anything +// that could climb out of the directory (a separator, a `..`, a leading dash) is refused rather than +// normalised, because a silently relocated read or write is worse than an error. +function assertSafeSegment(value: string, invalid: (v: string) => string): string { + if (!/^[A-Za-z0-9._-]+$/.test(value) || value === '.' || value === '..' || value.startsWith('-')) { + throw new Error(invalid(value)); + } + return value; +} + +export function assertSafeKey(key: string): string { + return assertSafeSegment(key, (k) => + `invalid --key "${k}": use a single path segment of letters, digits, dot, dash or underscore (it names a directory under runs/)`, + ); +} + +export function assertSafeSessionId(sessionId: string): string { + return assertSafeSegment(sessionId, (id) => + `invalid session id "${id}": use letters, digits, dot, dash or underscore (it names a charge record under runs//)`, + ); +} diff --git a/veed/charge-records.ts b/veed/charge-records.ts new file mode 100644 index 0000000..9f23d46 --- /dev/null +++ b/veed/charge-records.ts @@ -0,0 +1,105 @@ +// Who is allowed to charge this run, decided from the records the other attempts left behind. +// +// Concurrent runs of the same key are a normal workflow, so there is no lock here and no exclusive file: +// every attempt writes its OWN record, named for the session that wrote it, and the only question left is +// what the records it can see mean. That question is this module — pure, so every row of the table below is +// testable without a filesystem, a clock or a second process. veed/generate.ts does the IO around it. +// +// resolved the attempt finished (or was abandoned); it constrains nothing +// charging a charge is in flight right now; a second one would buy the same script twice +// paid the charge landed and the job id is on record; the video is one --resume away +// orphaned a charge was started by a process that is gone, and no job id was ever recorded, so whether +// the money moved is genuinely unknown +import { POLL_DEADLINE_MS } from './fabric.ts'; + +export type ChargePhase = 'charging' | 'charged'; + +// One attempt at charging, written BEFORE create_fabric_video is called: a record that only appears on +// success is missing at exactly the moment it matters — when the response never came back. +export interface ChargeRecord { + sessionId: string; + pid: number; + host: string; + startedAt: number; + phase: ChargePhase; + // Set with `charged`: the id of the job the workspace has now paid for. + jobId?: string; + resolvedAt?: number; +} + +export type ChargeState = 'resolved' | 'charging' | 'paid' | 'orphaned'; + +export interface ChargeVerdict extends ChargeRecord { + state: ChargeState; +} + +export interface ChargeContext { + host: string; + now: number; + isAlive: (pid: number) => boolean; +} + +const CHARGE_PREFIX = '.fabric-charge-'; +const CHARGE_SUFFIX = '.json'; +// A pid on another machine cannot be probed, so its record is trusted for a bounded time instead. The bound +// is the poll deadline plus a margin: anything shorter would declare a perfectly healthy run dead and let a +// second charge through. +const LEASE_MARGIN_MS = 5 * 60_000; +export const CHARGE_LEASE_MS = POLL_DEADLINE_MS + LEASE_MARGIN_MS; + +export function chargeFileName(sessionId: string): string { + return `${CHARGE_PREFIX}${sessionId}${CHARGE_SUFFIX}`; +} + +// Null for every other file a run directory holds, so a scan can read the session id straight off the name. +export function sessionIdOfChargeFile(fileName: string): string | null { + if (!fileName.startsWith(CHARGE_PREFIX) || !fileName.endsWith(CHARGE_SUFFIX)) return null; + const id = fileName.slice(CHARGE_PREFIX.length, fileName.length - CHARGE_SUFFIX.length); + return id === '' ? null : id; +} + +// `typeof x === 'number'` is not proof of a usable number: 1e999 is legal JSON that parses back as +// Infinity, and an infinite resolvedAt would read as a finished attempt — which is exactly the reading that +// clears the way to charge a second time. +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +// Null rather than a partial record: the caller refuses on null, because guessing a missing phase is +// guessing whether money moved. +export function parseChargeRecord(raw: string): ChargeRecord | null { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return null; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const r = value as Record; + if (typeof r.sessionId !== 'string' || r.sessionId === '') return null; + if (r.phase !== 'charging' && r.phase !== 'charged') return null; + if (!isFiniteNumber(r.pid) || typeof r.host !== 'string' || r.host === '' || !isFiniteNumber(r.startedAt)) return null; + // A charge that landed is only useful if it names the job it bought; a `charged` record without one says + // money moved and offers no way to collect what it bought, so it is refused rather than believed in part. + if (r.phase === 'charged' && (typeof r.jobId !== 'string' || r.jobId === '')) return null; + if (r.jobId !== undefined && typeof r.jobId !== 'string') return null; + if (r.resolvedAt !== undefined && !isFiniteNumber(r.resolvedAt)) return null; + return value as ChargeRecord; +} + +export function classifyChargeRecords(records: ChargeRecord[], ctx: ChargeContext): ChargeVerdict[] { + return records.map((record) => ({ ...record, state: stateOf(record, ctx) })); +} + +function stateOf(record: ChargeRecord, ctx: ChargeContext): ChargeState { + if (record.resolvedAt !== undefined) return 'resolved'; + // Liveness is deliberately not consulted: the money is gone and the job id is recorded, so a dead owner + // changes nothing about what the next run may do. + if (record.phase === 'charged') return 'paid'; + return ownerLives(record, ctx) ? 'charging' : 'orphaned'; +} + +function ownerLives(record: ChargeRecord, ctx: ChargeContext): boolean { + if (record.host !== ctx.host) return ctx.now - record.startedAt < CHARGE_LEASE_MS; + return ctx.isAlive(record.pid); +} diff --git a/veed/fabric-characters.ts b/veed/fabric-characters.ts new file mode 100644 index 0000000..971a938 --- /dev/null +++ b/veed/fabric-characters.ts @@ -0,0 +1,247 @@ +// The Fabric character presets, copied verbatim from VEED's own Fabric integration. There is NO API +// that serves this list — listing characters there is a pure filter over the same hardcoded array, +// so vendoring it is the whole of what that listing does. Re-copy if VEED adds characters. +// +// This is a DEFAULT MENU, not a limit: Fabric One Lipsync takes any image asset, so +// veed/fabric.ts also accepts --image . Each preset carries a voiceId that pairs +// well with the face; it is the default when --voice is not given. +// +// Thumbnails and previews are public on cdn-site-assets.veed.io — no auth, and the transload +// route fetches the thumbnail server-side, so these bytes never pass through this process. +export interface Character { + id: string; + name: string; + thumbnail: string; + gender: 'male' | 'female'; + voiceId: string; + previewUrl: string; +} + +// `readonly` rather than upstream's `Character[]`: the literal ends in a const assertion, which is +// not assignable to a mutable array type. +export const FABRIC_CHARACTERS: readonly Character[] = [ + { + id: 'character-1', + name: 'Character 1', + thumbnail: + 'https://cdn-site-assets.veed.io/1_P_bd7b69e369/1_P_bd7b69e369.jpg', + gender: 'female', + voiceId: 'jqcCZkN6Knx8BJ5TBdYR', + previewUrl: 'https://cdn-site-assets.veed.io/1_b90bca51f4/1_b90bca51f4.mp4', + }, + { + id: 'character-2', + name: 'Character 2', + thumbnail: 'https://cdn-site-assets.veed.io/2_bdeae28943/2_bdeae28943.jpg', + gender: 'female', + voiceId: 'wkqe33083YLBV35we7yB', + previewUrl: 'https://cdn-site-assets.veed.io/2_7b367257e0/2_7b367257e0.mp4', + }, + { + id: 'character-3', + name: 'Character 3', + thumbnail: 'https://cdn-site-assets.veed.io/3_094c5d0d71/3_094c5d0d71.jpg', + gender: 'male', + voiceId: '2mltbVQP21Fq8XgIfRQJ', + previewUrl: 'https://cdn-site-assets.veed.io/3_33eaad1791/3_33eaad1791.mp4', + }, + { + id: 'character-4', + name: 'Character 4', + thumbnail: 'https://cdn-site-assets.veed.io/4_1c2e9b9449/4_1c2e9b9449.jpg', + gender: 'female', + voiceId: '4tRn1lSkEn13EVTuqb0g', + previewUrl: 'https://cdn-site-assets.veed.io/4_a2c23964d3/4_a2c23964d3.mp4', + }, + { + id: 'character-5', + name: 'Character 5', + thumbnail: 'https://cdn-site-assets.veed.io/5_571a53eb5e/5_571a53eb5e.jpg', + gender: 'female', + voiceId: 'T7eLpgAAhoXHlrNajG8v', + previewUrl: 'https://cdn-site-assets.veed.io/5_ed9e3f064b/5_ed9e3f064b.mp4', + }, + { + id: 'character-6', + name: 'Character 6', + thumbnail: + 'https://cdn-site-assets.veed.io/6_P_c69c4ddae8/6_P_c69c4ddae8.jpg', + gender: 'male', + voiceId: '1BUhH8aaMvGMUdGAmWVM', + previewUrl: 'https://cdn-site-assets.veed.io/6_4de04ca6c4/6_4de04ca6c4.mp4', + }, + { + id: 'character-7', + name: 'Character 7', + thumbnail: 'https://cdn-site-assets.veed.io/7_4070f310cb/7_4070f310cb.jpg', + gender: 'female', + voiceId: 'SAz9YHcvj6GT2YYXdXww', + previewUrl: 'https://cdn-site-assets.veed.io/7_a2d3132e04/7_a2d3132e04.mp4', + }, + { + id: 'character-8', + name: 'Character 8', + thumbnail: 'https://cdn-site-assets.veed.io/8_dce399ffd9/8_dce399ffd9.jpg', + gender: 'female', + voiceId: 'jqcCZkN6Knx8BJ5TBdYR', + previewUrl: 'https://cdn-site-assets.veed.io/8_995be9659f/8_995be9659f.mp4', + }, + { + id: 'character-9', + name: 'Character 9', + thumbnail: 'https://cdn-site-assets.veed.io/9_e1269440e8/9_e1269440e8.jpg', + gender: 'male', + voiceId: 'gs0tAILXbY5DNrJrsM6F', + previewUrl: 'https://cdn-site-assets.veed.io/9_cdae080aa7/9_cdae080aa7.mp4', + }, + { + id: 'character-10', + name: 'Character 10', + thumbnail: + 'https://cdn-site-assets.veed.io/10_c51ad7b85f/10_c51ad7b85f.jpg', + gender: 'female', + voiceId: 'FVQMzxJGPUBtfz1Azdoy', + previewUrl: + 'https://cdn-site-assets.veed.io/10_a5ed3c4649/10_a5ed3c4649.mp4', + }, + { + id: 'character-11', + name: 'Character 11', + thumbnail: + 'https://cdn-site-assets.veed.io/11_P_f99954fcfe/11_P_f99954fcfe.jpg', + gender: 'male', + voiceId: 'pwMBn0SsmN1220Aorv15', + previewUrl: + 'https://cdn-site-assets.veed.io/11_c42cbb7bff/11_c42cbb7bff.mp4', + }, + { + id: 'character-12', + name: 'Character 12', + thumbnail: + 'https://cdn-site-assets.veed.io/12_0bf54bcd62/12_0bf54bcd62.jpg', + gender: 'female', + voiceId: '6u6JbqKdaQy89ENzLSju', + previewUrl: + 'https://cdn-site-assets.veed.io/12_c73d0fa99b/12_c73d0fa99b.mp4', + }, + { + id: 'character-13', + name: 'Character 13', + thumbnail: + 'https://cdn-site-assets.veed.io/13_c0475066b8/13_c0475066b8.jpg', + gender: 'female', + voiceId: '8DzKSPdgEQPaK5vKG0Rs', + previewUrl: + 'https://cdn-site-assets.veed.io/13_f88c128573/13_f88c128573.mp4', + }, + { + id: 'character-14', + name: 'Character 14', + thumbnail: + 'https://cdn-site-assets.veed.io/14_P_3fa580b86a/14_P_3fa580b86a.jpg', + gender: 'female', + voiceId: 'kPzsL2i3teMYv0FxEYQ6', + previewUrl: + 'https://cdn-site-assets.veed.io/14_2a682c98a4/14_2a682c98a4.mp4', + }, + { + id: 'character-15', + name: 'Character 15', + thumbnail: + 'https://cdn-site-assets.veed.io/15_P_7d44dcb0df/15_P_7d44dcb0df.jpg', + gender: 'female', + voiceId: 'eBvoGh8YGJn1xokno71w', + previewUrl: + 'https://cdn-site-assets.veed.io/15_01d78f15f4/15_01d78f15f4.mp4', + }, + { + id: 'character-16', + name: 'Character 16', + thumbnail: + 'https://cdn-site-assets.veed.io/16_674d899465/16_674d899465.jpg', + gender: 'male', + voiceId: 'VlUmeC1Uzj3NnwiVR9K9', + previewUrl: + 'https://cdn-site-assets.veed.io/16_af095aa103/16_af095aa103.mp4', + }, + { + id: 'character-17', + name: 'Character 17', + thumbnail: + 'https://cdn-site-assets.veed.io/17_P_96b8bf3cb0/17_P_96b8bf3cb0.jpg', + gender: 'female', + voiceId: 't7jjqLOG6kzCY6SckkfL', + previewUrl: + 'https://cdn-site-assets.veed.io/17_bc20f3f07f/17_bc20f3f07f.mp4', + }, + { + id: 'character-18', + name: 'Character 18', + thumbnail: + 'https://cdn-site-assets.veed.io/18_0bc9ef0ef8/18_0bc9ef0ef8.jpg', + gender: 'female', + voiceId: '8DzKSPdgEQPaK5vKG0Rs', + previewUrl: + 'https://cdn-site-assets.veed.io/18_8a66e0e89a/18_8a66e0e89a.mp4', + }, + { + id: 'character-19', + name: 'Character 19', + thumbnail: + 'https://cdn-site-assets.veed.io/19_98dae9b182/19_98dae9b182.jpg', + gender: 'male', + voiceId: '6OzrBCQf8cjERkYgzSg8', + previewUrl: + 'https://cdn-site-assets.veed.io/19_0b42d84604/19_0b42d84604.mp4', + }, + { + id: 'character-20', + name: 'Character 20', + thumbnail: + 'https://cdn-site-assets.veed.io/20_66526d0e4f/20_66526d0e4f.jpg', + gender: 'female', + voiceId: 'gPe4h2IS1C7XHbnizzFa', + previewUrl: + 'https://cdn-site-assets.veed.io/20_fe82034678/20_fe82034678.mp4', + }, + { + id: 'character-21', + name: 'Character 21', + thumbnail: + 'https://cdn-site-assets.veed.io/21_dd985d182b/21_dd985d182b.png', + gender: 'female', + voiceId: 'XfNU2rGpBa01ckF309OY', + previewUrl: + 'https://cdn-site-assets.veed.io/21_ba3d311d14/21_ba3d311d14.mp4', + }, + { + id: 'character-22', + name: 'Character 22', + thumbnail: + 'https://cdn-site-assets.veed.io/22_031ba9ca51/22_031ba9ca51.png', + gender: 'male', + voiceId: 'VlUmeC1Uzj3NnwiVR9K9', + previewUrl: + 'https://cdn-site-assets.veed.io/22_886acf57ba/22_886acf57ba.mp4', + }, + { + id: 'character-23', + name: 'Character 23', + thumbnail: + 'https://cdn-site-assets.veed.io/23_8071c211e4/23_8071c211e4.png', + gender: 'female', + voiceId: 'FGY2WhTYpPnrIDTdsKH5', + previewUrl: + 'https://cdn-site-assets.veed.io/23_04586068b9/23_04586068b9.mp4', + }, + { + id: 'character-24', + name: 'Character 24', + thumbnail: + 'https://cdn-site-assets.veed.io/24_79dd57fb98/24_79dd57fb98.png', + gender: 'female', + voiceId: 'jqcCZkN6Knx8BJ5TBdYR', + previewUrl: + 'https://cdn-site-assets.veed.io/24_0715d7fbd6/24_0715d7fbd6.mp4', + }, +] as const; diff --git a/veed/fabric-routes.ts b/veed/fabric-routes.ts new file mode 100644 index 0000000..f96a9c3 --- /dev/null +++ b/veed/fabric-routes.ts @@ -0,0 +1,201 @@ +// The raw VEED public REST routes that Fabric generation needs (www.veed.io/api/v1) — one thin, +// typed wrapper per route and nothing else. fabric.ts composes these into the operations the rest of +// the code calls; keeping the two apart is what makes the composite chain readable. +// +// HTTP is injected (VeedHttp, shared with the transcription client) so the orchestration is testable +// offline. Response envelopes are normalised by unwrap(). +import { randomUUID } from 'node:crypto'; +import { type VeedHttp, unwrap } from './api.ts'; + +export const FABRIC_MODEL = 'veed/fabric-one-lipsync'; + +export interface Space { + id: string; +} + +export interface Project { + id: string; +} + +export interface Voice { + id: string; + name: string; + locale: string; + localeLabel?: string; + gender: 'Female' | 'Male' | 'Neutral'; + provider?: string; + description?: string; + previewAudioUrl: string; +} + +export interface SpeechGeneration { + id: string; + status: 'pending' | 'active' | 'error' | string; + errorReason?: string; + duration?: number; +} + +export interface Generation { + // Normalised: the route's 'queued' reads as pending, and 'error'/'timedOut' both read as failed. + status: 'pending' | 'processing' | 'done' | 'failed' | string; + assetId?: string; + errorMessage?: string; + errorCode?: string; +} + +export async function getDefaultSpace(http: VeedHttp, workspaceId: string): Promise { + const space = unwrap(await http.getJson(`/workspace/${workspaceId}/space/default`)); + return space.id; +} + +export async function createProject( + http: VeedHttp, + args: { name: string; workspaceId: string; spaceId: string }, +): Promise { + // folderId and spaceId both carry the space; the route treats them as separate optional fields + // rather than aliases, so both are sent. + // + // privacy is lowercase ('public' | 'private') — an uppercase spelling is a 400. We choose + // 'private': this client downloads the finished mp4 and never needs a shareable link, so there is + // no reason to make a user's project world-readable. + const project = unwrap( + await http.postJson('/project', { + name: args.name, + workspaceId: args.workspaceId, + spaceId: args.spaceId, + folderId: args.spaceId, + privacy: 'private', + }), + ); + return project.id; +} + +// Server-side fetch of a public URL into an asset: VEED pulls the image itself, so the bytes never +// pass through this process. +export async function transloadAsset( + http: VeedHttp, + args: { + sourceUrl: string; + extension: string; + assetType: 'IMAGE' | 'AUDIO' | 'VIDEO'; + workspaceId: string; + projectId: string; + }, +): Promise { + const res = unwrap<{ id?: string; asset?: { id: string } }>( + await http.postJson('/asset/transload', { + sourceUrl: args.sourceUrl, + extension: args.extension, + // Idempotency key for the transload job; a fresh one per call. + taskId: randomUUID(), + workspaceId: args.workspaceId, + projectId: args.projectId, + assetType: args.assetType, + requestedProcesses: [], + }), + ); + const id = res.asset?.id ?? res.id; + if (!id) throw new Error('VEED: transload returned no asset id'); + return id; +} + +// gender arrives from the route as a numeric string. +const VOICE_GENDER: Record = { '0': 'Female', '1': 'Male', '2': 'Neutral' }; + +export async function listVoices( + http: VeedHttp, + filters: { locale?: string; gender?: Voice['gender'] } = {}, + origin = 'https://www.veed.io', +): Promise { + const raw = unwrap>>( + await http.getJson('/subtitles/synthesize/listVoices'), + ); + let voices = raw.map((v) => ({ + id: String(v.id), + name: String(v.name), + locale: String(v.locale), + localeLabel: v.localeLabel as string | undefined, + gender: VOICE_GENDER[String(v.gender)] ?? 'Neutral', + provider: v.provider as string | undefined, + description: v.description as string | undefined, + // The route returns no preview URL, so it is constructed from the voice and locale. + previewAudioUrl: + `${origin}/api/v1/subtitles/synthesize/preview` + + `?voice=${encodeURIComponent(String(v.id))}&rate=1&locale=${encodeURIComponent(String(v.locale))}`, + })); + // Both filters are client-side — the route offers none. + if (filters.locale) { + const want = filters.locale.toLowerCase(); + voices = voices.filter((v) => v.locale.toLowerCase().startsWith(want)); + } + if (filters.gender) voices = voices.filter((v) => v.gender === filters.gender); + return voices; +} + +// Hands the signed upload URL of an EMPTY audio asset to the TTS route, which renders the mp3 into +// it server-side. This client never uploads audio bytes. +export async function synthesizeSpeech( + http: VeedHttp, + args: { + workspaceId: string; + projectId: string; + assetId: string; + text: string; + voice: string; + uploadUrl: string; + }, +): Promise { + const started = unwrap<{ id: string }>( + await http.postJson('/subtitles/synthesize/generate', { + workspaceId: args.workspaceId, + projectId: args.projectId, + assetId: args.assetId, + text: args.text, + voice: args.voice, + uploadUrl: args.uploadUrl, + }), + ); + return started.id; +} + +export async function getSpeechGeneration(http: VeedHttp, id: string): Promise { + return unwrap(await http.getJson(`/subtitles/synthesize/generate/${id}`)); +} + +export async function createGeneration( + http: VeedHttp, + args: { + prompt: string; + assetId: string; + audioAssetId: string; + projectId: string; + workspaceId: string; + }, +): Promise { + const job = unwrap<{ id: string }>( + await http.postJson('/ai-playground', { + type: 'video', + model: FABRIC_MODEL, + prompt: args.prompt, + assetId: args.assetId, + audioAssetId: args.audioAssetId, + projectId: args.projectId, + workspaceId: args.workspaceId, + }), + ); + return job.id; +} + +export async function getGeneration(http: VeedHttp, jobId: string): Promise { + const job = unwrap<{ + status?: string; + error?: { message?: string; code?: string }; + output?: { assetId?: string }; + }>(await http.getJson(`/ai-playground/${jobId}`)); + + if (job.status === 'error' || job.status === 'timedOut') { + return { status: 'failed', errorMessage: job.error?.message, errorCode: job.error?.code }; + } + const raw = job.status || 'pending'; + return { status: raw === 'queued' ? 'pending' : raw, assetId: job.output?.assetId }; +} diff --git a/veed/fabric.ts b/veed/fabric.ts new file mode 100644 index 0000000..0a2e3a4 --- /dev/null +++ b/veed/fabric.ts @@ -0,0 +1,541 @@ +// Fabric talking-head generation, driven straight through VEED's public REST edge (see +// fabric-routes.ts for the individual routes). Every operation the rest of the code calls lives here, +// with the same names and shapes it has always had — only the transport underneath changed. +// +// THERE IS NO SERVER-SIDE PER-JOB PRICE. Pricing sits behind a service with no public route, and the +// per-event usage list is not readable with a user token, so estimateTotalCredits() below is the figure +// this code reports and it is OURS, not VEED's. Two controls are real, and the gates rest on them: the +// affordability check runs against the REAL workspace balance, and the balance is measured either side +// of the spend. Never let a report call the estimate a VEED quote. +import { type VeedHttp, getAsset, listWorkspaces } from './api.ts'; +import { FABRIC_CHARACTERS } from './fabric-characters.ts'; +import { describeValue } from './args.ts'; +import { + createGeneration, + createProject, + getDefaultSpace, + getGeneration, + getSpeechGeneration, + listVoices as listVoicesRoute, + synthesizeSpeech, + transloadAsset, +} from './fabric-routes.ts'; +import { createUploadableAsset } from './api.ts'; +import { poll } from './poll.ts'; + +export interface FabricCharacter { + id: string; + name: string; + thumbnail: string; + gender: 'male' | 'female'; +} + +export interface FabricVoice { + id: string; + name: string; + locale: string; + localeLabel: string; + gender: string; + previewAudioUrl: string; +} + +export interface FabricVoicePage { + voices: FabricVoice[]; + totalCount: number; + currentPage: number; + totalPages: number; + nextCursor?: string; +} + +export interface FabricWorkspace { + id: string; + name: string; +} + +// Where the speaking still comes from. A preset is a curated URL with a default voice; a URL or a local +// file is the user's own. Nothing downstream distinguishes them — the model takes an image asset id. +export type ImageSource = + | { kind: 'character'; id: string } + | { kind: 'url'; url: string } + | { kind: 'file'; path: string }; + +export interface VideoRequest { + script: string; + voiceId: string; + // A preset id. Ignored when `image` names something else; kept so existing callers are unchanged. + characterId: string; + // The user's own still, when they brought one. + image?: ImageSource; + workspaceId?: string; + // Supplied when the caller has already resolved it, which saves confirmVideo a second listing of + // workspaces it would only read a name out of. Purely a label: it never decides which one is billed. + workspaceName?: string; + // How fast THIS voice speaks, when the caller knows. The estimate is a duration priced per second, so + // the rate is what turns the script into money — see veed/voice-rates.ts. Left off, the module default + // applies, which is only right for a caller that has no rate data at all. + charsPerSecond?: number; +} + +export interface FabricConfirmation extends VideoRequest { + voiceName: string; + characterName: string; + workspaceName: string; + estimatedCredits: number; +} + +export interface FabricJob { + jobId: string; + // Optional because it is cosmetic: a create response has ALREADY charged the workspace by the time it is + // read, so a run must never be failed over a field nothing depends on. + status?: string; + durationSeconds?: number; +} + +export interface FabricStatus { + jobId: string; + status: 'pending' | 'processing' | 'done' | 'failed'; + url: string | null; +} + +// Portrait framing is a property of the character, not a parameter: these ids carry a `_P_` marker in +// their thumbnail path, and character-15 verifiably rendered 480x864. Undocumented — re-check if Fabric +// adds real aspect control. +export const PORTRAIT_CHARACTER_IDS = [ + 'character-1', 'character-6', 'character-11', 'character-14', 'character-15', 'character-17', +] as const; + +// The same `_P_` marker, read off the listing instead of the frozen list above, so a character VEED +// adds tomorrow classifies itself. Query strings are ignored: only the asset path carries the marker. +export function isPortraitThumbnail(thumbnail: string): boolean { + return thumbnail.split(/[?#]/)[0].includes('_P_'); +} + +// The one rule for reading a preset's shape, so the confirmation and anything that proposes a presenter +// cannot answer "what shape is this video?" differently. +export function framingOf(character: FabricCharacter): 'portrait' | 'landscape' { + return isPortraitThumbnail(character.thumbnail) ? 'portrait' : 'landscape'; +} + +// Cost is priced per SECOND, not per character, and generating draws AI Playground credits TWICE — the +// speech is synthesized first, then Fabric One Lipsync generates the clip from it. Both debits land on +// the SAME credit allowance (speech used to bill a separate seconds allowance and no longer does), so +// the estimate a user approves is their SUM. +// +// LIPSYNC credits = floor(videoSeconds x rate), and NOT multiplied by resolution — the multiplier +// VEED computes alongside it applies to its dollar estimate, not to credits. rate = 4. +// SPEECH credits = ceil(audioMinutes) x 2 — two credits per minute of generated audio, rounded UP to +// the whole minute, so a 10-second read and a 59-second read both cost 2. +// +// floor(seconds x 4) reproduces every measured LIPSYNC run exactly across a 20x range: 1.36s->5, +// 1.60s->6, 4.86s->19, 6.00s->24, 29.56s->118. It is MEASURED rather than read: VEED's own configuration +// carries several different figures for this model, and none is what the workspace was actually charged. +// Take the formula from the service, the rate from the charges. +// +// The speech rate is the product's stated billing rule, not yet corroborated against a charge in +// isolation — the two debits share one allowance, so a balance delta cannot separate them. The full +// derivation, with the file and symbol each rule comes from, is kept internally — service names cannot +// appear in this file, which is published. Re-derive there if an estimate stops matching what runs are +// charged; veed/generate.ts prints the measured rate on every paid run so a drift shows. +// +// We hold only the SCRIPT before the spend, so duration is estimated from its length and then priced. +// Keeping the rates apart matters: the speaking rate is a property of the VOICE and will move, while the +// prices are VEED's and will move independently. Measured: 18.0 chars/second. +export const CHARS_PER_SECOND = 18; +export const CREDITS_PER_SECOND = 4; +export const TTS_CREDITS_PER_MINUTE = 2; + +// The LIPSYNC credits alone — the Fabric One Lipsync generation, priced from the video duration. Kept +// apart from the speech charge so each rate stays correctable on its own evidence; anything quoting a +// TOTAL to a user wants estimateTotalCredits. The rate is the caller's to supply, because it belongs to +// the VOICE — see veed/voice-rates.ts. +export function estimateCredits(script: string, charsPerSecond: number = CHARS_PER_SECOND): number { + return Math.floor((script.length / charsPerSecond) * CREDITS_PER_SECOND); +} + +// Seconds of generated audio a script will draw, at a given rate. What the speech charge is priced from. +export function estimateSpeechSeconds(script: string, charsPerSecond: number = CHARS_PER_SECOND): number { + return Math.ceil(script.length / charsPerSecond); +} + +// The SPEECH credits — the text-to-speech synthesis, now billed from AI Playground credits rather than a +// separate seconds allowance. Two credits per minute of audio, rounded UP to the whole minute, so any +// read up to a minute costs 2. Priced from the SAME inferred duration the lipsync charge uses. +export function estimateSpeechCredits(script: string, charsPerSecond: number = CHARS_PER_SECOND): number { + return Math.ceil(estimateSpeechSeconds(script, charsPerSecond) / 60) * TTS_CREDITS_PER_MINUTE; +} + +// The whole AI-credit cost of one clip: the lipsync generation PLUS the speech synthesis, both drawn +// from the one credit allowance. This is the figure a user approves and is charged. +export function estimateTotalCredits(script: string, charsPerSecond: number = CHARS_PER_SECOND): number { + return estimateCredits(script, charsPerSecond) + estimateSpeechCredits(script, charsPerSecond); +} + +// What a script could cost across a spread of speaking rates, speech included. Quoted whenever the voice +// has never been measured, because one number there would be a guess wearing the clothes of a +// measurement. Slower speech makes a longer video, so the SLOW rate produces the HIGH figure. +export function estimateRange(script: string, rates: { slow: number; fast: number }): { low: number; high: number } { + return { + low: estimateTotalCredits(script, rates.fast), + high: estimateTotalCredits(script, rates.slow), + }; +} + +// The character list is a compiled-in constant on VEED's side too — listing characters there is a filter +// over the same hardcoded array, with no request behind it. See fabric-characters.ts. +export async function listCharacters(_http: VeedHttp, gender?: 'male' | 'female'): Promise { + const all = FABRIC_CHARACTERS.map((c) => ({ id: c.id, name: c.name, thumbnail: c.thumbnail, gender: c.gender })); + return gender ? all.filter((c) => c.gender === gender) : all; +} + +// The route returns every voice in one response, so the page shape is preserved by slicing locally — +// callers that page through a cursor keep working, and one that ignores it now sees everything at once. +const VOICE_PAGE_SIZE = 50; + +export async function listVoices( + http: VeedHttp, + opts: { locale: string; gender?: 'Female' | 'Male' | 'Neutral'; cursor?: string }, +): Promise { + const all = await listVoicesRoute(http, { locale: opts.locale, gender: opts.gender }); + const voices: FabricVoice[] = all.map((v) => ({ + id: v.id, + name: v.name, + locale: v.locale, + localeLabel: v.localeLabel ?? v.locale, + gender: v.gender, + previewAudioUrl: v.previewAudioUrl, + })); + // A cursor is the zero-based offset of the page to return; anything unparseable starts at the top + // rather than throwing, because a bad cursor must not cost a caller its listing. + const offset = Number.parseInt(opts.cursor ?? '0', 10); + const start = Number.isFinite(offset) && offset > 0 ? offset : 0; + const page = voices.slice(start, start + VOICE_PAGE_SIZE); + const next = start + VOICE_PAGE_SIZE; + return { + voices: page, + totalCount: voices.length, + currentPage: Math.floor(start / VOICE_PAGE_SIZE) + 1, + totalPages: Math.max(1, Math.ceil(voices.length / VOICE_PAGE_SIZE)), + ...(next < voices.length ? { nextCursor: String(next) } : {}), + }; +} + +// Named apart from api.ts's listWorkspaces on purpose: a different shape, and the two are one import +// away from colliding in a file that touches both flows. +export async function listFabricWorkspaces(http: VeedHttp): Promise { + const workspaces = await listWorkspaces(http); + return workspaces.map((w) => ({ id: w.id, name: w.name ?? w.id })); +} + +// A workspace's AI Playground credit balance — the one allowance a generation now draws on. Read before +// a spend to check affordability, and either side of it to observe what moved. +export { getAllowances, type Allowances } from './allowances.ts'; + +// --- what comes back from VEED is a claim, not a type --- +// +// The server is a separate system that can rename, drop or re-type a field without this repo hearing about +// it. That is survivable for a listing and fatal for a spend, so the shapes that gate money are proved +// here rather than in generate.ts, and every caller inherits the check. + +function badResponse(operation: string, detail: string): never { + throw new Error( + `${operation} answered with a payload that cannot be trusted: ${detail}. Nothing spent.\n` + + 'This is the server changing shape under us, not a mistake in the request — retry, and if it persists ' + + 'the contract has moved.', + ); +} + +// Resolves the workspace a request bills, by the same rule as before: an explicit id wins, a single +// workspace is taken, and several without a choice is refused rather than guessed at. +async function resolveWorkspace(http: VeedHttp, req: VideoRequest): Promise { + const workspaces = await listFabricWorkspaces(http); + if (workspaces.length === 0) throw new Error('no VEED workspaces on this account'); + if (req.workspaceId) { + // An id the caller named is taken as given. generate.ts owns the choice of workspace — it lists them, + // shows the balances and makes the user name one — so re-deciding here would only mean a second, + // stricter opinion about a question already answered. An id that is genuinely wrong fails at the + // first scoped call, with the server's own message. + const named = workspaces.find((w) => w.id === req.workspaceId); + return named ?? { id: req.workspaceId, name: req.workspaceId }; + } + if (workspaces.length > 1) { + throw new Error('several workspaces on this account — name the one to bill rather than letting it be guessed'); + } + return workspaces[0]; +} + +// The image a request will speak from, defaulting to the preset when none was brought. Kept in one place +// so confirmVideo and createVideo can never disagree about what is about to be generated. +export function imageSourceOf(req: VideoRequest): ImageSource { + return req.image ?? { kind: 'character', id: req.characterId }; +} + +// What the approval block calls the presenter. A preset has a name; the user's own still is named by +// where it came from, because there is nothing else honest to call it. +function describePresenter(operation: string, req: VideoRequest): string { + const source = imageSourceOf(req); + if (source.kind === 'character') { + const character = FABRIC_CHARACTERS.find((c) => c.id === source.id); + if (!character) badResponse(operation, `characterId ${describeValue(source.id)} is not a known character`); + return character.name; + } + if (source.kind === 'url') return `your image (${source.url})`; + return `your image (${source.path})`; +} + +// Turns whichever source into an image asset id. A URL is fetched by VEED itself; a local file is the +// only case where bytes leave this machine, and it waits for the upload to finalize before the id is +// used — an asset that is still UPLOADING would fail the generation after the money had moved. +async function resolveImageAsset( + http: VeedHttp, deps: FabricDeps, req: VideoRequest, workspaceId: string, projectId: string, +): Promise { + const source = imageSourceOf(req); + if (source.kind === 'character') { + const character = FABRIC_CHARACTERS.find((c) => c.id === source.id)!; + return transloadAsset(http, { + sourceUrl: character.thumbnail, + // 'png' for every preset regardless of the thumbnail's real suffix — the shape known to work end + // to end. Do not "fix" it to match the URL without re-testing a generation. + extension: 'png', + assetType: 'IMAGE', + workspaceId, + projectId, + }); + } + if (source.kind === 'url') { + return transloadAsset(http, { + sourceUrl: source.url, + extension: extensionOf(source.url), + assetType: 'IMAGE', + workspaceId, + projectId, + }); + } + + if (!deps.readFileBytes) { + throw new Error('a local --image needs a file reader; pass readFileBytes to generate a video from one'); + } + const extension = extensionOf(source.path); + const mimeType = extension === 'png' ? 'image/png' : extension === 'webp' ? 'image/webp' : 'image/jpeg'; + const bytes = await deps.readFileBytes(source.path); + const uploadable = await createUploadableAsset(http, { + assetType: 'IMAGE', group: 'image', mimeType, extension, workspaceId, projectId, + }); + await http.putBytes(uploadable.url, bytes, mimeType); + // GET /asset/:id 404s (null) until the upload finalizes, so null means "keep waiting". + await poll({ + fetch: () => getAsset(http, uploadable.asset.id), + decide: (a) => (a === null ? 'wait' : a.uploadState === 'FAILED' ? 'failed' : a.uploadState === 'UPLOADED' ? 'done' : 'wait'), + label: 'image upload', + failMessage: (a) => `VEED: image upload failed (state=${a?.uploadState})`, + sleep: deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))), + intervalMs: SPEECH_INTERVAL_MS, + maxAttempts: SPEECH_MAX_ATTEMPTS, + }); + return uploadable.asset.id; +} + +// Lowercase, no dot, query strings ignored. Defaults to jpg rather than guessing wrong on a bare URL. +function extensionOf(ref: string): string { + const path = ref.split(/[?#]/)[0]; + const dot = path.lastIndexOf('.'); + const ext = dot === -1 ? '' : path.slice(dot + 1).toLowerCase(); + return /^[a-z0-9]{2,5}$/.test(ext) ? ext : 'jpg'; +} + +// Everything createVideo needs from the outside world. Optional so the preset path, which touches no +// local file, keeps working with no deps at all. +export interface FabricDeps { + readFileBytes?: (path: string) => Promise; + sleep?: (ms: number) => Promise; +} + +// Spends nothing: it resolves the names the user needs to see and prices the script locally. The figure +// is OUR estimate — there is no per-job quote to ask for — so callers must not present it as VEED's. +export async function confirmVideo(http: VeedHttp, req: VideoRequest): Promise { + const operation = 'confirm'; + if (!req.script.trim()) badResponse(operation, 'the script is empty, so there is nothing to price'); + + const presenter = describePresenter(operation, req); + + const workspace = req.workspaceId && req.workspaceName + ? { id: req.workspaceId, name: req.workspaceName } + : await resolveWorkspace(http, req); + // A voice id that does not exist would fail at synthesis, AFTER the project and assets are made, so it + // is checked here while nothing has been spent. + const voices = await listVoicesRoute(http, {}); + const voice = voices.find((v) => v.id === req.voiceId); + if (!voice) badResponse(operation, `voiceId ${describeValue(req.voiceId)} is not a known voice`); + + const credits = estimateTotalCredits(req.script, req.charsPerSecond ?? CHARS_PER_SECOND); + if (!Number.isFinite(credits) || credits < 0) { + badResponse(operation, `the estimate came out as ${describeValue(credits)}, not a finite credit figure`); + } + return { + ...req, + workspaceId: workspace.id, + voiceName: voice.name, + characterName: presenter, + workspaceName: workspace.name, + estimatedCredits: credits, + }; +} + +const SPEECH_INTERVAL_MS = 1000; +const SPEECH_MAX_ATTEMPTS = 60; + +// THIS IS THE CALL THAT SPENDS. It is several requests rather than one, and the money moves partway +// through: synthesis bills first, the generation bills second. So everything before it must already be +// recoverable — veed/generate.ts writes its charge record before calling this, which is exactly right. +export async function createVideo(http: VeedHttp, req: VideoRequest, deps: FabricDeps = {}): Promise { + const operation = 'createVideo'; + describePresenter(operation, req); + // Only the id is needed here — the NAME is a confirmation concern — so a workspace the caller already + // named costs no request. That matters: generate.ts reads the balance immediately before this call, and + // an extra round trip in between would put time, and any other billing, inside the measurement. + const workspaceId = req.workspaceId ?? (await resolveWorkspace(http, req)).id; + + const spaceId = await getDefaultSpace(http, workspaceId); + const projectId = await createProject(http, { + name: `${req.script.slice(0, 40)}...`, + workspaceId, + spaceId, + }); + + const imageAssetId = await resolveImageAsset(http, deps, req, workspaceId, projectId); + + // An EMPTY audio asset plus a signed URL: the TTS route renders the mp3 into that URL server-side, so + // no audio bytes are uploaded from here either. + const tts = await createUploadableAsset(http, { + assetType: 'AUDIO', + group: 'TTS', + assetSubType: 'TTS', + mimeType: 'audio/mpeg', + extension: 'mp3', + storage: 'PERMANENT', + workspaceId, + projectId, + }); + + const speechId = await synthesizeSpeech(http, { + workspaceId, + projectId, + assetId: tts.asset.id, + text: req.script, + voice: req.voiceId, + uploadUrl: tts.url, + }); + const speech = await poll({ + fetch: () => getSpeechGeneration(http, speechId), + decide: (s) => (s.status === 'error' ? 'failed' : s.status === 'active' ? 'done' : 'wait'), + label: 'speech synthesis', + failMessage: (s) => + s.errorReason === 'outOfCredits' + ? 'VEED: this workspace is out of AI Playground credits. Nothing further was spent.' + : `VEED: speech synthesis failed (${s.errorReason ?? 'unknown'})`, + sleep: (ms) => new Promise((r) => setTimeout(r, ms)), + intervalMs: SPEECH_INTERVAL_MS, + maxAttempts: SPEECH_MAX_ATTEMPTS, + }); + + const jobId = await createGeneration(http, { + prompt: req.script, + assetId: imageAssetId, + audioAssetId: tts.asset.id, + projectId, + workspaceId, + }); + if (typeof jobId !== 'string' || jobId === '') { + badResponse(operation, `jobId is ${describeValue(jobId)}, not the id this run needs to collect the video`); + } + const duration = speech.duration; + return { + jobId, + status: 'pending', + durationSeconds: typeof duration === 'number' && Number.isFinite(duration) ? duration : undefined, + }; +} + +export async function getStatus(http: VeedHttp, jobId: string): Promise { + const job = await getGeneration(http, jobId); + const status: FabricStatus['status'] = + job.status === 'failed' ? 'failed' + : job.status === 'done' ? 'done' + : job.status === 'processing' ? 'processing' : 'pending'; + // Only a finished job has an output asset to resolve, and the download URL lives on the asset rather + // than on the job. + if (status !== 'done' || !job.assetId) return { jobId, status, url: null }; + const asset = await getAsset(http, job.assetId); + return { jobId, status, url: asset?.sourceUrl || asset?.cdnUrl || null }; +} + +export interface AwaitVideoDeps { + sleep: (ms: number) => Promise; + onProgress?: (attempt: number, status: string) => void; + // Defaults to the real clock so the deadline below always applies; tests inject a fake one. + now?: () => number; +} + +// Observed: a 12.7s clip took ~5 minutes, so the interval widens rather than hammering: 5s, 15s, then 30s. +const POLL_SCHEDULE_MS = [5_000, 15_000]; +const POLL_STEADY_MS = 30_000; +// Exported because the charge lease is derived from it: a lease shorter than this deadline would declare a +// healthy, still-polling run dead and let a second charge through. +export const POLL_DEADLINE_MS = 15 * 60_000; +// By the time this polls, createVideo has already charged the workspace, so a transport blip (a 502, a +// timeout) must NOT throw the paid job away. Tolerate three CONSECUTIVE failures and reset on any answer; +// a run of four is a real outage, and the job id is on disk by then (see veed/generate.ts) so `--resume` +// can still collect the video without paying again. +const MAX_CONSECUTIVE_POLL_FAILURES = 3; + +// The server's verdict, told apart from a poll that merely gave up. The two are opposite outcomes for money +// already spent — a failed job is unrecoverable and frees the run to take a fresh approval, a timeout may +// still be finishing server-side and must stay resumable — and the caller cannot tell them apart from a +// message. +export class FabricJobFailedError extends Error { + readonly jobId: string; + constructor(jobId: string) { + super(`Fabric job ${jobId} failed`); + this.name = 'FabricJobFailedError'; + this.jobId = jobId; + } +} + +export async function awaitVideo(http: VeedHttp, jobId: string, deps: AwaitVideoDeps): Promise { + const now = deps.now ?? Date.now; + const started = now(); + let consecutiveFailures = 0; + + for (let attempt = 0; ; attempt++) { + let status: FabricStatus | null = null; + try { + status = await getStatus(http, jobId); + consecutiveFailures = 0; + } catch (e) { + consecutiveFailures += 1; + const detail = e instanceof Error ? e.message : String(e); + if (consecutiveFailures > MAX_CONSECUTIVE_POLL_FAILURES) { + throw new Error( + `Fabric job ${jobId}: status check failed ${consecutiveFailures} times in a row (${detail}). ` + + 'The job is already paid for and may still be running — retry with --resume rather than regenerating.', + ); + } + deps.onProgress?.(attempt, `status check failed (${consecutiveFailures}/${MAX_CONSECUTIVE_POLL_FAILURES}), retrying: ${detail}`); + } + + // Only a real answer is terminal: `failed` is the server's verdict, not a blip, so it aborts at once + // instead of burning the retries above. + if (status) { + deps.onProgress?.(attempt, status.status); + if (status.status === 'done') { + if (!status.url) throw new Error(`Fabric job ${jobId} reported done with no url`); + return status.url; + } + if (status.status === 'failed') throw new FabricJobFailedError(jobId); + } + + if (now() - started >= POLL_DEADLINE_MS) { + throw new Error(`Fabric job ${jobId} timed out after ${Math.round(POLL_DEADLINE_MS / 60_000)} minutes (last status: ${status?.status ?? 'unavailable'})`); + } + await deps.sleep(POLL_SCHEDULE_MS[attempt] ?? POLL_STEADY_MS); + } +} diff --git a/veed/generate-set.ts b/veed/generate-set.ts new file mode 100644 index 0000000..6f76c73 --- /dev/null +++ b/veed/generate-set.ts @@ -0,0 +1,285 @@ +// One approval covering SEVERAL shots. +// +// node --import tsx veed/generate-set.ts --shots shots.json --key poem --workspace +// node --import tsx veed/generate-set.ts --key poem --yes +// +// A set is a layer over the single-shot engine, not a replacement for it. Every guarantee generate.ts +// already provides is per-KEY — the charge record that survives a create which timed out, --resume, +// --abandon, the refusal to buy the same script twice — so each shot runs under its own derived key +// (`poem-1`, `poem-2`, …) and keeps all of them, unchanged and independent. A shot that fails leaves the +// shots already paid for alone. +// +// What the set adds is the ONE thing a user should not have to do N times: approve. The set approval is +// the authorisation; the per-shot approvals it writes are its expansion, not a second question. That is +// why the set is hashed as a whole — reorder the shots, edit one line, swap one image, and the approval +// no longer covers what is about to be bought. +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { estimateRange, estimateTotalCredits } from './fabric.ts'; +import { + assertNonEmptyScript, type GenerateDeps, type PendingConfirmation, pendingPathFor, run, runDirFor, + scriptHash, +} from './generate.ts'; +import { + DEFAULT_VOICE_RATES_PATH, parseVoiceRates, rateRange, resolveRate, SEEDED_RATES, type VoiceRates, +} from './voice-rates.ts'; + +export interface Shot { + // Names the run directory for this shot, so a failure points at something a person can act on. + id: string; + script: string; + character?: string; + image?: string; + voice?: string; +} + +export interface SetApproval { + shots: Shot[]; + // Over the WHOLE set: order included, because two shots swapped is a different video. + setSha256: string; + workspaceId: string; + workspaceNamed: boolean; + estimatedCredits: number; + approvedAt: number; +} + +export function setPathFor(key: string): string { + return join(runDirFor(key), '.fabric-pending-set.json'); +} + +// The key a shot runs under. Derived, never taken from input, so a shot id cannot escape the run dir. +export function shotKey(setKey: string, id: string): string { + return `${setKey}-${id}`; +} + +export function hashSet(shots: Shot[]): string { + const canonical = JSON.stringify( + shots.map((s) => ({ id: s.id, script: s.script, character: s.character ?? null, image: s.image ?? null, voice: s.voice ?? null })), + ); + return createHash('sha256').update(canonical).digest('hex'); +} + +export function parseShots(raw: string): Shot[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new Error(`the shots file is not valid JSON: ${e instanceof Error ? e.message : e}`); + } + if (!Array.isArray(parsed) || parsed.length === 0) throw new Error('the shots file must be a non-empty array'); + + const seen = new Set(); + return parsed.map((entry, i) => { + const s = entry as Partial; + const id = s.id ?? String(i + 1); + if (!/^[A-Za-z0-9._-]+$/.test(id)) throw new Error(`shot ${i + 1}: id "${id}" must be letters, digits, dot, dash or underscore`); + if (seen.has(id)) throw new Error(`shot ${i + 1}: duplicate id "${id}" — ids name run directories, so they must differ`); + seen.add(id); + if (s.character && s.image) throw new Error(`shot ${id}: give a character or an image, not both`); + return { + id, + script: assertNonEmptyScript(String(s.script ?? '')), + ...(s.character ? { character: s.character } : {}), + ...(s.image ? { image: s.image } : {}), + ...(s.voice ? { voice: s.voice } : {}), + }; + }); +} + +// Priced shot by shot at each shot's own voice rate, then summed — a set can mix a measured voice with an +// unmeasured one, and averaging them would quote a figure that is right for neither. `low`/`high` are the +// spread across known speaking rates, carried so the approval can say how firm the total is. +export function estimateSet( + shots: Shot[], + defaults: { voice: string } = { voice: '' }, + rates: VoiceRates = SEEDED_RATES, +): { credits: number; low: number; high: number; allMeasured: boolean } { + const spread = rateRange(rates); + let credits = 0; + let low = 0; + let high = 0; + let allMeasured = true; + for (const shot of shots) { + const { charsPerSecond, samples } = resolveRate(shot.voice ?? defaults.voice, rates); + if (samples === null) allMeasured = false; + // Each shot is a separate generation, so its speech rounds up to its own whole minute — priced per + // shot and summed, never as one pooled duration. + credits += estimateTotalCredits(shot.script, charsPerSecond); + const range = estimateRange(shot.script, spread); + low += range.low; + high += range.high; + } + return { credits, low, high, allMeasured }; +} + +// Every way a stored set approval can fail to cover what is about to be spent. Each is a refusal, never +// a warning: the whole point of the record is that nobody is billed for something they did not see. +export function assertCovers(approval: SetApproval, shots: Shot[], nowMs: number, maxAgeMs: number): void { + if (hashSet(shots) !== approval.setSha256) { + throw new Error( + 'refusing to spend: the shots no longer match the set that was approved — a script, an image, an ' + + 'order or a voice has changed. Nothing spent. Re-run the confirm pass and approve the new figure.', + ); + } + // Guard the timestamp before doing arithmetic on it — a missing/non-finite approvedAt makes `age` NaN, + // and the age-based message would otherwise read "NaN minutes old", which names no actionable reason. + if (!Number.isFinite(approval.approvedAt)) { + throw new Error( + 'refusing to spend: the approval has no valid timestamp — the pending set record is corrupt. ' + + 'Nothing spent. Re-run the confirm pass and approve a fresh figure.', + ); + } + const age = nowMs - approval.approvedAt; + if (age < 0 || age > maxAgeMs) { + throw new Error( + `refusing to spend: the approval is ${Math.round(age / 60_000)} minutes old. Nothing spent. ` + + 'Re-run the confirm pass and approve a fresh figure.', + ); + } +} + +// Expands the ONE approval into the per-shot approval each run consumes. These files are not a second +// question — they are what the answer already given means, written where the single-shot engine reads it. +export function pendingForShot( + approval: SetApproval, + shot: Shot, + defaults: { character: string; voice: string }, + rates: VoiceRates = SEEDED_RATES, +): PendingConfirmation { + const voiceId = shot.voice ?? defaults.voice; + // The SAME rate the single-shot engine will re-derive when it re-confirms this pending. Priced any + // other way, its "you approved N but the quote is now M" guard fires on every shot of every set. + const { charsPerSecond } = resolveRate(voiceId, rates); + return { + script: shot.script, + scriptSha256: scriptHash(shot.script), + characterId: shot.character ?? defaults.character, + ...(shot.image ? { image: shot.image } : {}), + voiceId, + workspaceId: approval.workspaceId, + workspaceNamed: approval.workspaceNamed, + estimatedCredits: estimateTotalCredits(shot.script, charsPerSecond), + approvedAt: approval.approvedAt, + }; +} + +export interface SetResult { + status: 'confirmed' | 'generated' | 'no-token' | 'workspace-required'; + credits?: number; + clips?: string[]; +} + +export async function runSet( + args: { key: string; shots?: Shot[]; workspace?: string; yes: boolean; character: string; voice: string }, + deps: GenerateDeps, + maxAgeMs = 60 * 60_000, +): Promise { + const log = deps.log ?? ((m: string) => console.log(m)); + // Both default the same way generate.ts does, so a set and a single shot agree about time. + const now = deps.now ?? Date.now; + const setPath = setPathFor(args.key); + // The same learned rates the single-shot engine reads, so a set and a shot quote the same script alike. + const rates = parseVoiceRates(await deps.readState(DEFAULT_VOICE_RATES_PATH)); + + if (!args.yes) { + if (!args.shots) throw new Error('the confirm pass needs --shots '); + const shots = args.shots; + const { credits, low, high, allMeasured } = estimateSet(shots, { voice: args.voice }, rates); + + // One block, one figure, one yes. The per-shot lines are there so nobody approves a total without + // seeing what makes it up. + log( + [ + `[fabric] ${shots.length} shots in one approval`, + ...shots.map((s) => { + const who = s.image ? `your image (${s.image})` : (s.character ?? args.character); + const { charsPerSecond, samples } = resolveRate(s.voice ?? args.voice, rates); + const basis = samples === null ? 'estimated rate' : `${samples} run${samples === 1 ? '' : 's'} of this voice`; + return ` ${s.id.padEnd(10)} ~${estimateTotalCredits(s.script, charsPerSecond)} credits · ${s.script.length} chars · ${who} · ${basis}`; + }), + ` ${'TOTAL'.padEnd(10)} ~${credits} AI Playground credits`, + // Cost follows the finished DURATION, and speaking rate decides that. Where a voice has never been + // measured the total is a lean-slow guess, so the spread it could land in is stated rather than + // implied by a tilde. + ...(allMeasured + ? [] + : [` ${''.padEnd(10)} some voices here have never been measured — expect ${low}-${high} credits`]), + ].join('\n'), + ); + + // Written only after the block is printed: an approval that exists before the user has seen what it + // covers is an approval for nothing. + const approval: SetApproval = { + shots, + setSha256: hashSet(shots), + workspaceId: args.workspace ?? '', + workspaceNamed: Boolean(args.workspace), + estimatedCredits: credits, + approvedAt: now(), + }; + await deps.writeState(setPath, `${JSON.stringify(approval, null, 2)}\n`); + log(`\nNothing spent. To approve ${credits} credits and generate all ${shots.length}, run exactly:\n\n node --import tsx veed/generate-set.ts --key ${args.key} --yes\n`); + return { status: 'confirmed', credits }; + } + + const raw = await deps.readState(setPath); + if (raw === null) { + throw new Error( + `no approved set for --key ${args.key}. Run the confirm pass first (it spends nothing):\n` + + ` node --import tsx veed/generate-set.ts --shots --key ${args.key}`, + ); + } + const approval = JSON.parse(raw) as SetApproval; + assertCovers(approval, approval.shots, now(), maxAgeMs); + + const clips: string[] = []; + for (const shot of approval.shots) { + const key = shotKey(args.key, shot.id); + // The set approval, expanded where the single-shot engine looks for it. Everything below this line + // is the existing per-shot path, with every guarantee it already had. + await deps.writeState( + pendingPathFor(key), + `${JSON.stringify(pendingForShot(approval, shot, { character: args.character, voice: args.voice }, rates), null, 2)}\n`, + ); + log(`[fabric] shot ${shot.id} →`); + const result = await run( + { key, character: args.character, voice: args.voice, workspace: approval.workspaceId, yes: true, resume: false }, + deps, + ); + if (result.status !== 'generated') return { status: result.status as SetResult['status'], clips }; + clips.push(join(runDirFor(key), `${key}.mp4`)); + } + + // Spent: the approval is gone, so one yes buys one set — the same rule a single shot follows. + await deps.removeState(setPath); + log(`\n[fabric] ${clips.length} clips generated. Join them with:\n node --import tsx pipeline/scripts/concat-videos.ts ${join(runDirFor(args.key), `${args.key}.mp4`)} ${clips.join(' ')}`); + return { status: 'generated', clips }; +} + +async function main(): Promise { + const { parseArgs, realDeps } = await import('./generate.ts'); + const argv = process.argv.slice(2); + const at = argv.indexOf('--shots'); + if (at !== -1 && (at + 1 >= argv.length || argv[at + 1].startsWith('--'))) { + throw new Error('flag --shots requires a value: the path to a shots JSON file'); + } + const shotsFile = at === -1 ? undefined : argv[at + 1]; + // --shots is handled here and removed; everything left is handed to the single-shot parser, which is + // strict and will name any other unknown flag. + const rest = at === -1 ? argv : [...argv.slice(0, at), ...argv.slice(at + 2)]; + // parseArgs wants a script on a confirm pass and REFUSES one alongside --yes; a set keeps its + // scripts in the shots file, so the placeholder is supplied only where the parser insists on it. + const spending = rest.includes('--yes'); + const args = parseArgs(spending || rest.includes('--script') ? rest : [...rest, '--script', 'set']); + const shots = shotsFile ? parseShots(await readFile(shotsFile, 'utf8')) : undefined; + const result = await runSet({ ...args, shots }, realDeps()); + if (result.status === 'workspace-required' || result.status === 'no-token') process.exit(1); +} + +if (process.argv[1]?.endsWith('generate-set.ts')) { + main().catch((e: unknown) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); + }); +} diff --git a/veed/generate.ts b/veed/generate.ts new file mode 100644 index 0000000..4a52c99 --- /dev/null +++ b/veed/generate.ts @@ -0,0 +1,1141 @@ +// Generate source footage with VEED Fabric when the user has no video to caption. +// +// 1. Log in once: node --import tsx veed/login.ts (same token as transcription — no MCP connector) +// 2. Confirm cost: node --import tsx veed/generate.ts --script "..." --key my-run --workspace ws-id +// 3. Spend + make: node --import tsx veed/generate.ts --key my-run --yes (NO --script here) +// 4. If 3 broke: node --import tsx veed/generate.ts --key my-run --resume (collects, spends nothing) +// 5. If 3 vanished: node --import tsx veed/generate.ts --key my-run --abandon (clears one record) +// +// Which workspace pays is the user's decision: with no --workspace and no remembered choice the confirm +// pass stops and prints every workspace with its balance. A remembered choice (veed/.veed-workspace.json) is +// not a confirmed one, so the spend pass refuses until the command names the workspace, and refuses a +// different one from the approval. A run that spends reports what it was approved for, plus what the balance +// did, into runs//.fabric-spend-.json. +// +// Confirm and spend are two separate processes. The confirm pass writes the approved script + its SHA-256, +// ids, workspace and quoted credits to runs//.fabric-pending.json; the spend pass spends THAT and takes +// no --script (re-typing it is how billed words drift from priced ones). It refuses if the fresh estimate +// exceeds the approved figure, the recorded script no longer matches its hash, or the approval is over an +// hour old; a spent approval is deleted so one "yes" buys one video. +// +// Nothing takes an exclusive lock, because concurrent runs are normal: every attempt writes its OWN +// runs//.fabric-charge-.json. It is written BEFORE the create call (phase 'charging') and +// updated with the job id after (phase 'charged'), because a create that times out or 502s has still moved +// the money and only a record written first can say so. Before charging, a run stands down if another is +// charging now, is already paid for (--resume collects it), or was orphaned (--abandon clears that one +// record). See veed/charge-records.ts. +// +// Writes runs//.mp4, whose filename must derive back to via runKeyOf (resolve-video.ts, the +// rule go.ts and prep.ts share). The default run only ever CONFIRMS, since generation spends real credits. +// The flow lives in run() with HTTP client, token, download and file write injected (like orchestrate.ts's +// transcribeWithVeed) so the spend gate is testable offline; main() supplies the real ones. +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { hostname } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { REPO_ROOT } from '../config.ts'; +import { runKeyOf } from '../pipeline/scripts/resolve-video.ts'; +import { VEED_ORIGIN } from './api.ts'; +import { assertSafeKey, assertSafeSessionId, describeValue, parseFlags } from './args.ts'; +import { + chargeFileName, classifyChargeRecords, parseChargeRecord, sessionIdOfChargeFile, + type ChargeRecord, type ChargeVerdict, +} from './charge-records.ts'; +import { + awaitVideo, CHARS_PER_SECOND, confirmVideo, createVideo, estimateRange, estimateTotalCredits, + framingOf, FabricJobFailedError, getAllowances, listCharacters, + type Allowances, type ImageSource, +} from './fabric.ts'; +import type { VeedHttp } from './api.ts'; +import { refreshingHttp } from './http.ts'; +import { DEFAULT_TOKEN_PATH, resolveToken } from './token-store.ts'; +import { + conservativeRate, DEFAULT_VOICE_RATES_PATH, parseVoiceRates, rateFor, rateRange, serializeVoiceRates, + withObservation, +} from './voice-rates.ts'; +import { DEFAULT_WORKSPACE_PATH, parseWorkspaceChoice, serializeWorkspaceChoice } from './workspace-store.ts'; +import { + allUnpriced, describeChoice, formatWorkspaceTable, listWorkspacesWithCredits, resolveWorkspace, + type WorkspaceCredits, +} from './workspace.ts'; + +// Defaults so a caller who supplies only --script gets a sensible 9:16 talking head. Character choice is how +// portrait framing is selected (no aspectRatio parameter); character-15 verifiably renders 480x864. +const DEFAULT_CHARACTER = 'character-15'; +const DEFAULT_VOICE = 'kOvUpYLYS0rKGldsKcD1'; // Maeve, English (Ireland) + +// A URL is fetched by VEED; anything else is a path on this machine. Deciding here keeps the rule in one +// place rather than in both passes. +export function imageSourceFrom(ref: string | undefined): ImageSource | undefined { + if (!ref) return undefined; + return /^https?:\/\//i.test(ref) ? { kind: 'url', url: ref } : { kind: 'file', path: ref }; +} + +export interface Args { + // Absent on the spend pass: --yes takes the script from the approval on disk, so priced words are billed words. + script?: string; + key: string; + character: string; + // The user's own still — a URL or a local path — used INSTEAD of a preset character. + image?: string; + voice: string; + workspace?: string; + yes: boolean; + resume: boolean; + // The session whose charge record is to be resolved. Names ONE record, never a whole run's state. + abandon?: string; + // Spend despite an allowance reading that says it cannot cover this (dev/internal accounts misreport). + // Never bypasses the approval itself. + ignoreBalance?: boolean; +} + +// What pass 1 records under runs// and pass 2 is allowed to spend. The hash pins the exact words the +// quote was for; approvedAt bounds how long a quote is honoured. +export interface PendingConfirmation { + script: string; + scriptSha256: string; + characterId: string; + // Present only when the user brought their own still; part of the approval, since swapping it between the + // passes would generate something nobody said yes to. + image?: string; + voiceId: string; + workspaceId: string; + // True only when the user named the workspace on the approving invocation; a cached choice is confirmed + // before it bills, not assumed. + workspaceNamed: boolean; + estimatedCredits: number; + approvedAt: number; +} + +// The audit trail for a run that spent something. Unlike the two files above it is never deleted: it is the +// only durable answer to "what did this cost, and out of whose credits?". +export interface SpendRecord { + jobId: string; + workspaceId: string; + workspaceName: string; + // Our own estimate, and the figure the user approved. VEED quotes no per-job price. + estimatedCredits: number; + // What the credits bought, kept so a paid run doubles as a calibration sample (only the finished video + // says how many seconds a script really took). Optional for records written before this field existed. + scriptChars?: number; + durationSeconds?: number; + // What the quote was built from, so the report can name the gap rather than the reader inferring it. + assumedCharsPerSecond?: number; + // The workspace balance delta, kept ONLY when it corroborates the quote closely enough to be quoted back; + // null when the balance could not be read, did not move, or moved by an amount no single run explains. + chargedCredits: number | null; + // The same delta, unfiltered: a WORKSPACE-level observation (a teammate or the web app moves it too), + // never a claim about this run — it is what a user takes to VEED support when the figures disagree. + observedWorkspaceDelta?: number | null; + // The balance read immediately before the charge, kept so a --resume in a LATER process can still measure + // what the job took: without it an interrupted run can never say more than its estimate. + balanceBefore: number | null; + at: number; +} + +const PENDING_FILE = '.fabric-pending.json'; +const SPEND_PREFIX = '.fabric-spend-'; +const SPEND_SUFFIX = '.json'; +// An approval goes stale after an hour: past that the user has moved on, and server pricing may have moved +// with them. Re-confirming is free; being billed for a quote nobody is still looking at is not. +const PENDING_TTL_MS = 60 * 60_000; +// Runs AFTER the credits are spent, so a stalled CDN connection must not hang forever on paid-for work: +// generous enough for the whole transfer, finite so the process always ends. Same budget as veed/http.ts. +const DOWNLOAD_TIMEOUT_MS = 15 * 60_000; + +export function scriptHash(script: string): string { + return createHash('sha256').update(script, 'utf8').digest('hex'); +} + +export function runDirFor(key: string): string { + return join(REPO_ROOT, 'runs', assertSafeKey(key)); +} + +export function pendingPathFor(key: string): string { + return join(runDirFor(key), PENDING_FILE); +} + +export function chargePathFor(key: string, sessionId: string): string { + return join(runDirFor(key), chargeFileName(assertSafeSessionId(sessionId))); +} + +// Per ATTEMPT, like the charge record: a singleton file let a second run of the same key destroy the first's +// only account of what it cost. --resume knows which session's record it has to complete. +export function spendPathFor(key: string, sessionId: string): string { + return join(runDirFor(key), `${SPEND_PREFIX}${assertSafeSessionId(sessionId)}${SPEND_SUFFIX}`); +} + +// The run-directory guards live in args.ts, shared with every entry point that names one; re-exported +// here because this module's callers and the docs address them through it. +export { assertSafeKey, assertSafeSessionId }; + +const OPTIONS = { + script: { type: 'string' }, + key: { type: 'string' }, + character: { type: 'string' }, + image: { type: 'string' }, + voice: { type: 'string' }, + workspace: { type: 'string' }, + abandon: { type: 'string' }, + yes: { type: 'boolean' }, + resume: { type: 'boolean' }, + // Balance is evidence, not truth (dev/internal accounts misreport), so this downgrades that reading from a + // refusal to a warning. Overrides nothing else — the approval still has to exist, match, and be fresh. + 'ignore-balance': { type: 'boolean' }, +} as const; + +const USAGE = [ + 'usage: node --import tsx veed/generate.ts --script "spoken words" --workspace [--key ] [--character ] [--voice ]', + ' node --import tsx veed/generate.ts --key --yes (spends the cost the confirm pass above quoted)', + ' node --import tsx veed/generate.ts --key --resume (collects a job already created and paid for)', + ' node --import tsx veed/generate.ts --key --abandon (clears one abandoned charge record)', +].join('\n'); + +const SCRIPT_WITH_YES = [ + '--script cannot be combined with --yes.', + '--yes spends the confirmation you already approved, which is recorded under runs//.fabric-pending.json;', + 're-typing the script is exactly how the billed words drift away from the priced ones. Confirm first:', + ' node --import tsx veed/generate.ts --script "spoken words" --key ', + 'then spend what it quoted:', + ' node --import tsx veed/generate.ts --key --yes', +].join('\n'); + +// The four modes are mutually exclusive by construction; checked in parseArgs AND in run(), which is a +// public entry point of its own. +function assertFlagCombination(args: { script?: string; yes: boolean; resume: boolean; abandon?: string }): void { + if (args.yes && args.script !== undefined) throw new Error(SCRIPT_WITH_YES); + if (args.resume && (args.yes || args.script !== undefined)) { + throw new Error( + '--resume takes neither --script nor --yes: it downloads a job that was already created and paid for, ' + + 'and spends nothing.', + ); + } + if (args.abandon !== undefined && (args.yes || args.resume || args.script !== undefined)) { + throw new Error( + '--abandon takes no --script, --yes or --resume: it only resolves one abandoned charge record, and ' + + 'spends nothing.', + ); + } + if (!args.yes && !args.resume && args.abandon === undefined && args.script === undefined) throw new Error(USAGE); +} + +// An unexpanded shell variable (`--script "$SCRIPT"` with `$SCRIPT` unset) parses as `--script ""`, which +// would be quoted for a video with nothing to say and then billed by --yes. Validated in parseArgs like +// --key, not in assertFlagCombination, which only compares flags against each other. +export function assertNonEmptyScript(script: string): string { + if (script.trim() === '') { + throw new Error( + `--script must not be empty or whitespace-only (got ${JSON.stringify(script)}). This is usually a ` + + 'shell variable that failed to expand, e.g. --script "$SCRIPT" with $SCRIPT unset.', + ); + } + return script; +} + +export function parseArgs(argv: string[]): Args { + const result: Partial = { + key: 'generated', + character: DEFAULT_CHARACTER, + voice: DEFAULT_VOICE, + yes: false, + resume: false, + }; + + const { values } = parseFlags({ args: argv, options: OPTIONS }); + + if (values.script !== undefined) result.script = assertNonEmptyScript(values.script); + if (values.key !== undefined) result.key = values.key; + if (values.character !== undefined) result.character = values.character; + if (values.image !== undefined) result.image = values.image; + if (values.voice !== undefined) result.voice = values.voice; + if (values.workspace !== undefined) result.workspace = values.workspace; + if (values.abandon !== undefined) result.abandon = assertSafeSessionId(values.abandon); + result.yes = values.yes ?? false; + result.resume = values.resume ?? false; + result.ignoreBalance = values['ignore-balance'] ?? false; + + assertFlagCombination(result as Args); + assertSafeKey(result.key as string); + + return result as Args; +} + +export interface GenerateDeps { + // A currently-valid access token, or null when there is no usable login. Called again per request, so a + // long poll survives the token expiring mid-run. + resolveAccessToken: () => Promise; + connect: (getToken: () => Promise) => Promise; + download: (url: string) => Promise; + // Only for a user's LOCAL image; injected so the spend path stays testable without touching a disk. + readFileBytes?: (path: string) => Promise; + writeOutput: (path: string, bytes: Uint8Array) => Promise; + // The small JSON state files under runs// (approval, charge records, spend trail); injected so the + // spend gate stays testable without touching the filesystem. + readState: (path: string) => Promise; + writeState: (path: string, text: string) => Promise; + removeState: (path: string) => Promise; + // File names in a run directory (empty when absent): charge records are per-attempt, so finding the OTHER + // attempts means listing the directory rather than reading one known path. + listState: (dir: string) => Promise; + // Who this process is, stamped into the record it writes so another run can ask whether it is still there. + // One process charges at most once, so its session id is also its attempt id. + sessionId: string; + pid: number; + host: string; + // Only meaningful for a pid on THIS host; injected so both branches of the liveness rule are drivable. + isAlive: (pid: number) => boolean; + sleep: (ms: number) => Promise; + now?: () => number; + log?: (message: string) => void; +} + +export type RunResult = + | { status: 'no-token' } + // Nobody has said which workspace to bill, so the run stops having spent nothing and shows the choices. + | { status: 'workspace-required'; workspaces: WorkspaceCredits[] } + | { status: 'confirmed'; credits: number } + | { status: 'generated'; path: string; bytes: number } + | { status: 'abandoned'; sessionId: string }; + +function refusePending(path: string, detail: string): never { + throw new Error( + `refusing to spend: ${path} ${detail}. Nothing spent.\n` + + 'That file is left where it is so it can be inspected; re-run the confirm pass and approve the cost it ' + + 'prints.', + ); +} + +// The approval crossed a process boundary as JSON, so every field is an untrusted claim. Unchecked, a +// missing timestamp reads back as `nowMs - undefined` = NaN and passes BOTH the `> TTL` and `< 0` checks +// silently; refusal names the offending field, because "malformed" is not something a user can act on. +function assertPendingShape(value: unknown, path: string): PendingConfirmation { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + refusePending(path, `holds ${describeValue(value)}, which is not a JSON object`); + } + const record = value as Record; + for (const field of ['script', 'scriptSha256', 'characterId', 'voiceId', 'workspaceId'] as const) { + const held = record[field]; + if (typeof held !== 'string' || held === '') { + refusePending(path, `has ${field} = ${describeValue(held)}, not a non-empty string`); + } + } + if (typeof record.workspaceNamed !== 'boolean') { + refusePending(path, `has workspaceNamed = ${describeValue(record.workspaceNamed)}, not a boolean`); + } + // Finite, because 1e999 is legal JSON that reads back as Infinity: an infinite approved figure caps + // nothing, and an infinite timestamp is never stale. + for (const field of ['estimatedCredits', 'approvedAt'] as const) { + const held = record[field]; + if (typeof held !== 'number' || !Number.isFinite(held)) { + refusePending(path, `has ${field} = ${describeValue(held)}, not a finite number`); + } + } + if ((record.estimatedCredits as number) < 0) { + refusePending(path, `has estimatedCredits = ${record.estimatedCredits}, which is not a cost anyone approved`); + } + return value as PendingConfirmation; +} + +// Reads back what pass 1 approved. Everything here is local: a bad approval is refused before a single +// byte reaches VEED, let alone a charge. +async function loadPending( + deps: GenerateDeps, path: string, key: string, nowMs: number, +): Promise { + const raw = await deps.readState(path); + if (raw === null) { + throw new Error( + `no approved confirmation for --key ${key}. Run the confirm pass first (it spends nothing):\n` + + ` node --import tsx veed/generate.ts --script "spoken words" --key ${key}\n` + + 'then re-run this command once you have approved the cost it prints.', + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`refusing to spend: ${path} is not readable JSON; re-run the confirm pass`); + } + const record = assertPendingShape(parsed, path); + // The hash pins the exact words the quote was for: a record edited after approval buys something the + // user was never shown a price for. + if (scriptHash(record.script) !== record.scriptSha256) { + throw new Error( + `refusing to spend: the script in ${path} does not match the hash recorded when the cost was approved.\n` + + 'Re-run the confirm pass and approve the cost it prints.', + ); + } + const ageMs = nowMs - record.approvedAt; + if (ageMs > PENDING_TTL_MS || ageMs < 0) { + const age = ageMs < 0 ? 'timestamped in the future' : `${Math.round(ageMs / 60_000)} minutes old`; + throw new Error( + `refusing to spend: that approval is ${age} (limit ${PENDING_TTL_MS / 60_000} minutes).\n` + + 'Re-run the confirm pass to see the current cost and approve it again.', + ); + } + return record; +} + +// --- charge records: what the OTHER attempts at this key say about whether it may be charged again --- + +// One record as found on disk. A null `record` is a file that could not be parsed, which is refused rather +// than skipped: guessing its phase would guess whether money moved. +interface ScannedCharge { + sessionId: string; + record: ChargeRecord | null; +} + +// Every attempt but our own. Our own record is excluded because a run must never stand down for itself. +async function scanChargeRecords(deps: GenerateDeps, key: string): Promise { + const dir = runDirFor(key); + const found: ScannedCharge[] = []; + for (const name of await deps.listState(dir)) { + const sessionId = sessionIdOfChargeFile(name); + if (sessionId === null || sessionId === deps.sessionId) continue; + const raw = await deps.readState(join(dir, name)); + // Gone between the listing and the read: the owner resolved it, which is the one race that is safe. + if (raw === null) continue; + found.push({ sessionId, record: parseChargeRecord(raw) }); + } + return found; +} + +function verdictsOf(scanned: ScannedCharge[], deps: GenerateDeps, nowMs: number): ChargeVerdict[] { + return classifyChargeRecords( + scanned.flatMap((s) => (s.record ? [s.record] : [])), + { host: deps.host, now: nowMs, isAlive: deps.isAlive }, + ); +} + +// Paid first, then a live charge, then an orphan: all three refuse, and this only decides which one the user +// is told about — the most actionable one. +const BLOCKER_ORDER = ['paid', 'charging', 'orphaned'] as const; + +function blockingVerdict(verdicts: ChargeVerdict[]): ChargeVerdict | undefined { + for (const state of BLOCKER_ORDER) { + const hit = verdicts.find((v) => v.state === state); + if (hit) return hit; + } + return undefined; +} + +function unreadableRecordMessage(sessionId: string, key: string): string { + return ( + `refusing to spend: the charge record for session ${sessionId} under --key ${key} cannot be read, so it ` + + 'cannot be ruled out that this run was already charged. Nothing spent.\n' + + 'Check the workspace for a recent charge, then clear that one record:\n' + + ` node --import tsx veed/generate.ts --key ${key} --abandon ${sessionId}` + ); +} + +function blockerMessage(v: ChargeVerdict, key: string): string { + const started = new Date(v.startedAt).toISOString(); + if (v.state === 'paid') { + return ( + `refusing to spend: session ${v.sessionId} has already paid for Fabric job ${v.jobId} under --key ${key}. ` + + 'Nothing spent.\n' + + 'Collect that job instead — it charges nothing:\n' + + ` node --import tsx veed/generate.ts --key ${key} --resume` + ); + } + if (v.state === 'charging') { + return ( + `refusing to spend: session ${v.sessionId} (pid ${v.pid} on ${v.host}) is charging --key ${key} right ` + + `now, since ${started}. Nothing spent.\n` + + 'Let it finish; if it is interrupted, its video is collected without paying again:\n' + + ` node --import tsx veed/generate.ts --key ${key} --resume` + ); + } + return ( + `refusing to spend: session ${v.sessionId} (pid ${v.pid} on ${v.host}) started charging --key ${key} at ` + + `${started} and never came back, so VEED MAY already have charged for it — and no job id was ever ` + + 'recorded, so nothing here can collect it. Nothing spent.\n' + + 'Check that workspace for a video and a charge from around then before paying again. Once you have, ' + + 'clear that one record (it releases nothing else):\n' + + ` node --import tsx veed/generate.ts --key ${key} --abandon ${v.sessionId}` + ); +} + +// The pre-flight scan: a charge already in flight, already paid or possibly-landed all stop the run before +// anything reaches the server. +function assertNoBlockingCharge(scanned: ScannedCharge[], deps: GenerateDeps, key: string, nowMs: number): void { + const unreadable = scanned.find((s) => s.record === null); + if (unreadable) throw new Error(unreadableRecordMessage(unreadable.sessionId, key)); + const blocker = blockingVerdict(verdictsOf(scanned, deps, nowMs)); + if (blocker) throw new Error(blockerMessage(blocker, key)); +} + +// Ties are broken on the session id so that two records stamped in the same millisecond still yield to the +// SAME one in both processes; without that, a symmetric race can stand both runs down or neither. +function startedBefore(other: ChargeVerdict, ours: ChargeRecord): boolean { + if (other.startedAt !== ours.startedAt) return other.startedAt < ours.startedAt; + return other.sessionId < ours.sessionId; +} + +// Write our record, then look again: no mutex, so the record is on disk before the charge, a rival that got +// there first is visible, and the earlier startedAt decides. A record written only after a successful +// create would make this impossible. +async function acquireChargeSlot(deps: GenerateDeps, key: string, nowMs: number): Promise { + const record: ChargeRecord = { + sessionId: deps.sessionId, pid: deps.pid, host: deps.host, startedAt: nowMs, phase: 'charging', + }; + const path = chargePathFor(key, deps.sessionId); + await deps.writeState(path, `${JSON.stringify(record, null, 2)}\n`); + + const rescanned = await scanChargeRecords(deps, key); + const unreadable = rescanned.find((s) => s.record === null); + if (unreadable) { + await deps.removeState(path); + throw new Error(unreadableRecordMessage(unreadable.sessionId, key)); + } + const verdicts = verdictsOf(rescanned, deps, nowMs); + // A rival that started LATER is not a blocker: it can see us, and it is the one that stands down. + const blocker = blockingVerdict(verdicts.filter((v) => v.state !== 'charging' || startedBefore(v, record))); + if (blocker) { + await deps.removeState(path); + if (blocker.state !== 'charging') throw new Error(blockerMessage(blocker, key)); + throw new Error( + `refusing to spend: session ${blocker.sessionId} started charging --key ${key} first ` + + `(${new Date(blocker.startedAt).toISOString()}), so this run stood down rather than buy the same ` + + 'script twice. Nothing spent.\n' + + 'Collect that run\'s video when it finishes — it charges nothing:\n' + + ` node --import tsx veed/generate.ts --key ${key} --resume`, + ); + } + return record; +} + +// The attempt is over: the record stays as an account of what happened, and constrains nothing further. +async function resolveChargeRecord( + deps: GenerateDeps, key: string, sessionId: string, at: number, +): Promise { + const path = chargePathFor(key, sessionId); + const raw = await deps.readState(path); + const record = raw === null ? null : parseChargeRecord(raw); + // Nothing legible to resolve, so leave nothing legible behind either. + if (!record) { + await deps.removeState(path); + return; + } + await deps.writeState(path, `${JSON.stringify({ ...record, resolvedAt: at }, null, 2)}\n`); +} + +// The counterpart for --resume: the job a charge record says this key has ALREADY been charged for. +async function loadResumableJob( + deps: GenerateDeps, key: string, nowMs: number, +): Promise<{ jobId: string; sessionId: string }> { + const scanned = await scanChargeRecords(deps, key); + const verdicts = verdictsOf(scanned, deps, nowMs); + // The newest, because a key charged more than once (each time on its own fresh approval) is resumed to its + // most recent job. + const paid = verdicts.filter((v) => v.state === 'paid').sort((a, b) => b.startedAt - a.startedAt)[0]; + if (paid?.jobId) return { jobId: paid.jobId, sessionId: paid.sessionId }; + + const stranded = verdicts.find((v) => v.state === 'orphaned' || v.state === 'charging'); + if (stranded) { + throw new Error( + `no in-flight Fabric job recorded for --key ${key}: session ${stranded.sessionId} started a charge at ` + + `${new Date(stranded.startedAt).toISOString()} but never recorded a job id, so there is nothing to poll.\n` + + 'Check the workspace before paying again, then clear that record:\n' + + ` node --import tsx veed/generate.ts --key ${key} --abandon ${stranded.sessionId}`, + ); + } + throw new Error( + `no in-flight Fabric job recorded for --key ${key} (looked for a charge record in ${runDirFor(key)}).\n` + + 'There is nothing to resume: either the job already downloaded, or it was never created.', + ); +} + +// Purely local, and deliberately narrow: it resolves the ONE record it is given and leaves every other +// attempt, and every other run, exactly as it was. +async function abandonChargeRecord( + deps: GenerateDeps, key: string, sessionId: string, nowMs: number, log: (m: string) => void, +): Promise { + const path = chargePathFor(key, sessionId); + if (await deps.readState(path) === null) { + throw new Error( + `no charge record for session ${sessionId} under --key ${key} (looked in ${path}). Nothing to abandon.`, + ); + } + await resolveChargeRecord(deps, key, sessionId, nowMs); + log( + `[fabric] charge record ${sessionId} abandoned; --key ${key} is free to take a fresh approval. If that ` + + 'attempt did charge, those credits are gone — this only unblocks the key.', + ); + return { status: 'abandoned', sessionId }; +} + +// Where the workspace to bill came from. `flag` is the only source that updates the stored choice: it is the +// only one where the user just named a workspace. +type WorkspaceSource = 'approval' | 'flag' | 'stored'; + +// Purely local, and deliberately without a fallback: an unanswered question stops the run (see run()) rather +// than being answered on the user's behalf. +async function resolveWorkspaceChoice( + args: Args, deps: GenerateDeps, pending: PendingConfirmation | undefined, +): Promise<{ id: string; source: WorkspaceSource } | null> { + if (pending) { + // The approval binds script, figure AND workspace together; a flag naming a different one contradicts + // it, and honouring either side silently bills a choice the user did not make twice. + if (args.workspace && args.workspace !== pending.workspaceId) { + throw new Error( + `refusing to spend: the approval on disk bills ${pending.workspaceId}, but --workspace names ` + + `${args.workspace}. Nothing spent.\n` + + `Drop the flag to spend from ${pending.workspaceId}, or re-run the confirm pass against ` + + `${args.workspace} and approve what it quotes.`, + ); + } + return { id: pending.workspaceId, source: 'approval' }; + } + if (args.workspace) return { id: args.workspace, source: 'flag' }; + const stored = parseWorkspaceChoice(await deps.readState(DEFAULT_WORKSPACE_PATH)); + if (stored) return { id: stored.workspaceId, source: 'stored' }; + return null; +} + +// ASYMMETRIC because the directions are not equally believable: VEED charges at or under quote, so a delta +// below it is ordinary rounding (generous floor), while a delta above it is likely a concurrent run, not a +// bigger charge (tight ceiling). The 20/5 split comes from a real run — quoted 24, charged 22 (8.3% under) — +// which the old symmetric 5% band discarded. Do not re-tighten the lower bound without re-reading that data. +const LOWER_BAND_RATIO = 0.80; +const UPPER_BAND_RATIO = 1.05; +// 20% of a small quote can be a fraction of a credit (e.g. 2.4 on a quote of 3), which integer billing can +// never land on exactly; the absolute floor keeps small quotes measurable at all. +const BAND_SLACK_CREDITS = 1; + +// What a workspace delta is worth as corroboration: measured (close enough to quote back), unmeasured +// (unreadable, non-positive, or below the floor), or ambiguous (above the ceiling — overcharge or a +// concurrent run, indistinguishable). `delta` rides on all three because it is an observation in its own +// right; discarding it on the ambiguous branch withheld the one measured number the user needs to go check. +type ChargeAssessment = + | { kind: 'measured'; delta: number } + | { kind: 'unmeasured'; delta: number | null } + | { kind: 'ambiguous'; delta: number }; + +function assessCharge(before: number | null, after: number | null, quoted: number): ChargeAssessment { + if (before === null || after === null) return { kind: 'unmeasured', delta: null }; + const delta = before - after; + // A balance that did not move (or moved the wrong way) corroborates nothing, regardless of the band. + if (delta <= 0) return { kind: 'unmeasured', delta }; + const lower = Math.min(quoted - BAND_SLACK_CREDITS, quoted * LOWER_BAND_RATIO); + const upper = Math.max(quoted + BAND_SLACK_CREDITS, quoted * UPPER_BAND_RATIO); + if (delta < lower) return { kind: 'unmeasured', delta }; + if (delta > upper) return { kind: 'ambiguous', delta }; + return { kind: 'measured', delta }; +} + +// For reporting, never gating: an unreadable allowance degrades the report to an estimate rather than +// failing a paid-for run, and is never treated as an empty one (which would refuse an affordable run). +async function readAllowances(client: VeedHttp, workspaceId: string): Promise { + try { + return await getAllowances(client, workspaceId); + } catch { + return null; + } +} + +async function readBalance(client: VeedHttp, workspaceId: string): Promise { + return (await readAllowances(client, workspaceId))?.aiPlaygroundCredits ?? null; +} + +// Name + balance for the workspace about to be billed, so the user reads it before approving anything. The +// name is cosmetic, so a listing failure must not stop a run the user has already directed. +async function describeFraming(client: VeedHttp, characterId: string): Promise { + let listed; + try { + listed = (await listCharacters(client)).find((c) => c.id === characterId); + } catch { + listed = undefined; + } + if (!listed) return `unknown — ${characterId} is not in the live character listing, so its shape cannot be stated`; + return framingOf(listed) === 'portrait' ? 'portrait 9:16' : 'landscape 16:9'; +} + +export async function run(args: Args, deps: GenerateDeps): Promise { + const log = deps.log ?? ((m: string) => console.log(m)); + // Re-checked here as well as in parseArgs: run() is a public entry point and the key ends up in a path. + const key = assertSafeKey(args.key); + assertFlagCombination(args); + // Same reasoning as the key: a caller of run() that never went through parseArgs (every test here included) + // must not be able to reach confirm_fabric_video with nothing to say. + if (args.script !== undefined) assertNonEmptyScript(args.script); + const now = deps.now ?? Date.now; + const pendingPath = pendingPathFor(key); + + // Spends nothing and asks the server nothing, so it answers before the login is even resolved. + if (args.abandon !== undefined) { + return await abandonChargeRecord(deps, key, assertSafeSessionId(args.abandon), now(), log); + } + + // Pass 2 and --resume read their whole request off disk BEFORE anything touches the network. + if (args.yes) assertNoBlockingCharge(await scanChargeRecords(deps, key), deps, key, now()); + const pending = args.yes ? await loadPending(deps, pendingPath, key, now()) : undefined; + const resuming = args.resume ? await loadResumableJob(deps, key, now()) : undefined; + + const token = await deps.resolveAccessToken(); + if (!token) return { status: 'no-token' }; + + const client = await deps.connect(async () => { + const fresh = await deps.resolveAccessToken(); + if (!fresh) throw new Error('VEED login is no longer valid; run: node --import tsx veed/login.ts'); + return fresh; + }); + + // --resume never confirms and never creates: the video is already bought, this only collects it. + if (resuming) { + log(`[fabric] resuming job ${resuming.jobId} — it is already paid for, nothing further will be charged`); + try { + return await collect(client, resuming.jobId, key, resuming.sessionId, deps, log); + } finally { + // The charge report is unconditional, and the run that was interrupted never got to make it. The + // record to close is that SESSION's, not the key's: another attempt's trail is not this run's to touch. + await completeSpendRecord(client, key, resuming.sessionId, deps, log); + } + } + + // Which workspace pays is the user's decision. With no flag and no remembered choice the run stops here, + // before the first billable thought, and shows them what there is to choose from. + const choice = await resolveWorkspaceChoice(args, deps, pending); + if (!choice) { + const workspaces = await listWorkspacesWithCredits(client); + log( + [ + '[fabric] Fabric bills a workspace and none has been chosen, so nothing has been done and nothing spent.', + '', + formatWorkspaceTable(workspaces), + '', + 'Re-run naming the workspace to spend from, for example:', + ` node --import tsx veed/generate.ts --script "spoken words" --key ${key} --workspace ${workspaces[0].id}`, + 'That choice is remembered, so this is asked once and not every session.', + ].join('\n'), + ); + return { status: 'workspace-required', workspaces }; + } + + const workspaceId = choice.id; + // An account with no workspaces has nothing to bill; checked here where the workspace is chosen, so it + // fails once with a useful message rather than later, one call at a time. + const resolution = await resolveWorkspace({ http: client, explicit: workspaceId }); + if (resolution.kind === 'must-choose') throw new Error('internal error: a workspace was already chosen'); + const workspace = resolution.workspace; + log(`[fabric] ${describeChoice(workspace, resolution.source)}`); + // The spend pass is the last moment at which a workspace nobody ever named can still be questioned, and + // the CLI cannot hold a conversation — so naming it on this invocation IS the acknowledgement. + if (pending && !pending.workspaceNamed && args.workspace !== workspaceId) { + throw new Error( + `refusing to spend: ${workspace.name} (${workspaceId}) is remembered from an earlier session, and ` + + 'nobody has confirmed it for this charge. Nothing spent.\n' + + `It has ${workspace.credits === null ? 'an unreadable balance' : `${workspace.credits} credits`}. ` + + 'Confirm it by naming it:\n' + + ` node --import tsx veed/generate.ts --key ${key} --yes --workspace ${workspaceId}\n` + + 'To bill a different workspace, re-run the confirm pass with that --workspace.', + ); + } + + // Only an explicit flag records a decision; a run that inherited its workspace has not made one. + const workspaceNamed = choice.source === 'flag'; + if (choice.source === 'stored') { + log( + `[fabric] ${workspaceId} is remembered from an earlier session, not chosen for this run — the spend ` + + 'pass below names it again, which is where that choice gets confirmed.', + ); + } + if (workspaceNamed) { + await deps.writeState( + DEFAULT_WORKSPACE_PATH, + serializeWorkspaceChoice({ workspaceId, workspaceName: workspace.name, chosenAt: now() }), + ); + } + + // Pass 1 builds the request from the flags; pass 2 rebuilds it from the approval, so the script is typed + // exactly once and cannot drift between the price the user saw and the one they are charged. + const script = pending?.script ?? (args.script as string); + const voiceId = pending?.voiceId ?? args.voice; + + // Resolved BEFORE the confirmation, because it is what prices it: confirmVideo quotes at this rate, so + // the figure in the approval block and the figure the estimate line explains are the same number. + const rates = parseVoiceRates(await deps.readState(DEFAULT_VOICE_RATES_PATH)); + const known = rateFor(voiceId, rates); + const rate = known?.charsPerSecond ?? conservativeRate(rates); + const range = estimateRange(script, rateRange(rates)); + + const req = { + script, + voiceId, + characterId: pending?.characterId ?? args.character, + image: imageSourceFrom(pending?.image ?? args.image), + workspaceId, + // Already resolved above; passing it spares confirmVideo a duplicate workspace listing. + workspaceName: workspace.name, + charsPerSecond: rate, + }; + log( + known + ? `[fabric] local estimate ~${estimateTotalCredits(script, rate)} credits (${script.length} chars at ${rate} chars/second, measured over ${known.samples} run${known.samples === 1 ? '' : 's'} of this voice)` + : `[fabric] local estimate ~${estimateTotalCredits(script, rate)} credits (${script.length} chars). This voice has never been measured, and speaking rate decides the length: expect ${range.low}-${range.high} credits.`, + ); + // The confirmation ALWAYS runs, and always before create_fabric_video below — it is what the user is + // shown to approve. Nothing between here and the `if (!args.yes)` gate may spend. + const confirmation = await confirmVideo(client, req); + // One approval covers the script, the presenter, the voice, the framing AND the cost, so all five are in + // the block it is given against: the script and the framing lost their own gates, not their visibility. + log( + [ + '[fabric] confirmation', + ` character ${confirmation.characterName} (${confirmation.characterId})`, + ` voice ${confirmation.voiceName}`, + ` framing ${await describeFraming(client, req.characterId)}`, + ` workspace ${confirmation.workspaceName}`, + ` cost ~${confirmation.estimatedCredits} AI Playground credits`, + // The speech synthesis and Fabric One Lipsync are two debits on the one credit allowance; the figure + // above is their sum, so the user sees the whole spend rather than half of it. + " (covers both the speech synthesis and Fabric One Lipsync — one allowance, one figure)", + // An unmeasured voice gets the spread it could land in, not a single figure dressed as a + // measurement. The point number above already leans slow; this says how wide the uncertainty is. + ...(known ? [] : [` (estimated: this voice has never been measured, expect ${range.low}-${range.high})`]), + ` script ${script.length} chars, exactly as they will be spoken:`, + ...script.split('\n').map((line) => ` ${line}`), + ].join('\n'), + ); + + // A workspace that cannot cover the quote is a stop, not a prompt to look elsewhere: a richer workspace + // bills money nobody offered. Checked on BOTH passes, each with its own freshly-read balance (it can move + // between them); refusing on the CONFIRM pass means no unaffordable approval is ever written. An UNREADABLE + // balance is not evidence of insufficiency, so it proceeds and lets the server refuse. + if (workspace.credits !== null && workspace.credits < confirmation.estimatedCredits && args.ignoreBalance) { + log( + `[fabric] WARNING: ${workspace.name} reports ${workspace.credits} credits against a ~${confirmation.estimatedCredits} ` + + 'credit quote, and --ignore-balance says to spend anyway. If the report is right this fails at VEED, ' + + 'having charged nothing; if it is wrong, this charges normally.', + ); + } else if (workspace.credits !== null && workspace.credits < confirmation.estimatedCredits) { + throw new Error( + `refusing to spend: ${workspace.name} (${workspaceId}) has ${workspace.credits} credits, but this ` + + `costs ${confirmation.estimatedCredits}. Nothing spent.\n` + + 'Top that workspace up, shorten the script, or re-confirm naming a different --workspace — this will ' + + 'not switch workspaces on your behalf.', + ); + } + + if (!pending) { + const record: PendingConfirmation = { + script, + scriptSha256: scriptHash(script), + characterId: req.characterId, + ...(args.image ? { image: args.image } : {}), + voiceId: req.voiceId, + workspaceId, + workspaceNamed, + estimatedCredits: confirmation.estimatedCredits, + approvedAt: now(), + }; + await deps.writeState(pendingPath, `${JSON.stringify(record, null, 2)}\n`); + // An unnamed workspace carries its own flag into the command, so approving the cost and confirming the + // workspace are the same keystroke rather than two questions. + const spendCommand = ` node --import tsx veed/generate.ts --key ${key} --yes` + + (workspaceNamed ? '' : ` --workspace ${workspaceId}`); + log( + `\nNothing spent. To approve ${confirmation.estimatedCredits} credits and generate, run exactly:\n\n` + + `${spendCommand}\n\n` + + 'That re-uses the confirmation above — do not re-type --script.', + ); + return { status: 'confirmed', credits: confirmation.estimatedCredits }; + } + + // The cap: the server may not charge more than the figure the user actually saw and approved. A cheaper + // quote is fine — being billed LESS than approved needs no second look. + if (confirmation.estimatedCredits > pending.estimatedCredits) { + throw new Error( + `refusing to spend: you approved ${pending.estimatedCredits} credits, but the server now quotes ` + + `${confirmation.estimatedCredits}. Nothing spent.\n` + + 'Re-run the confirm pass to see the new cost and approve it explicitly.', + ); + } + + // --- everything past this line spends real credits --- + // VEED exposes no per-job charge, so the balance either side of the create call is all there is — and it is + // only ever a WORKSPACE observation; the quote above is the figure this run reports. + const balanceBefore = await readBalance(client, workspaceId); + // Written BEFORE the charging call, deliberately: a create that times out, 502s or returns a truncated + // body has still moved the money, and a record written afterwards would not exist to say so (buying twice). + const charge = await acquireChargeSlot(deps, key, now()); + const chargePath = chargePathFor(key, charge.sessionId); + const job = await createVideo(client, req, { readFileBytes: deps.readFileBytes }); + // Now the charge is known to have landed AND has an id: the record can name the job --resume needs. + await deps.writeState(chargePath, `${JSON.stringify({ ...charge, phase: 'charged', jobId: job.jobId }, null, 2)}\n`); + // Closing balance taken HERE, not after the download: the balance is workspace-global, so the minutes of + // polling and downloading would let other spends land inside the measurement. Whether VEED debits at create + // or completion is unknown, so this reading is preferred only when it moved, with the post-download one as fallback. + const balanceAtCreate = await readBalance(client, workspaceId); + // The approval is spent the instant the charge lands, so it goes before anything that could still fail: + // a job that fails, times out or dies mid-download must not leave a "yes" behind that buys a second video. + await deps.removeState(pendingPath); + log(`[fabric] job ${job.jobId} started${job.durationSeconds ? ` (${job.durationSeconds.toFixed(1)}s of video)` : ''}`); + log(`[fabric] recorded at ${chargePath} — if this run is interrupted, collect it without paying again:\n node --import tsx veed/generate.ts --key ${key} --resume`); + + const spend: SpendRecord = { + jobId: job.jobId, + workspaceId, + workspaceName: workspace.name, + estimatedCredits: confirmation.estimatedCredits, + scriptChars: confirmation.script.length, + durationSeconds: job.durationSeconds, + assumedCharsPerSecond: rate, + chargedCredits: null, + observedWorkspaceDelta: null, + balanceBefore, + at: now(), + }; + const spendPath = spendPathFor(key, charge.sessionId); + // Written at the charge and again below with the observed figure: a run that dies mid-download still spent + // the credits, and an audit trail that only appears on success hides the cases worth auditing. + await deps.writeState(spendPath, `${JSON.stringify(spend, null, 2)}\n`); + + try { + return await collect(client, job.jobId, key, charge.sessionId, deps, log); + } finally { + let balanceAfter = balanceAtCreate; + let assessment = assessCharge(balanceBefore, balanceAtCreate, confirmation.estimatedCredits); + // The tight reading is credible only when it already CORROBORATES the quote; anything less — nothing + // moved, or only PART of the charge had landed — needs the completion read, the last chance to see a + // debit that settles late. The speech synthesis debits a couple of credits at create while the clip's + // credits settle at completion, so the early delta is routinely positive but far below the quote; + // stopping there would report that partial figure as "nothing credible" while the full charge settled + // unseen. 'ambiguous' is left alone: the early delta is already above the quote, and waiting only lets + // more concurrent movement pile onto a reading that is already untrustworthy. + if (assessment.kind === 'unmeasured') { + balanceAfter = await readBalance(client, workspaceId); + assessment = assessCharge(balanceBefore, balanceAfter, confirmation.estimatedCredits); + } + spend.chargedCredits = assessment.kind === 'measured' ? assessment.delta : null; + spend.observedWorkspaceDelta = assessment.delta; + reportCharge(log, spend, assessment, balanceAfter); + if (spend.durationSeconds && spend.scriptChars) { + // The next quote for this voice is built from what this one actually did. + await deps.writeState( + DEFAULT_VOICE_RATES_PATH, + serializeVoiceRates(withObservation(rates, req.voiceId, spend.scriptChars / spend.durationSeconds)), + ); + } + await deps.writeState(spendPath, `${JSON.stringify(spend, null, 2)}\n`); + log(`[fabric] spend recorded at ${spendPath}`); + } +} + +// Saying nothing about cost is never acceptable, nor is a number the run cannot stand behind. VEED gives no +// per-job figure, so the leading number is OUR estimate (labelled as one), and the workspace-global balance +// delta is offered as corroboration, named a workspace movement, never "charged N". The user always gets a +// number AND how much to trust it. +function reportCharge( + log: (m: string) => void, spend: SpendRecord, assessment: ChargeAssessment, balanceAfter: number | null, +): void { + const workspace = `${spend.workspaceName} (${spend.workspaceId})`; + // Printed on EVERY run that can measure, not only where the delta agrees: an estimate that was wrong + // is exactly the case this line explains, so hiding it there would hide it when it matters most. + if (spend.durationSeconds && spend.scriptChars) { + const observed = spend.scriptChars / spend.durationSeconds; + log( + `[fabric] this run measured ${observed.toFixed(1)} characters/second` + + (assessment.delta ? ` and ${(assessment.delta / spend.durationSeconds).toFixed(2)} credits/second` : '') + + `. The estimate assumed ${spend.assumedCharsPerSecond ?? CHARS_PER_SECOND}; speaking rate is a ` + + 'property of the voice, and it is what decides how long — and so how expensive — a script is.', + ); + } + log( + `[fabric] this run was approved for an estimated ${spend.estimatedCredits} credits against ${workspace} — ` + + 'that figure is our own estimate from the script length, not a price VEED quoted. VEED exposes no ' + + 'per-job cost, so the balance movement below is the only measurement of what was actually charged.', + ); + const left = balanceAfter === null ? '' : `, ${balanceAfter} left`; + if (assessment.kind === 'measured') { + log( + `[fabric] that workspace's balance moved ${assessment.delta} credits over the run${left}, which ` + + 'corroborates the estimate. It is a workspace-wide movement rather than this run\'s charge: anything ' + + 'else billing the same workspace moves it too.', + ); + return; + } + if (assessment.kind === 'ambiguous') { + log( + `[fabric] that workspace's balance moved ${assessment.delta} credits over the run${left} — more than ` + + `the ${spend.estimatedCredits}-credit estimate, so it is recorded as an observed workspace movement ` + + 'and not as this run\'s charge. The likeliest cause is our estimate running low — a voice slower ' + + 'than assumed makes a longer video — and a concurrent run billing the same workspace is the other. ' + + 'The measured rate above says which.', + ); + return; + } + // Deliberately covers all three ways a delta says nothing — unread, unmoved, moved too little — because + // claiming the balance "could not be read" when it read perfectly and sat still is its own small lie. + log( + `[fabric] the balance either side gives nothing credible to corroborate that with (it could not be read, ` + + `did not move, or moved too little to stand for the estimate), so this is our estimate and not ` + + `the amount charged: ~${spend.estimatedCredits} credits from ${workspace}`, + ); +} + +// Closes the audit trail a charged-but-interrupted run left open: it names the workspace that was billed, so +// the balance can be read against it without asking the user anything a second time. +async function completeSpendRecord( + client: VeedHttp, key: string, sessionId: string, deps: GenerateDeps, log: (m: string) => void, +): Promise { + const path = spendPathFor(key, sessionId); + const raw = await deps.readState(path); + let spend: SpendRecord | null = null; + try { + if (raw !== null) spend = JSON.parse(raw) as SpendRecord; + } catch { + spend = null; + } + if (!spend || typeof spend.workspaceId !== 'string') { + log(`[fabric] no usable spend record at ${path}, so this run cannot say what the job cost`); + return; + } + + // A resume COMPLETES a record, never re-decides one: the balance it could read now reflects every other + // spend against this workspace since, so re-running the assessment would replace the one real measurement + // with an artefact of that drift. + if (typeof spend.chargedCredits === 'number' && Number.isFinite(spend.chargedCredits)) { + reportCharge(log, spend, { kind: 'measured', delta: spend.chargedCredits }, null); + return; + } + + const resolved = await resolveWorkspace({ http: client, explicit: spend.workspaceId }); + const workspace = resolved.kind === 'resolved' + ? resolved.workspace + : { id: spend.workspaceId, name: spend.workspaceName, credits: null }; + const assessment = assessCharge(spend.balanceBefore ?? null, workspace.credits, spend.estimatedCredits); + spend.chargedCredits = assessment.kind === 'measured' ? assessment.delta : null; + spend.observedWorkspaceDelta = assessment.delta; + reportCharge(log, spend, assessment, workspace.credits); + await deps.writeState(path, `${JSON.stringify(spend, null, 2)}\n`); + log(`[fabric] spend recorded at ${path}`); +} + +// Poll an EXISTING job (paid for already) and land the file. Shared by the spend pass and --resume, so both +// clean up the same state and neither can reach create_fabric_video from here. +async function collect( + client: VeedHttp, jobId: string, key: string, sessionId: string, deps: GenerateDeps, + log: (m: string) => void, +): Promise { + const now = deps.now ?? Date.now; + let url: string; + try { + url = await awaitVideo(client, jobId, { + sleep: deps.sleep, + now: deps.now, + onProgress: (attempt, status) => log(`[fabric] poll ${attempt + 1}: ${status}`), + }); + } catch (e) { + // A job the SERVER declared dead is unrecoverable and needs a fresh approval, which a lingering record + // would block forever; every other failure may still finish server-side, so its record stays for --resume. + if (e instanceof FabricJobFailedError) await resolveChargeRecord(deps, key, sessionId, now()); + throw e; + } + + const bytes = await deps.download(url); + // The filename must derive back to `key` via runKeyOf (as go.ts and prep.ts do), or this run silently + // collides with whatever run that filename would derive to. + const out = join(REPO_ROOT, 'runs', key, `${key}.mp4`); + if (runKeyOf(out) !== key) { + throw new Error(`internal error: runKeyOf(${out}) !== "${key}" — refusing to write a colliding run`); + } + await deps.writeOutput(out, bytes); + // A downloaded job is no longer in flight; the approval that bought it was already spent at the charge. + await resolveChargeRecord(deps, key, sessionId, now()); + log(`[fabric] wrote ${out} (${(bytes.length / 1e6).toFixed(1)} MB)`); + return { status: 'generated', path: out, bytes: bytes.length }; +} + +function noTokenHelp(): void { + console.error( + [ + 'No VEED login found. Log in with VEED:', + '', + ' node --import tsx veed/login.ts', + '', + 'It opens your browser once and stores a refreshable token, owner-only, at', + ` ${DEFAULT_TOKEN_PATH}`, + '', + 'The same login covers both transcription and Fabric generation.', + ].join('\n'), + ); +} + +export function realDeps(): GenerateDeps { + return { + resolveAccessToken: () => + resolveToken({ + envToken: process.env.VEED_ACCESS_TOKEN, + tokenPath: DEFAULT_TOKEN_PATH, + expectedOrigin: VEED_ORIGIN, + }), + // Resolved per request, so a token refresh mid-poll is picked up without reconnecting. + connect: async (getToken) => refreshingHttp(getToken), + readFileBytes: async (path) => new Uint8Array(await readFile(path)), + download: async (url) => { + const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + if (!res.ok) throw new Error(`download ${url} -> ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); + }, + writeOutput: async (path, bytes) => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); + }, + readState: async (path) => { + try { + return await readFile(path, 'utf8'); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw e; + } + }, + writeState: async (path, text) => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, text, { mode: 0o600 }); + }, + removeState: (path) => rm(path, { force: true }), + listState: async (dir) => { + try { + return await readdir(dir); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw e; + } + }, + // One id per process, and a process charges at most once, so this identifies the attempt as well. + sessionId: randomUUID(), + pid: process.pid, + host: hostname(), + // Signal 0 checks for the process without touching it; EPERM means it exists and simply is not ours. + isAlive: (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return (e as NodeJS.ErrnoException).code === 'EPERM'; + } + }, + sleep: (ms) => new Promise((r) => setTimeout(r, ms)), + now: Date.now, + }; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const result = await run(args, realDeps()); + if (result.status === 'no-token') { + noTokenHelp(); + process.exit(1); + } + // run() has already printed the workspaces and the exact command to re-run with; exiting non-zero keeps a + // caller that chains commands from treating "nobody chose" as "done". + if (result.status === 'workspace-required') process.exit(1); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((e: unknown) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); + }); +} diff --git a/veed/go.ts b/veed/go.ts index 056e85b..f62169f 100644 --- a/veed/go.ts +++ b/veed/go.ts @@ -12,10 +12,11 @@ import { parseFlags } from './args.ts'; import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { extname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { REPO_ROOT } from '../config.ts'; import { resolveVideoArg, runKeyOf } from '../pipeline/scripts/resolve-video.ts'; -import { VEED_ORIGIN } from './api.ts'; -import { realHttp } from './http.ts'; +import { VEED_ORIGIN, type VeedHttp } from './api.ts'; +import { refreshingHttp } from './http.ts'; import { transcribeWithVeed } from './orchestrate.ts'; import { DEFAULT_TOKEN_PATH, resolveToken } from './token-store.ts'; @@ -27,6 +28,26 @@ async function readVideoBytes(videoPath: string): Promise<{ bytes: Uint8Array; m return { bytes, mimeType, extension: ext }; } +// The stored login, resolved FRESH each call so a token refresh is always picked up. +function resolveVeedToken(): Promise { + return resolveToken({ + envToken: process.env.VEED_ACCESS_TOKEN, + tokenPath: DEFAULT_TOKEN_PATH, + expectedOrigin: VEED_ORIGIN, + }); +} + +// A VeedHttp that re-resolves the token per REQUEST. A batch can outlive a single access token, so closing +// over one (realHttp) would 401 on a later video after earlier ones already spent credits; resolving per +// request refreshes mid-run instead. +export function connectRefreshing(resolve: () => Promise = resolveVeedToken): VeedHttp { + return refreshingHttp(async () => { + const token = await resolve(); + if (!token) throw new Error('VEED login expired mid-run — re-run: node --import tsx veed/login.ts'); + return token; + }); +} + function noTokenHelp(): void { console.error( [ @@ -62,11 +83,7 @@ async function main(): Promise { process.exit(1); } - const token = await resolveToken({ - envToken: process.env.VEED_ACCESS_TOKEN, - tokenPath: DEFAULT_TOKEN_PATH, - expectedOrigin: VEED_ORIGIN, - }); + const token = await resolveVeedToken(); if (!token) { noTokenHelp(); process.exit(1); @@ -80,7 +97,7 @@ async function main(): Promise { process.exit(1); } - const http = realHttp(token); + const http = connectRefreshing(); for (const video of videos) { // The one run-key rule, shared with prep/ — never repeated inline. const key = runKeyOf(video); @@ -98,7 +115,12 @@ async function main(): Promise { } } -main().catch((e: unknown) => { - console.error(e instanceof Error ? e.message : e); - process.exit(1); -}); +// Only when run as the entry point — importing go.ts (e.g. from a test of connectRefreshing) must not +// kick off a transcription batch from the importer's argv. Compare the module URL rather than matching the +// filename: an endsWith('go.ts') check also fires for any other script whose path happens to end that way. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((e: unknown) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); + }); +} diff --git a/veed/http.ts b/veed/http.ts index 90d9f35..11397c9 100644 --- a/veed/http.ts +++ b/veed/http.ts @@ -6,6 +6,19 @@ const JSON_TIMEOUT_MS = 30_000; // The GCS PUT carries the whole video; give it room. const UPLOAD_TIMEOUT_MS = 15 * 60_000; +// A Fabric run polls for up to 15 minutes, which outlives an access token. This resolves the token +// per REQUEST rather than closing over one, so a refresh mid-run is picked up automatically and a +// paid job is never lost to an expiry between the charge and the download. +export function refreshingHttp(getToken: () => Promise): VeedHttp { + const at = (fn: (http: VeedHttp) => Promise): Promise => getToken().then((t) => fn(realHttp(t))); + return { + getJson: (path, headers) => at((h) => h.getJson(path, headers)), + getJsonOrNull: (path) => at((h) => h.getJsonOrNull(path)), + postJson: (path, body) => at((h) => h.postJson(path, body)), + putBytes: (url, bytes, contentType) => at((h) => h.putBytes(url, bytes, contentType)), + }; +} + export function realHttp(token: string): VeedHttp { // Origin is hard-required by the upload-URL endpoint (it also // seeds the GCS resumable session); Node's fetch doesn't add one itself. diff --git a/veed/orchestrate.ts b/veed/orchestrate.ts index b752020..a44db4b 100644 --- a/veed/orchestrate.ts +++ b/veed/orchestrate.ts @@ -8,7 +8,7 @@ import { startTranscription, getSubtitle, } from './api.ts'; -import { describeChoice, formatWorkspaceTable, resolveWorkspace } from './workspace.ts'; +import { describeWorkspaceChoice, formatWorkspaceTable, resolveWorkspace } from './workspace.ts'; import { mapVeedTranscript } from './transcript-mapper.ts'; import type { Transcript } from './transcript-mapper.ts'; @@ -64,7 +64,8 @@ export async function transcribeWithVeed(deps: TranscribeDeps, opts: TranscribeO ); } workspaceId = resolution.workspace.id; - log(describeChoice(resolution.workspace, resolution.source)); + // Transcription bills VEED transcription credits, not the AI Playground balance describeChoice shows. + log(describeWorkspaceChoice(resolution.workspace, resolution.source)); } else { log(`workspace ${workspaceId}`); } diff --git a/veed/poll.ts b/veed/poll.ts new file mode 100644 index 0000000..0589a22 --- /dev/null +++ b/veed/poll.ts @@ -0,0 +1,22 @@ +// One poll loop for every VEED route that hands back a job to wait on: asset upload, transcription, +// speech synthesis, video generation. Sleep is injected so the wait is instant under test. +export async function poll(opts: { + fetch: () => Promise; + decide: (value: T) => 'done' | 'failed' | 'wait'; + label: string; + failMessage: (value: T) => string; + sleep: (ms: number) => Promise; + intervalMs: number; + maxAttempts: number; +}): Promise { + for (let attempt = 0; attempt < opts.maxAttempts; attempt++) { + const value = await opts.fetch(); + const state = opts.decide(value); + if (state === 'failed') throw new Error(opts.failMessage(value)); + if (state === 'done') return value; + await opts.sleep(opts.intervalMs); + } + throw new Error( + `VEED: ${opts.label} did not finish after ${opts.maxAttempts} polls (${opts.intervalMs}ms each)`, + ); +} diff --git a/veed/readiness.ts b/veed/readiness.ts index 7a820f8..7fbd3c5 100644 --- a/veed/readiness.ts +++ b/veed/readiness.ts @@ -38,6 +38,11 @@ add( // --- VEED-native transcription path --- add('VEED API base', true, VEED_API_BASE, false); +// Fabric generation rides the same login and the same REST edge as transcription — surface it so a user +// who wonders "can this thing make me a video?" gets an answer without reading the source. This file makes +// no network calls, so this reports the CONFIGURED url only; reachability is proven by the first real run. +add('Fabric generation endpoint', true, `${VEED_API_BASE} (configured; not contacted)`, false); + const tokenPath = DEFAULT_TOKEN_PATH; const tokenEnv = (process.env.VEED_ACCESS_TOKEN ?? '').trim() !== ''; // Optional, not blocking: only the VEED provider reads this token. diff --git a/veed/sample-presenter.ts b/veed/sample-presenter.ts new file mode 100644 index 0000000..134c22b --- /dev/null +++ b/veed/sample-presenter.ts @@ -0,0 +1,275 @@ +// Proposes ONE Fabric character + voice, so nobody has to read 24 thumbnails and 588 voice rows to start. +// Deterministic like its sibling pipeline/scripts/sample-style.ts, and it shares that file's seeding idiom. +// It lives in veed/ rather than pipeline/scripts/ because it needs the VEED login and the VEED API. +// +// node --import tsx veed/sample-presenter.ts [--key ] [--seed N] [--gender male|female] +// [--locale en] [--portrait | --landscape] +// +// It PROPOSES, it does not decide: it writes nothing generate.ts reads, spends nothing (the two list tools +// are free), and ends with the exact generate.ts command carrying the pair — the user overrules it by +// re-rolling with --seed, or by editing the ids in that command. Which workspace pays is still generate.ts's +// question to ask, so no workspace flag is invented here. +import { pathToFileURL } from 'node:url'; +import { seedFromKey, mulberry32 } from '../pipeline/scripts/sample-style.ts'; +import { VEED_ORIGIN } from './api.ts'; +import { assertSafeKey, parseFlags } from './args.ts'; +import type { FabricCharacter, FabricVoice } from './fabric.ts'; +import { framingOf, listCharacters, listVoices } from './fabric.ts'; +import type { VeedHttp } from './api.ts'; +import { realHttp } from './http.ts'; +import { DEFAULT_TOKEN_PATH, resolveToken } from './token-store.ts'; + +// generate.ts's own default, so the proposal and the command it prints agree when no --key is given. +const DEFAULT_KEY = 'generated'; +// English unless asked otherwise: list_voices REQUIRES a locale, and 588 English voices already outnumber +// what anyone will audition. +export const DEFAULT_LOCALE = 'en'; +const ALTERNATES = 3; + +export type Framing = 'portrait' | 'landscape' | 'any'; + +export interface PresenterPool { + characters: FabricCharacter[]; + voices: FabricVoice[]; +} + +export interface PresenterOptions { + key?: string; + seed?: number; + gender?: 'male' | 'female'; + locale?: string; + framing?: Framing; +} + +export interface PresenterProposal { + character: FabricCharacter; + voice: FabricVoice; + seed: number; + framing: Framing; + locale: string; + // What the pick was drawn FROM, so the output can say how much was ruled out by the filters. + pool: { characters: number; voices: number }; + // Runners-up the user can overrule the proposal with, by eye (thumbnails) and by ear (previews). + alternates: { characters: FabricCharacter[]; voices: FabricVoice[] }; +} + +// Voice gender is capitalised server-side ('Female' | 'Male' | 'Neutral'); character gender is not. +function voiceGenderFor(gender: 'male' | 'female'): 'Male' | 'Female' { + return gender === 'male' ? 'Male' : 'Female'; +} + +// 'en' matches every English voice, 'en-IE' only the Irish ones — the same prefix rule list_voices uses. +function matchesLocale(voice: FabricVoice, locale: string): boolean { + const want = locale.toLowerCase(); + const have = voice.locale.toLowerCase(); + return have === want || have.startsWith(`${want}-`); +} + +export function eligibleCharacters(characters: FabricCharacter[], opts: PresenterOptions = {}): FabricCharacter[] { + const framing = opts.framing ?? 'any'; + return characters + .filter((c) => (opts.gender ? c.gender === opts.gender : true)) + // Framing is a property of the character (Fabric has no aspectRatio parameter), read live off the + // thumbnail rather than from a list that goes stale the next time VEED ships a character. + .filter((c) => (framing === 'any' ? true : framingOf(c) === framing)) + // Sorted so the draw depends on the listing's CONTENT, not on the order the server happened to return. + .sort((a, b) => a.id.localeCompare(b.id)); +} + +export function eligibleVoices(voices: FabricVoice[], opts: PresenterOptions = {}): FabricVoice[] { + const locale = opts.locale ?? DEFAULT_LOCALE; + const gender = opts.gender ? voiceGenderFor(opts.gender) : undefined; + return voices + .filter((v) => matchesLocale(v, locale)) + .filter((v) => (gender ? v.gender.toLowerCase() === gender.toLowerCase() : true)) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +// Draws `n` DISTINCT items from one PRNG stream: an alternate that repeats the pick is not an alternative. +function drawDistinct(pool: T[], rnd: () => number, n: number): T[] { + const remaining = [...pool]; + const out: T[] = []; + while (out.length < n && remaining.length > 0) { + out.push(...remaining.splice(Math.floor(rnd() * remaining.length), 1)); + } + return out; +} + +export function samplePresenter(pool: PresenterPool, opts: PresenterOptions = {}): PresenterProposal { + const framing = opts.framing ?? 'any'; + const locale = opts.locale ?? DEFAULT_LOCALE; + const characters = eligibleCharacters(pool.characters, opts); + const voices = eligibleVoices(pool.voices, opts); + // Naming the filter that emptied the pool: proposing something outside it would quietly hand the user a + // presenter they explicitly ruled out. + if (characters.length === 0) { + throw new Error(`no character matches those filters (gender=${opts.gender ?? 'any'}, framing=${framing}) — drop one and re-run`); + } + if (voices.length === 0) { + throw new Error(`no voice matches those filters (locale=${locale}, gender=${opts.gender ?? 'any'}) — try a broader --locale`); + } + + const seed = opts.seed ?? seedFromKey(opts.key ?? DEFAULT_KEY); + // One stream for both halves: a seed reproduces the PAIR, not either side of it. + const rnd = mulberry32(seed); + const [character, ...altCharacters] = drawDistinct(characters, rnd, 1 + ALTERNATES); + const [voice, ...altVoices] = drawDistinct(voices, rnd, 1 + ALTERNATES); + + return { + character, + voice, + seed, + framing, + locale, + pool: { characters: characters.length, voices: voices.length }, + alternates: { characters: altCharacters, voices: altVoices }, + }; +} + +// A server that always hands back a cursor would otherwise page forever; 'en' alone is 24 pages of 25. +const MAX_VOICE_PAGES = 60; + +// The ONLY two tools this script may reach for. Both are free reads: nothing here can spend a credit, +// create a job, or leave state behind. +export async function collectPresenterPool( + client: VeedHttp, opts: { locale?: string; gender?: 'male' | 'female' } = {}, +): Promise { + const characters = await listCharacters(client, opts.gender); + const voices: FabricVoice[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_VOICE_PAGES; page += 1) { + const got = await listVoices(client, { + locale: opts.locale ?? DEFAULT_LOCALE, + ...(opts.gender ? { gender: voiceGenderFor(opts.gender) } : {}), + ...(cursor ? { cursor } : {}), + }); + voices.push(...got.voices); + if (!got.nextCursor) break; + cursor = got.nextCursor; + } + return { characters, voices }; +} + +export function generateCommand(proposal: PresenterProposal, key: string): string { + // The script is the one thing this cannot propose, so it stays a placeholder the user replaces — and the + // command it belongs to is the CONFIRM pass, which quotes a cost and spends nothing. + return 'node --import tsx veed/generate.ts --script "" ' + + `--key ${key} --character ${proposal.character.id} --voice ${proposal.voice.id}`; +} + +// Alternates are meant to be SCANNED — ragged ids and names make three rows read like thirty. +function columnPadder(rows: string[][]): (row: string[]) => string { + const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length))); + return (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(' '); +} + +export function formatProposal(proposal: PresenterProposal, key: string): string { + const { character, voice, alternates, pool } = proposal; + const lines = [ + `[sample-presenter] proposing 1 of ${pool.characters} characters x ${pool.voices} voices ` + + `(seed=${proposal.seed}, locale=${proposal.locale}, framing=${proposal.framing})`, + ` character ${character.id} ${character.name} ${character.gender} ${framingOf(character)}`, + ` ${character.thumbnail}`, + ` voice ${voice.id} ${voice.name} ${voice.localeLabel} ${voice.gender}`, + ` ${voice.previewAudioUrl}`, + ]; + if (alternates.characters.length) { + const pad = columnPadder(alternates.characters.map((c) => [c.id, c.name, c.gender])); + lines.push(' other characters (look):'); + for (const c of alternates.characters) { + lines.push(` ${pad([c.id, c.name, c.gender])} ${framingOf(c)} ${c.thumbnail}`); + } + } + if (alternates.voices.length) { + const pad = columnPadder(alternates.voices.map((v) => [v.id, v.name, v.localeLabel, v.gender])); + lines.push(' other voices (listen):'); + for (const v of alternates.voices) { + lines.push(` ${pad([v.id, v.name, v.localeLabel, v.gender])} ${v.previewAudioUrl}`); + } + } + lines.push( + '', + ' This is a proposal, not a decision: nothing was written and nothing was chosen for you. Listing', + ' characters and voices costs 0 credits, so re-running this is always free. Re-roll with --seed N,', + ' narrow with --gender/--locale/--portrait/--landscape, or just edit the two ids below.', + '', + generateCommand(proposal, key), + ); + return lines.join('\n'); +} + +const USAGE = 'usage: node --import tsx veed/sample-presenter.ts [--key ] [--seed N] [--gender male|female] [--locale ] [--portrait|--landscape]'; + +const OPTIONS = { + key: { type: 'string' }, + seed: { type: 'string' }, + gender: { type: 'string' }, + locale: { type: 'string' }, + portrait: { type: 'boolean' }, + landscape: { type: 'boolean' }, +} as const; + +// Shares the strict parseFlags every entry point uses (veed/args.ts) rather than a second hand-rolled reader +// that can drift from it: an unknown or misspelled flag, or a --seed with no value, is an error, not a silent +// miss. --key gets the same safe-key guard generate.ts uses, since it ends up in the confirm command run next. +export function parsePresenterArgs(argv: string[]): PresenterOptions { + const { values } = parseFlags({ args: argv, options: OPTIONS }); + + const key = assertSafeKey(values.key ?? DEFAULT_KEY); + + let seed: number | undefined; + if (values.seed !== undefined) { + seed = Number(values.seed); + if (!Number.isFinite(seed)) throw new Error(`--seed must be a number, got "${values.seed}"`); + } + + const gender = values.gender; + if (gender !== undefined && gender !== 'male' && gender !== 'female') { + throw new Error(`--gender must be male or female, got "${gender}"\n${USAGE}`); + } + + if (values.portrait && values.landscape) { + throw new Error(`--portrait and --landscape are opposites; pass at most one\n${USAGE}`); + } + const framing: Framing = values.portrait ? 'portrait' : values.landscape ? 'landscape' : 'any'; + + return { key, seed, gender, locale: values.locale ?? DEFAULT_LOCALE, framing }; +} + +async function main(): Promise { + let opts: PresenterOptions; + try { + opts = parsePresenterArgs(process.argv.slice(2)); + } catch (e) { + console.error(e instanceof Error ? e.message : e); + process.exit(2); + } + + const token = await resolveToken({ + envToken: process.env.VEED_ACCESS_TOKEN, tokenPath: DEFAULT_TOKEN_PATH, expectedOrigin: VEED_ORIGIN, + }); + if (!token) { + console.error( + [ + 'No VEED login found. Log in once (the same login Fabric generation uses):', + '', + ' node --import tsx veed/login.ts', + '', + 'It stores a refreshable token, owner-only, at', + ` ${DEFAULT_TOKEN_PATH}`, + ].join('\n'), + ); + process.exit(1); + } + + const client = realHttp(token); + const pool = await collectPresenterPool(client, { locale: opts.locale, gender: opts.gender }); + console.log(formatProposal(samplePresenter(pool, opts), opts.key ?? DEFAULT_KEY)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((e: unknown) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); + }); +} diff --git a/veed/voice-rates.ts b/veed/voice-rates.ts new file mode 100644 index 0000000..1dff6e9 --- /dev/null +++ b/veed/voice-rates.ts @@ -0,0 +1,106 @@ +// How fast a voice actually speaks, in characters per second. +// +// This exists because the cost of a generation is per SECOND of finished video, while the only thing we +// hold before spending is a script. Converting one to the other needs a speaking rate, and a speaking +// rate is a property of the VOICE: measured voices run from 11.2 to 18.1 characters a second, a 62% +// spread, so no single constant is right for more than one of them. +// +// The API cannot tell us: listVoices carries a `wordsPerMinute` field, but it is unset (-1) for the +// great majority of voices, so nothing can be built on it. The rate is MEASURED instead — every paid run +// records the script length and the finished duration, and that is an observation about that voice. +// +// Two consequences shape the design. A rate we have not measured is genuinely unknown, so the estimate +// is a RANGE across the rates we do know rather than a point that pretends otherwise. And the single +// figure we put next to it leans SLOW — the lower tertile of known rates — because a slow voice makes a +// longer video and a bigger bill, so under-quoting is the direction that surprises someone after they +// have already approved. +import { join } from 'node:path'; +import { REPO_ROOT } from '../config.ts'; + +export const DEFAULT_VOICE_RATES_PATH = join(REPO_ROOT, 'veed', '.veed-voice-rates.json'); + +export interface VoiceRate { + charsPerSecond: number; + // How many runs are behind the figure, so a single sample is never mistaken for a settled one. + samples: number; +} + +export type VoiceRates = Record; + +// Measured, one paid run each unless noted. Kept in the source as the floor of what is known, so a fresh +// checkout with no learned file still estimates from evidence rather than from a guess. +export const SEEDED_RATES: VoiceRates = { + '2mltbVQP21Fq8XgIfRQJ': { charsPerSecond: 18.1, samples: 4 }, // Axell + OvO0rJbpandgx1bK263a: { charsPerSecond: 11.2, samples: 1 }, // Teddy — the slowest seen + m2eiQmRlw2jKNs6qW86Z: { charsPerSecond: 15.8, samples: 1 }, // Drew C + dAlhI9qAHVIjXuVppzhW: { charsPerSecond: 16.7, samples: 1 }, // Tamsin +}; + +// The lower tertile of everything known: a third of voices are slower than this, two thirds faster. Used +// when the voice at hand has never been measured. Deliberately not the mean — the mean splits the error +// evenly, and the two directions are not equally bad. +export function conservativeRate(rates: VoiceRates = SEEDED_RATES): number { + const known = Object.values(rates).map((r) => r.charsPerSecond).sort((a, b) => a - b); + if (known.length === 0) return 14; + const at = (known.length - 1) / 3; + const lo = known[Math.floor(at)]; + const hi = known[Math.ceil(at)]; + return Number((lo + (hi - lo) * (at - Math.floor(at))).toFixed(1)); +} + +// The envelope the estimate is honest about when the voice is unknown: slowest and fastest seen. +export function rateRange(rates: VoiceRates = SEEDED_RATES): { slow: number; fast: number } { + const known = Object.values(rates).map((r) => r.charsPerSecond); + if (known.length === 0) return { slow: 11, fast: 18 }; + return { slow: Math.min(...known), fast: Math.max(...known) }; +} + +export function rateFor(voiceId: string, rates: VoiceRates = SEEDED_RATES): VoiceRate | undefined { + return rates[voiceId]; +} + +// The rate a quote should actually use: what was measured for this voice, or the conservative default +// when it has never been heard. `samples: null` marks the second case, so a caller can say "estimated" +// rather than presenting a stand-in as a measurement. +export function resolveRate(voiceId: string, rates: VoiceRates = SEEDED_RATES): { charsPerSecond: number; samples: number | null } { + const known = rates[voiceId]; + return known ? { charsPerSecond: known.charsPerSecond, samples: known.samples } : { charsPerSecond: conservativeRate(rates), samples: null }; +} + +// A new observation folded into what a voice was already known to do. A running mean, weighted by how +// many runs are behind each figure, so one unusual script cannot swing a well-measured voice. +export function withObservation(rates: VoiceRates, voiceId: string, charsPerSecond: number): VoiceRates { + if (!Number.isFinite(charsPerSecond) || charsPerSecond <= 0) return rates; + const prior = rates[voiceId]; + if (!prior) return { ...rates, [voiceId]: { charsPerSecond: Number(charsPerSecond.toFixed(1)), samples: 1 } }; + const samples = prior.samples + 1; + const mean = (prior.charsPerSecond * prior.samples + charsPerSecond) / samples; + return { ...rates, [voiceId]: { charsPerSecond: Number(mean.toFixed(1)), samples } }; +} + +// A corrupt or truncated file reads as "nothing learned yet": re-measuring costs nothing, while acting +// on a half-parsed rate would quote a figure from noise. +export function parseVoiceRates(raw: string | null): VoiceRates { + if (raw === null) return { ...SEEDED_RATES }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ...SEEDED_RATES }; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return { ...SEEDED_RATES }; + const learned: VoiceRates = {}; + for (const [id, value] of Object.entries(parsed as Record)) { + const r = value as Partial; + if (typeof r?.charsPerSecond === 'number' && Number.isFinite(r.charsPerSecond) && r.charsPerSecond > 0) { + learned[id] = { charsPerSecond: r.charsPerSecond, samples: typeof r.samples === 'number' && r.samples > 0 ? r.samples : 1 }; + } + } + // Seeds are the floor, learned values win: a voice measured here is better known than one measured once + // on someone else's machine. + return { ...SEEDED_RATES, ...learned }; +} + +export function serializeVoiceRates(rates: VoiceRates): string { + return `${JSON.stringify(rates, null, 2)}\n`; +} diff --git a/veed/workspace-store.ts b/veed/workspace-store.ts new file mode 100644 index 0000000..088f233 --- /dev/null +++ b/veed/workspace-store.ts @@ -0,0 +1,33 @@ +// Remembers which workspace the user chose to have billed, so the choice is made once rather than every +// session. It lives beside the login it belongs to (and is gitignored the same way): it names an account +// resource that spends real money, so it is local state, never something to commit or share. +import { join } from 'node:path'; +import { REPO_ROOT } from '../config.ts'; + +// The one place this file's location is defined; veed/generate.ts reads and writes it through its injected +// state helpers, which is why nothing here touches the filesystem. +export const DEFAULT_WORKSPACE_PATH = join(REPO_ROOT, 'veed', '.veed-workspace.json'); + +export interface WorkspaceChoice { + workspaceId: string; + workspaceName?: string; + chosenAt: number; +} + +// A corrupt or truncated file is treated as "no choice yet": asking again costs nothing, while acting on a +// half-read workspace id would spend somewhere nobody picked. +export function parseWorkspaceChoice(raw: string | null): WorkspaceChoice | null { + if (raw === null) return null; + let parsed: WorkspaceChoice; + try { + parsed = JSON.parse(raw) as WorkspaceChoice; + } catch { + return null; + } + if (typeof parsed?.workspaceId !== 'string' || parsed.workspaceId === '') return null; + return parsed; +} + +export function serializeWorkspaceChoice(choice: WorkspaceChoice): string { + return `${JSON.stringify(choice, null, 2)}\n`; +} diff --git a/veed/workspace.ts b/veed/workspace.ts index 0ad5ba0..24ba3c2 100644 --- a/veed/workspace.ts +++ b/veed/workspace.ts @@ -1,14 +1,15 @@ // Which VEED workspace a run bills, resolved in ONE place. // -// A workspace is an account-level billing decision, and more than one thing spends against it: -// transcription draws VEED credits today, and anything else that bills will draw its own. So the question -// is answered here, once, rather than by each caller in its own way — transcription used to silently take -// the first workspace the listing returned, which is exactly how a user ends up billed somewhere they -// never named. +// This is deliberately not part of the Fabric client. A workspace is an account-level billing decision, +// and more than one thing spends against it: transcription draws VEED credits, generation draws AI +// Playground credits, and whatever comes next will draw something else. Two callers previously answered +// this question differently — transcription silently took the first workspace it was handed, while +// generation refused to take even a single one — which is exactly the kind of split that leaves a user +// billed somewhere they never named. // -// The rule: an explicit choice wins; otherwise, if the account holds exactly ONE workspace there is -// nothing to decide, so take it and SAY which — informing is not the same as choosing for someone. Only a -// genuine ambiguity, several workspaces and no answer, is a question. +// The rule, once: an explicit choice wins; otherwise a remembered one; otherwise, if the account holds +// exactly ONE workspace there is nothing to decide, so take it and SAY which — informing is not the same +// as choosing for someone. Only a genuine ambiguity, several workspaces and no prior answer, is a question. import { type VeedHttp, listWorkspaces } from './api.ts'; import { getAllowances } from './allowances.ts'; @@ -19,18 +20,17 @@ export interface WorkspaceCredits { // may want to spend from, so it is shown rather than hidden — and an unreadable balance is never read as // an empty one. credits: number | null; - ttsSeconds: number | null; } -export type WorkspaceSource = 'flag' | 'only-one'; +export type WorkspaceSource = 'flag' | 'remembered' | 'only-one'; export type Resolution = | { kind: 'resolved'; workspace: WorkspaceCredits; source: WorkspaceSource } // Several to choose between and nobody has chosen: the caller asks, this never guesses. | { kind: 'must-choose'; workspaces: WorkspaceCredits[] }; -// Every workspace with both allowances, for the one case that needs the comparison: asking the user to -// choose. Balances are best-effort — an unreadable one is shown as unknown, never as zero, and never +// Every workspace with its credit balance, for the one case that needs the comparison: asking the user +// to choose. Balances are best-effort — an unreadable one is shown as unknown, never as zero, and never // blocks a run, because it is not evidence of an empty workspace. export async function listWorkspacesWithCredits(http: VeedHttp): Promise { const workspaces = await listWorkspaces(http); @@ -41,9 +41,9 @@ export async function listWorkspacesWithCredits(http: VeedHttp): Promise { try { const a = await getAllowances(http, id); - return { id, name, credits: a.aiPlaygroundCredits, ttsSeconds: a.textToSpeechSeconds }; + return { id, name, credits: a.aiPlaygroundCredits }; } catch { - return { id, name, credits: null, ttsSeconds: null }; + return { id, name, credits: null }; } } @@ -51,9 +51,11 @@ export async function resolveWorkspace(opts: { http: VeedHttp; // Named on the command line; wins over everything, and is not second-guessed against the listing. explicit?: string; + // The choice remembered from a previous run. + remembered?: string; }): Promise { - const named = opts.explicit; - const source: WorkspaceSource = 'flag'; + const named = opts.explicit ?? opts.remembered; + const source: WorkspaceSource = opts.explicit ? 'flag' : 'remembered'; const workspaces = await listWorkspaces(opts.http); if (workspaces.length === 0) throw new Error('no VEED workspaces on this account — nothing can be billed'); @@ -79,31 +81,46 @@ export function allUnpriced(workspaces: WorkspaceCredits[]): boolean { return workspaces.length > 0 && workspaces.every((w) => w.credits === null); } -// What the user reads before naming the workspace they want billed. Both allowances, because a run can be -// stopped by either of them. +// What the user reads before naming the workspace they want billed: the AI Playground credit balance a +// generation spends against. export function formatWorkspaceTable(workspaces: WorkspaceCredits[]): string { const w = (pick: (x: WorkspaceCredits) => string, min: number): number => Math.max(min, ...workspaces.map((x) => pick(x).length)); const credits = (x: WorkspaceCredits): string => (x.credits === null ? 'unknown' : String(x.credits)); - const tts = (x: WorkspaceCredits): string => (x.ttsSeconds === null ? 'unknown' : `${x.ttsSeconds}s`); const idW = w((x) => x.id, 2); const nameW = w((x) => x.name, 4); const creditW = w(credits, 7); - const row = (a: string, b: string, c: string, d: string): string => - ` ${a.padEnd(idW)} ${b.padEnd(nameW)} ${c.padStart(creditW)} ${d}`; + const row = (a: string, b: string, c: string): string => + ` ${a.padEnd(idW)} ${b.padEnd(nameW)} ${c.padStart(creditW)}`; return [ - row('ID', 'NAME', 'CREDITS', 'TEXT-TO-SPEECH'), - ...workspaces.map((x) => row(x.id, x.name, credits(x), tts(x))), + row('ID', 'NAME', 'CREDITS'), + ...workspaces.map((x) => row(x.id, x.name, credits(x))), ].join('\n'); } -// The one-liner a run prints once it knows whose credits it is about to use. Says which workspace, what it -// holds, and — when nobody chose it — why it did not ask. +// Why this workspace was used, when nobody named it. Empty for an explicit choice — a flag needs no +// justification back to the person who typed it. +function whyChosen(source: WorkspaceSource): string { + return source === 'only-one' + ? ' — the only workspace on this account, so nothing was asked' + : source === 'remembered' + ? ' — remembered from a previous run' + : ''; +} + +// The one-liner the FABRIC generation path prints once it knows whose credits it is about to use. The +// balance shown is the AI Playground pool, which is the one generation draws — do NOT reuse this for +// transcription, whose spend is VEED transcription credits (a different allowance this resolver never +// reads); use describeWorkspaceChoice there instead. export function describeChoice(workspace: WorkspaceCredits, source: WorkspaceSource): string { const held = workspace.credits === null ? 'balance unavailable' - : `${workspace.credits} AI Playground credits` + - (workspace.ttsSeconds === null ? '' : `, ${workspace.ttsSeconds}s text-to-speech`); - const why = source === 'only-one' ? ' — the only workspace on this account, so nothing was asked' : ''; - return `billing workspace ${workspace.name} (${workspace.id}) — ${held}${why}`; + : `${workspace.credits} AI Playground credits`; + return `billing workspace ${workspace.name} (${workspace.id}) — ${held}${whyChosen(source)}`; +} + +// The same line WITHOUT a balance, for a caller whose spend is not the AI Playground pool. Naming the AI +// Playground figure next to a VEED-transcription run would show the wrong currency for the operation. +export function describeWorkspaceChoice(workspace: WorkspaceCredits, source: WorkspaceSource): string { + return `billing workspace ${workspace.name} (${workspace.id})${whyChosen(source)}`; }