@tandryukha/aidemo 0.16.1 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,8 +103,10 @@ storyboard.json
103
103
  → compose trim idle · sync · auto-zoom · cards · caption · mux → output/final-demo.mp4
104
104
  ```
105
105
 
106
- **4 · What you get** — `output/final-demo.mp4`, plus a README-ready GIF
107
- (`aidemo gif`) and named stills (`aidemo stills`) from the same take. UI changed?
106
+ **4 · What you get** — `output/final-demo.mp4` with `final-demo.srt`/`.vtt`
107
+ captions beside it (timed to the video; `captions.track` also embeds them as a
108
+ subtitle track viewers can toggle), plus a README-ready GIF (`aidemo gif`) and
109
+ named stills (`aidemo stills`) from the same take. UI changed?
108
110
  Re-run against the same storyboard — no re-recording by hand.
109
111
 
110
112
  The design goal: demos that look **human-made and snappy**, not like an AI
@@ -162,7 +164,9 @@ Voice/captions need `OPENAI_API_KEY` in `.env` — **or** `AIDEMO_TTS_PROVIDER=l
162
164
  (no key, offline), **or** `OPENAI_BASE_URL` at a local server. See
163
165
  [docs/LOCAL_MODELS.md](docs/LOCAL_MODELS.md). No Playwright browser download is
164
166
  needed — the engine uses your system Chrome (`channel: "chrome"`; see
165
- [Setup](#setup) to use a different browser).
167
+ [Setup](#setup) to use a different browser). The one Playwright download is
168
+ its ~2 MB ffmpeg helper, which records the take: `aidemo doctor` checks for it
169
+ and prints the exact install command (`npx playwright install ffmpeg`).
166
170
 
167
171
  [![Quickstart output — the bundled fixture rendered end-to-end](docs/quickstart.gif)](https://github.com/tandryukha/aidemo/releases/download/v0.3.0/quickstart-demo.mp4)
168
172
 
@@ -249,7 +253,9 @@ Codex).
249
253
 
250
254
  ## Setup
251
255
 
252
- Prereqs: **Node 20+**, **Google Chrome**, **ffmpeg + ffprobe** on `PATH`.
256
+ Prereqs: **Node 20+**, **Google Chrome**, **ffmpeg + ffprobe** on `PATH`, and
257
+ Playwright's ~2 MB ffmpeg helper that records the take (one per Playwright
258
+ version; `aidemo doctor` prints the exact install command).
253
259
  Developed and tested on macOS; Linux works for the default (Playwright) capture
254
260
  and `--capture obs`. Run `aidemo doctor` to check your setup.
255
261
 
@@ -282,7 +288,7 @@ demos/<name>/ ← your working area (untracked; scaffold with `aidemo i
282
288
  generated/ storyboard.json timeline.json captions.{srt,vtt,cues.json}
283
289
  recordings/ raw.webm (or raw.mp4 for native/OBS capture)
284
290
  audio/ scene-*.mp3 narration.mp3 voice.json
285
- output/ final-demo.mp4
291
+ output/ final-demo.mp4 final-demo.{srt,vtt} (captions timed to the video)
286
292
  logs/ <command>.log fail-<scene>-<n>.{png,json} (on a failed action)
287
293
  ```
288
294
 
package/docs/AUTHORING.md CHANGED
@@ -104,7 +104,8 @@ so the forecast is in the log next to the take. The same numbers come back
104
104
  `storyboard.json` → **voice** (TTS per scene) → **record** (drives Chrome,
105
105
  injected animated cursor, video + timeline) → **captions** (Whisper word
106
106
  timing) → **compose** (trim idle "thinking" spans, sync each scene's video to
107
- its narration length, overlay captions, mux audio) → `output/final-demo.mp4`.
107
+ its narration length, overlay captions, mux audio) → `output/final-demo.mp4`,
108
+ with `final-demo.srt`/`.vtt` captions beside it, timed to the video.
108
109
 
109
110
  No LLM runs during capture — the recording is a deterministic replay of your
110
111
  fixed action-spec. Voice and captions call an OpenAI-compatible endpoint
@@ -285,10 +286,11 @@ Cinematic keys (all opt-in; omit for the plain look):
285
286
  (per-action `keystrokes` overrides). See *Attention*.
286
287
  - `captions: {position?="bottom"|"top", style?="pill"|"bar"|"none", font?,
287
288
  size?, color?, background?, maxWords?, maxCueMs?, minCueMs?, maxLines?,
288
- maxCharsPerLine?}` — caption strip placement and look; scenes may
289
- override `position` with their own `captions`. Compose also auto-flips a cue
290
- to the top while an overlay occupies the bottom band. See *Attention* and
291
- *Produced look*.
289
+ maxCharsPerLine?, track?=false}` — caption strip placement and look; scenes
290
+ may override `position` with their own `captions`. Compose also auto-flips a
291
+ cue to the top while an overlay occupies the bottom band. `track: true`
292
+ (top level only) also embeds the captions as a soft subtitle track. See
293
+ *Attention* and *Produced look*.
292
294
  - `redact: [{selector, frame?, blur?}]` — **blur regions at compose time**
293
295
  (prices, emails, tokens); scenes may add their own `redact`. See *Attention*.
294
296
  - `hide: [selector, …]` — hide elements at **record** time (teasers, cookie
@@ -583,10 +585,27 @@ shadowed window on a styled canvas, with captions in the padding band below.
583
585
 
584
586
  **Caption styles** (`captions`, top level): `style` is `"pill"` (default —
585
587
  centered rounded box), `"bar"` (full-width band flush with the frame edge; the
586
- usual choice under a `frame`), or `"none"` (no captions burned — the
587
- SRT/VTT files are still written, for players and platforms that render their
588
- own). `font`, `size` (default 30, logical px), `color`, `background` (any
589
- CSS) restyle the strip; `position` still flips per scene / automatically.
588
+ usual choice under a `frame`), or `"none"` (no captions burned — use the
589
+ sidecar files or the subtitle track below instead). `font`, `size` (default
590
+ 30, logical px), `color`, `background` (any CSS) restyle the strip;
591
+ `position` still flips per scene / automatically.
592
+
593
+ **Caption files and subtitle track.** Every compose writes
594
+ `output/final-demo.srt` and `output/final-demo.vtt` (`final-demo.<lang>.*`
595
+ for language renders) beside the video, **timed to the final video**: every
596
+ cue is shifted by the intro card's length, exactly like the burned strip and
597
+ the narration. That's what a player or host (YouTube, Loom, Confluence, an
598
+ LMS) wants when it loads captions by file name. `generated/captions.*` stay
599
+ narration-relative (t=0 is the first narrated word) for the engine's own
600
+ use, so don't ship those next to a video that has an intro card. With
601
+ `captions.track: true` compose also muxes the same cues into the MP4 as a
602
+ `mov_text` subtitle track, tagged with the narration language (`--lang`, else
603
+ the storyboard's `language`, else English) and **off until the viewer turns
604
+ it on**. Re-composing replaces the track rather than adding another. For
605
+ captions a viewer can toggle instead of burned ones, combine
606
+ `style: "none"` with `track: true`; with burned captions as well, a viewer
607
+ who turns the track on sees them twice (lint note `caption-track-burned`).
608
+ `report.json` → `captions.srt` / `.vtt` / `.track` record what was written.
590
609
 
591
610
  **Output presets** (`output.preset`) fill `width`/`height`/`fit` when you
592
611
  don't set them explicitly: `youtube` 1920×1080, `short` 1080×1920,
@@ -606,7 +625,8 @@ artifacts in `output/walkthrough/`: `index.html` (one card per scene with
606
625
  its payoff frame, title, narration and a jump-to-time button over the video;
607
626
  ← / → keys step through, Enter plays from a step), `guide.md` (the same as a
608
627
  README/SOP section: heading + frame + narration per scene), `scene-NN-<id>.png`
609
- frames, the SRT/VTT captions, and `walkthrough.json`. Frames come from the
628
+ frames, the SRT/VTT captions (timed to the video, like the sidecars), and
629
+ `walkthrough.json`. Frames come from the
610
630
  **final** video, so they carry zoom, cursor, overlays and the frame. Scene
611
631
  `title`s name the steps (falling back to ids).
612
632
 
@@ -1162,7 +1182,11 @@ off), focus events kept vs dropped, and `warnings[]` —
1162
1182
  the narration even at the x1.6 ceiling and were cut), `blank-tail`,
1163
1183
  `stale-captions`, `focus-dropped`, `cursor-missing`, `anchor-unreachable`
1164
1184
  (an anchored action could not reach its word within the retime limits; the
1165
- scene's `anchors[]` shows `targetMs` / `landedMs` / `offMs`). A held scene
1185
+ scene's `anchors[]` shows `targetMs` / `landedMs` / `offMs`),
1186
+ `caption-track-empty` (`captions.track` with no caption cues: run `captions`
1187
+ first) and `caption-track-failed` (the ffmpeg build could not mux the
1188
+ subtitle track; the video ships without it and the sidecars still carry the
1189
+ captions). A held scene
1166
1190
  is a storyboard problem, not a compose problem: give it on-screen beats or
1167
1191
  shorten its narration, then re-run `voice` + `compose`.
1168
1192
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tandryukha/aidemo",
3
- "version": "0.16.1",
3
+ "version": "0.17.1",
4
4
  "license": "MIT",
5
5
  "description": "AI Demo Engine — turn a storyboard into a narrated, captioned product demo video",
6
6
  "type": "module",
@@ -37,12 +37,11 @@
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "1.32.2",
39
39
  "commander": "^12.1.0",
40
- "openai": "^4.67.0",
40
+ "openai": "^6.49.0",
41
41
  "playwright": "^1.55.1",
42
- "re2js": "2.8.6",
42
+ "re2js": "2.9.1",
43
43
  "tsx": "^4.23.1",
44
- "zod": "^3.23.8",
45
- "zod-to-json-schema": "3.25.2"
44
+ "zod": "^4.6.7"
46
45
  },
47
46
  "devDependencies": {
48
47
  "@types/node": "^22.20.1",
package/src/browser.ts CHANGED
@@ -8,10 +8,72 @@
8
8
  * the options are exactly the engine's historical `{ channel: "chrome", ... }`.
9
9
  */
10
10
  import { chromium, type Browser, type BrowserContext, type LaunchOptions } from "playwright";
11
+ import { readFileSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import { homedir } from "node:os";
14
+ import { dirname, isAbsolute, join, resolve } from "node:path";
11
15
  import { BrowserSetupError, chromeLaunchOptions, type ChromeLaunchTarget } from "./config.js";
12
16
 
13
17
  type PersistentOptions = NonNullable<Parameters<typeof chromium.launchPersistentContext>[1]>;
14
18
 
19
+ /** Executable name of Playwright's ffmpeg helper, per platform. */
20
+ const FFMPEG_HELPER_EXE: Partial<Record<NodeJS.Platform, string>> = {
21
+ linux: "ffmpeg-linux",
22
+ darwin: "ffmpeg-mac",
23
+ win32: "ffmpeg-win64.exe",
24
+ };
25
+
26
+ /**
27
+ * Playwright's own ffmpeg helper: the encoder recordVideo writes every take
28
+ * with. It is neither the system ffmpeg (compose) nor a browser — a ~2 MB
29
+ * download per Playwright ffmpeg *revision*, and no npm install fetches it
30
+ * (playwright has no install script). So a fresh npx install, or a Playwright
31
+ * minor that bumps the revision (1.64: 1011 → 1013, with VP9 encoding), leaves
32
+ * it missing and the take fails at launch. Mirrors playwright-core's
33
+ * documented lookup (PLAYWRIGHT_BROWSERS_PATH, else the per-OS
34
+ * ms-playwright cache dir). `install` is the exact command for THIS engine's
35
+ * Playwright. null when the location can't be determined.
36
+ */
37
+ export function recorderFfmpeg(): { path: string; revision: string; install: string } | null {
38
+ try {
39
+ const exe = FFMPEG_HELPER_EXE[process.platform];
40
+ if (!exe) return null;
41
+ const req = createRequire(import.meta.url);
42
+ const playwrightPkg = req.resolve("playwright/package.json");
43
+ // playwright-core is playwright's dependency: resolve it from there.
44
+ const corePkg = createRequire(playwrightPkg).resolve("playwright-core/package.json");
45
+ const coreDir = dirname(corePkg);
46
+ const browsers = JSON.parse(readFileSync(join(coreDir, "browsers.json"), "utf8")) as {
47
+ browsers: Array<{ name: string; revision: string }>;
48
+ };
49
+ const revision = browsers.browsers.find((b) => b.name === "ffmpeg")?.revision;
50
+ if (!revision) return null;
51
+ const env = process.env.PLAYWRIGHT_BROWSERS_PATH;
52
+ let registry =
53
+ env === "0"
54
+ ? join(coreDir, ".local-browsers")
55
+ : env || join(defaultPlaywrightCacheDir(), "ms-playwright");
56
+ if (!isAbsolute(registry)) registry = resolve(process.env.INIT_CWD || process.cwd(), registry);
57
+ const cli = join(dirname(playwrightPkg), "cli.js");
58
+ return {
59
+ path: join(registry, `ffmpeg-${revision}`, exe),
60
+ revision,
61
+ install: `node "${cli}" install ffmpeg`,
62
+ };
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function defaultPlaywrightCacheDir(): string {
69
+ if (process.platform === "darwin") return join(homedir(), "Library", "Caches");
70
+ if (process.platform === "win32") return process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local");
71
+ return process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
72
+ }
73
+
74
+ /** A launch that failed on Playwright's missing ffmpeg helper, not on the browser. */
75
+ const FFMPEG_HELPER_RE = /playwright install ffmpeg|[\\/]ffmpeg-\d+[\\/]ffmpeg/i;
76
+
15
77
  /** Human-readable description of the configured browser, for logs/doctor. */
16
78
  export function describeChromeTarget(target: ChromeLaunchTarget = chromeLaunchOptions()): string {
17
79
  if ("executablePath" in target) return `executable ${target.executablePath} (AIDEMO_CHROME_EXECUTABLE)`;
@@ -31,6 +93,20 @@ const MISSING_RE =
31
93
  export function explainLaunchError(err: unknown, target: ChromeLaunchTarget): Error {
32
94
  const e = err instanceof Error ? err : new Error(String(err));
33
95
  if (!MISSING_RE.test(e.message)) return e;
96
+ if (FFMPEG_HELPER_RE.test(e.message)) {
97
+ // The browser is fine — Playwright's recording helper is what's missing.
98
+ // Blaming Chrome here sent users to reinstall a browser that works.
99
+ const helper = recorderFfmpeg();
100
+ const out = new BrowserSetupError(
101
+ `Playwright's ffmpeg helper${helper ? ` (revision ${helper.revision})` : ""} is missing — ` +
102
+ `the ~2 MB encoder Playwright records the take with (not the system ffmpeg, not a ` +
103
+ `browser; no npm install fetches it). Install it once per Playwright version: ` +
104
+ `${helper?.install ?? "npx playwright install ffmpeg"}` +
105
+ `\nOriginal error: ${e.message}`
106
+ );
107
+ (out as Error & { cause?: unknown }).cause = e;
108
+ return out;
109
+ }
34
110
  let hint: string;
35
111
  if ("executablePath" in target) {
36
112
  hint =
@@ -0,0 +1,176 @@
1
+ import { promises as fs } from "node:fs";
2
+ import type { Project } from "./project.js";
3
+ import { toSrt, toVtt, type Cue } from "./captions.js";
4
+ import { exists, readJson } from "./util.js";
5
+
6
+ /**
7
+ * Captions timed to the FINAL video (issue #89).
8
+ *
9
+ * generated/captions.* are narration-relative: t=0 is the start of the
10
+ * narration track. The final MP4 opens on the intro card, and compose delays
11
+ * the narration (adelay) and the burned cues by the card's length — so those
12
+ * files run early by exactly that much against final-demo.mp4. Compose writes
13
+ * re-timed copies beside the video (what players and hosts pick up by file
14
+ * name) and, with `captions.track`, muxes them as an off-by-default subtitle
15
+ * track. The walkthrough bundle ships the same re-timed cues.
16
+ */
17
+
18
+ /** Cues re-timed onto the final video: the intro card delays every cue by `introMs`. */
19
+ export function shiftCues(cues: Cue[], introMs: number): Cue[] {
20
+ if (!introMs) return cues;
21
+ return cues.map((c) => ({ ...c, startMs: c.startMs + introMs, endMs: c.endMs + introMs }));
22
+ }
23
+
24
+ /**
25
+ * This project's caption cues (language-scoped), re-timed onto the final
26
+ * video. null when the captions stage has produced none.
27
+ */
28
+ export async function finalVideoCues(project: Project, introMs: number): Promise<Cue[] | null> {
29
+ if (!(await exists(project.captionsCuesPath))) return null;
30
+ const cues = (await readJson<Cue[]>(project.captionsCuesPath)) ?? [];
31
+ return cues.length ? shiftCues(cues, introMs) : null;
32
+ }
33
+
34
+ /**
35
+ * Write output/final-demo[.<lang>].srt/.vtt for these cues, or — when there
36
+ * are none — remove sidecars an earlier compose left, so a stale file never
37
+ * sits beside a video it doesn't match.
38
+ */
39
+ export async function writeCaptionSidecars(
40
+ project: Project,
41
+ cues: Cue[] | null
42
+ ): Promise<{ srt: string; vtt: string } | null> {
43
+ if (!cues?.length) {
44
+ await Promise.all(
45
+ [project.outputSrtPath, project.outputVttPath].map((p) => fs.rm(p, { force: true }))
46
+ );
47
+ return null;
48
+ }
49
+ await fs.writeFile(project.outputSrtPath, toSrt(cues));
50
+ await fs.writeFile(project.outputVttPath, toVtt(cues));
51
+ return { srt: project.outputSrtPath, vtt: project.outputVttPath };
52
+ }
53
+
54
+ /** ISO 639-1 → ISO 639-2/T, the three-letter code an MP4 track's language carries. */
55
+ const ISO639_2T: Record<string, string> = {
56
+ af: "afr", ar: "ara", bg: "bul", bn: "ben", ca: "cat", cs: "ces", cy: "cym",
57
+ da: "dan", de: "deu", el: "ell", en: "eng", es: "spa", et: "est", eu: "eus",
58
+ fa: "fas", fi: "fin", fr: "fra", ga: "gle", gl: "glg", he: "heb", hi: "hin",
59
+ hr: "hrv", hu: "hun", hy: "hye", id: "ind", is: "isl", it: "ita", ja: "jpn",
60
+ ka: "kat", kk: "kaz", ko: "kor", lt: "lit", lv: "lav", mk: "mkd", ms: "msa",
61
+ mt: "mlt", nb: "nob", nl: "nld", nn: "nno", no: "nor", pl: "pol", pt: "por",
62
+ ro: "ron", ru: "rus", sk: "slk", sl: "slv", sq: "sqi", sr: "srp", sv: "swe",
63
+ sw: "swa", ta: "tam", te: "tel", th: "tha", tl: "tgl", tr: "tur", uk: "ukr",
64
+ ur: "urd", vi: "vie", zh: "zho",
65
+ };
66
+
67
+ /**
68
+ * MP4 language code for a BCP-47 tag ("en", "en-GB", "pt-BR"): its ISO
69
+ * 639-2/T code. A tag that already leads with a three-letter code ("fil",
70
+ * "yue") passes through; anything unknown is "und" (undetermined).
71
+ */
72
+ export function mp4Language(tag: string | undefined): string {
73
+ const primary = (tag ?? "").trim().toLowerCase().split(/[-_]/)[0];
74
+ if (/^[a-z]{3}$/.test(primary)) return primary;
75
+ return ISO639_2T[primary] ?? "und";
76
+ }
77
+
78
+ /** Track handler types that carry subtitles/captions in ISO-BMFF / QuickTime. */
79
+ const SUBTITLE_HANDLERS = new Set(["sbtl", "text", "subt"]);
80
+
81
+ interface Box {
82
+ type: string;
83
+ start: number;
84
+ size: number;
85
+ header: number;
86
+ }
87
+
88
+ /** The boxes laid end to end in buf[from, to); stops at the first malformed one. */
89
+ function* boxes(buf: Buffer, from: number, to: number): Generator<Box> {
90
+ let p = from;
91
+ while (p + 8 <= to) {
92
+ let size = buf.readUInt32BE(p);
93
+ const type = buf.toString("latin1", p + 4, p + 8);
94
+ let header = 8;
95
+ if (size === 1) {
96
+ if (p + 16 > to) return;
97
+ size = Number(buf.readBigUInt64BE(p + 8));
98
+ header = 16;
99
+ } else if (size === 0) {
100
+ size = to - p;
101
+ }
102
+ if (size < header || p + size > to) return;
103
+ yield { type, start: p, size, header };
104
+ p += size;
105
+ }
106
+ }
107
+
108
+ const child = (buf: Buffer, parent: Box, type: string): Box | undefined => {
109
+ for (const b of boxes(buf, parent.start + parent.header, parent.start + parent.size)) {
110
+ if (b.type === type) return b;
111
+ }
112
+ return undefined;
113
+ };
114
+
115
+ /**
116
+ * Clear `track_enabled` in every subtitle track's `tkhd`, in place — one byte
117
+ * per track, nothing moves, so the faststart layout survives. ffmpeg's mov
118
+ * muxer enables the first track of each media type whatever `-disposition`
119
+ * says, and players show an enabled subtitle track from the start; with the
120
+ * flag cleared the track is listed but off until the viewer picks it.
121
+ * `tracks` = subtitle tracks found, `changed` = flags actually cleared.
122
+ */
123
+ export async function disableSubtitleTracks(
124
+ file: string
125
+ ): Promise<{ tracks: number; changed: number }> {
126
+ const fh = await fs.open(file, "r+");
127
+ try {
128
+ const { size: fileSize } = await fh.stat();
129
+ // Top-level boxes by header only (mdat is most of the file): find moov.
130
+ const head = Buffer.alloc(16);
131
+ let pos = 0;
132
+ let moov: { start: number; size: number } | null = null;
133
+ while (pos + 8 <= fileSize) {
134
+ const { bytesRead } = await fh.read(head, 0, 16, pos);
135
+ if (bytesRead < 8) break;
136
+ let size = head.readUInt32BE(0);
137
+ const type = head.toString("latin1", 4, 8);
138
+ if (size === 1) size = bytesRead >= 16 ? Number(head.readBigUInt64BE(8)) : 0;
139
+ else if (size === 0) size = fileSize - pos;
140
+ if (size < 8) break;
141
+ if (type === "moov") {
142
+ moov = { start: pos, size };
143
+ break;
144
+ }
145
+ pos += size;
146
+ }
147
+ if (!moov) return { tracks: 0, changed: 0 };
148
+ const buf = Buffer.alloc(moov.size);
149
+ await fh.read(buf, 0, moov.size, moov.start);
150
+ const moovBox = boxes(buf, 0, buf.length).next().value;
151
+ if (!moovBox) return { tracks: 0, changed: 0 };
152
+ let tracks = 0;
153
+ let changed = 0;
154
+ for (const trak of boxes(buf, moovBox.header, moovBox.size)) {
155
+ if (trak.type !== "trak") continue;
156
+ const tkhd = child(buf, trak, "tkhd");
157
+ const mdia = child(buf, trak, "mdia");
158
+ const hdlr = mdia && child(buf, mdia, "hdlr");
159
+ if (!tkhd || !hdlr) continue;
160
+ // hdlr payload: version(1) flags(3) pre_defined(4) handler_type(4)
161
+ const at = hdlr.start + hdlr.header + 8;
162
+ if (!SUBTITLE_HANDLERS.has(buf.toString("latin1", at, at + 4))) continue;
163
+ tracks++;
164
+ // tkhd payload: version(1) flags(3); track_enabled = bit 0 of the low byte.
165
+ const flags = tkhd.start + tkhd.header + 3;
166
+ if (buf[flags] & 0x01) {
167
+ buf[flags] &= ~0x01;
168
+ await fh.write(buf, flags, 1, moov.start + flags);
169
+ changed++;
170
+ }
171
+ }
172
+ return { tracks, changed };
173
+ } finally {
174
+ await fh.close();
175
+ }
176
+ }
package/src/captions.ts CHANGED
@@ -707,7 +707,7 @@ export function cueStats(cues: Cue[], minCueMs = DEFAULT_SEG.minCueMs): {
707
707
  return { short, midClause, midClausePct: Math.round((midClause / breaks) * 100) };
708
708
  }
709
709
 
710
- function toSrt(cues: Cue[]): string {
710
+ export function toSrt(cues: Cue[]): string {
711
711
  return (
712
712
  cues
713
713
  .map(
@@ -718,7 +718,7 @@ function toSrt(cues: Cue[]): string {
718
718
  );
719
719
  }
720
720
 
721
- function toVtt(cues: Cue[]): string {
721
+ export function toVtt(cues: Cue[]): string {
722
722
  const vt = (ms: number) => srtTime(ms).replace(",", ".");
723
723
  return (
724
724
  "WEBVTT\n\n" +
package/src/compose.ts CHANGED
@@ -19,7 +19,14 @@ import {
19
19
  HoldSchema,
20
20
  AutoIdleSchema,
21
21
  } from "./types.js";
22
- import { sceneWordsFor, type Cue } from "./captions.js";
22
+ import { sceneWordsFor, toSrt, type Cue } from "./captions.js";
23
+ import {
24
+ disableSubtitleTracks,
25
+ finalVideoCues,
26
+ mp4Language,
27
+ writeCaptionSidecars,
28
+ } from "./caption-track.js";
29
+ import { resolveNarrationLanguage } from "./i18n.js";
23
30
  import { wordStartMs } from "./anchors.js";
24
31
  import { renderCaptionPngs } from "./caption-render.js";
25
32
  import { renderCardPng } from "./cards.js";
@@ -1099,8 +1106,60 @@ export async function compose(
1099
1106
  : []),
1100
1107
  ])
1101
1108
  : null;
1102
- await muxAudio(project, storyboard, finalVideo, introMs, chapters);
1109
+ // Captions timed to the FINAL video (issue #89): the cues the strip burns,
1110
+ // shifted by the intro card exactly like the overlay and the narration's
1111
+ // adelay. Always written beside the MP4; `captions.track` also embeds them.
1112
+ const videoCues = await finalVideoCues(project, introMs);
1113
+ let subtitle: SubtitleTrack | null = null;
1114
+ if (storyboard.captions?.track) {
1115
+ if (videoCues) {
1116
+ subtitle = {
1117
+ path: resolve(tmp, "track.srt"),
1118
+ language: mp4Language(resolveNarrationLanguage(storyboard, project.lang) ?? "en"),
1119
+ };
1120
+ await fs.writeFile(subtitle.path, toSrt(videoCues));
1121
+ } else {
1122
+ warn(
1123
+ "caption-track-empty",
1124
+ `captions.track is set but there are no caption cues — run: aidemo captions ${project.dir}`
1125
+ );
1126
+ }
1127
+ }
1128
+ try {
1129
+ await muxAudio(project, storyboard, finalVideo, introMs, chapters, subtitle);
1130
+ } catch (err) {
1131
+ if (!subtitle) throw err;
1132
+ // An ffmpeg build without the srt demuxer / mov_text encoder: keep the
1133
+ // video, drop the track — the sidecars below still carry the captions.
1134
+ // runFfmpeg's message is "ffmpeg exited N" + the stderr tail; ffmpeg
1135
+ // names the root cause on the first stderr line.
1136
+ const [exit, ...stderr] = String((err as Error).message ?? err).split("\n");
1137
+ const why = stderr.map((l) => l.trim()).find(Boolean);
1138
+ warn(
1139
+ "caption-track-failed",
1140
+ `could not embed the subtitle track, so the video has none (the .srt/.vtt sidecars ` +
1141
+ `still carry the captions): ${exit}${why ? ` — ${why.slice(0, 200)}` : ""}`
1142
+ );
1143
+ subtitle = null;
1144
+ await muxAudio(project, storyboard, finalVideo, introMs, chapters);
1145
+ }
1146
+ if (subtitle) {
1147
+ const { tracks } = await disableSubtitleTracks(project.outputPath);
1148
+ if (tracks === 0) {
1149
+ warn("caption-track-failed", "the muxed MP4 carries no subtitle track");
1150
+ subtitle = null;
1151
+ } else {
1152
+ log(`subtitle track: ${subtitle.language} (mov_text, off until the viewer turns it on)`);
1153
+ }
1154
+ }
1103
1155
  ok(`final video → ${project.outputPath}`);
1156
+ const sidecars = await writeCaptionSidecars(project, videoCues);
1157
+ if (sidecars) {
1158
+ ok(
1159
+ `captions → ${relative(project.dir, sidecars.srt)} + .vtt (timed to the video` +
1160
+ `${introMs ? `: cues +${introMs}ms for the intro card` : ""})`
1161
+ );
1162
+ }
1104
1163
  // Blank-open warning + poster (issue #47; measured after scene assembly,
1105
1164
  // above). The poster used to be taken at a fixed offset, which on a demo
1106
1165
  // that opens with a `goto` is exactly the white load frame; pick the first
@@ -1166,7 +1225,12 @@ export async function compose(
1166
1225
  scenes: sceneReports,
1167
1226
  zoom: { focusTotal: zoomEvents.length, focusDropped },
1168
1227
  cursorPoints: cursorPts.length,
1169
- captions: { cues: captionsResult.cues, passes: captionsResult.passes },
1228
+ captions: {
1229
+ cues: captionsResult.cues,
1230
+ passes: captionsResult.passes,
1231
+ ...(sidecars ? { srt: sidecars.srt, vtt: sidecars.vtt } : {}),
1232
+ ...(subtitle ? { track: subtitle.language } : {}),
1233
+ },
1170
1234
  hold: hold.mode,
1171
1235
  ...(poster ? { poster } : {}),
1172
1236
  blankOpenMs: Math.round(blankOpenMs),
@@ -1902,7 +1966,7 @@ async function burnCaptions(
1902
1966
  ): Promise<{ video: string; cues: number; passes: number; flipped: number }> {
1903
1967
  const none = { video: silentVideo, cues: 0, passes: 0, flipped: 0 };
1904
1968
  if (placement.look?.style === "none") {
1905
- log("captions: style none — not burned (SRT/VTT files still written)");
1969
+ log("captions: style none — not burned (the .srt/.vtt beside the video carry them)");
1906
1970
  return none;
1907
1971
  }
1908
1972
  if (!(await exists(project.captionsCuesPath))) return none;
@@ -2007,12 +2071,20 @@ async function fileToDataUri(path: string): Promise<string> {
2007
2071
  return `data:${mime};base64,${buf.toString("base64")}`;
2008
2072
  }
2009
2073
 
2074
+ /** An SRT (already on the final video's clock) to embed as a soft subtitle track. */
2075
+ interface SubtitleTrack {
2076
+ path: string;
2077
+ /** ISO 639-2 code for the track's language tag. */
2078
+ language: string;
2079
+ }
2080
+
2010
2081
  async function muxAudio(
2011
2082
  project: Project,
2012
2083
  storyboard: Storyboard,
2013
2084
  video: string,
2014
2085
  introMs: number,
2015
- chaptersPath: string | null = null
2086
+ chaptersPath: string | null = null,
2087
+ subtitle: SubtitleTrack | null = null
2016
2088
  ): Promise<void> {
2017
2089
  let musicPath = await resolveMusic(project, storyboard);
2018
2090
  const videoMs = await probeDurationMs(video);
@@ -2041,6 +2113,12 @@ async function muxAudio(
2041
2113
  chaptersIdx = musicPath ? 3 : 2;
2042
2114
  args.push("-i", chaptersPath);
2043
2115
  }
2116
+ // Subtitle SRT last, so every index above is unchanged without one.
2117
+ let subtitleIdx = -1;
2118
+ if (subtitle) {
2119
+ subtitleIdx = 2 + (musicPath ? 1 : 0) + (chaptersPath ? 1 : 0);
2120
+ args.push("-i", subtitle.path);
2121
+ }
2044
2122
 
2045
2123
  const delay = introMs > 0 ? `,adelay=${Math.round(introMs)}:all=1` : "";
2046
2124
  const filters: string[] = [];
@@ -2111,6 +2189,7 @@ async function muxAudio(
2111
2189
 
2112
2190
  args.push("-filter_complex", filters.join(";"));
2113
2191
  args.push("-map", "0:v", "-map", "[aout]");
2192
+ if (subtitle) args.push("-map", `${subtitleIdx}:s`);
2114
2193
  if (chaptersIdx >= 0) args.push("-map_metadata", String(chaptersIdx));
2115
2194
  args.push(
2116
2195
  "-c:v",
@@ -2118,7 +2197,21 @@ async function muxAudio(
2118
2197
  "-c:a",
2119
2198
  "aac",
2120
2199
  "-b:a",
2121
- "192k",
2200
+ "192k"
2201
+ );
2202
+ if (subtitle) {
2203
+ // mov_text is the MP4 text-subtitle codec; not default — and compose then
2204
+ // clears the tkhd enabled flag ffmpeg sets anyway (disableSubtitleTracks).
2205
+ args.push(
2206
+ "-c:s",
2207
+ "mov_text",
2208
+ "-metadata:s:s:0",
2209
+ `language=${subtitle.language}`,
2210
+ "-disposition:s:0",
2211
+ "0"
2212
+ );
2213
+ }
2214
+ args.push(
2122
2215
  // moov atom up front so the MP4 streams/scrubs in a browser tab or a PR
2123
2216
  // preview before it has fully downloaded (frames identical; container only).
2124
2217
  "-movflags",
package/src/distribute.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  chromeLaunchOptions,
26
26
  type ChromeLaunchTarget,
27
27
  } from "./config.js";
28
- import { describeChromeTarget, probeConfiguredBrowser } from "./browser.js";
28
+ import { describeChromeTarget, probeConfiguredBrowser, recorderFfmpeg } from "./browser.js";
29
29
  import { localTtsStatus } from "./voice-local.js";
30
30
  import { Project } from "./project.js";
31
31
  import { STARTER_BRIEF, STARTER_STORYBOARD, briefFromInspect, storyboardFromInspect } from "./starter.js";
@@ -659,6 +659,12 @@ export interface DoctorReport {
659
659
  };
660
660
  };
661
661
  playwright: boolean;
662
+ /**
663
+ * Playwright's ffmpeg helper for THIS engine's Playwright — the encoder the
664
+ * built-in capture records every take with (see recorderFfmpeg). null when
665
+ * its location can't be determined.
666
+ */
667
+ recorderFfmpeg: { found: boolean; path: string; revision: string; install: string } | null;
662
668
  skill: {
663
669
  target: string;
664
670
  installed: { version: string; stableAvailable: string | null } | null;
@@ -694,6 +700,7 @@ export async function doctorReport(dir: string | undefined): Promise<DoctorRepor
694
700
  const localTts = ttsProvider() === "local" ? await localTtsStatus() : null;
695
701
  const chrome = await probeChrome();
696
702
  const browser = await probeBrowserTarget(chrome);
703
+ const helper = recorderFfmpeg();
697
704
  const report: DoctorReport = {
698
705
  engineVersion: latest,
699
706
  node: process.version,
@@ -725,6 +732,7 @@ export async function doctorReport(dir: string | undefined): Promise<DoctorRepor
725
732
  : {}),
726
733
  },
727
734
  playwright: playwrightOk,
735
+ recorderFfmpeg: helper ? { found: await exists(helper.path), ...helper } : null,
728
736
  skill: {
729
737
  target,
730
738
  installed: manifest
@@ -741,6 +749,7 @@ export async function doctorReport(dir: string | undefined): Promise<DoctorRepor
741
749
  report.ffmpeg &&
742
750
  report.browser.found &&
743
751
  report.playwright &&
752
+ (report.recorderFfmpeg?.found ?? true) &&
744
753
  // Local TTS needs no key: voice is in-process, captions auto-fall-back
745
754
  // to the offline derivation in `render`.
746
755
  (report.apiKey !== "missing" || report.tts.provider === "local") &&
@@ -806,6 +815,15 @@ export async function doctor(dir: string | undefined): Promise<void> {
806
815
  );
807
816
  if (r.playwright) ok(`playwright: resolvable`);
808
817
  else fail(`playwright: not installed`);
818
+ if (r.recorderFfmpeg?.found) {
819
+ ok(`playwright ffmpeg helper: revision ${r.recorderFfmpeg.revision} (records the take)`);
820
+ } else if (r.recorderFfmpeg) {
821
+ fail(
822
+ `playwright ffmpeg helper: revision ${r.recorderFfmpeg.revision} NOT FOUND at ` +
823
+ `${r.recorderFfmpeg.path} — every take fails without it (~2 MB, once per ` +
824
+ `Playwright version): ${r.recorderFfmpeg.install}`
825
+ );
826
+ }
809
827
 
810
828
  if (r.skill.installed) {
811
829
  ok(
package/src/lint.ts CHANGED
@@ -532,6 +532,27 @@ export function lintStoryboard(
532
532
  fix: "add a `cursor: {}` block (the take is then recorded cursor-free and the cursor is overlaid at compose)",
533
533
  });
534
534
  }
535
+ if (sb.captions?.track && sb.captions.style !== "none") {
536
+ push({
537
+ severity: "info",
538
+ code: "caption-track-burned",
539
+ message:
540
+ "`captions.track` embeds a soft subtitle track while the captions are also burned in — " +
541
+ "a viewer who turns the track on sees every caption twice",
542
+ fix: 'set `captions.style: "none"` for toggleable captions only, or drop `track` to keep just the burned strip',
543
+ });
544
+ }
545
+ for (const scene of sb.scenes) {
546
+ if (scene.captions?.track != null) {
547
+ push({
548
+ severity: "info",
549
+ code: "caption-track-scene",
550
+ scene: scene.id,
551
+ message: "`captions.track` is storyboard-level only — a scene's `captions` just moves the strip",
552
+ fix: "move `track` to the top-level `captions` block",
553
+ });
554
+ }
555
+ }
535
556
  if (sb.zoom && sb.zoom.enabled !== false) {
536
557
  const capped = clampScaleForWidth(sb.zoom.scale, sb.video.width);
537
558
  if (capped < sb.zoom.scale) {
package/src/mcp/server.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
- import { zodToJsonSchema } from "zod-to-json-schema";
5
4
  import { readFile } from "node:fs/promises";
6
5
  import { resolve } from "node:path";
7
6
  import {
@@ -58,6 +57,30 @@ tool (title + body — environment/log context is auto-attached) before you
58
57
  finish; skip if nothing came up. A failed job_status result also carries a
59
58
  feedbackHint pointing back here.`;
60
59
 
60
+ /**
61
+ * JSON Schema for storyboard.json, generated from the engine's zod schema.
62
+ * Input semantics (a field with a default is optional — what an author
63
+ * writes), draft-07, every sub-schema inlined, and unknown keys rejected
64
+ * (`additionalProperties: false`): zod drops unknown keys silently, so the
65
+ * schema says they are not part of the contract. Same contract the
66
+ * zod-to-json-schema export had before the zod 4 migration.
67
+ */
68
+ function storyboardJsonSchema(): Record<string, unknown> {
69
+ return z.toJSONSchema(StoryboardSchema, {
70
+ target: "draft-7",
71
+ io: "input",
72
+ unrepresentable: "any",
73
+ override: (ctx) => {
74
+ if (
75
+ ctx.zodSchema._zod.def.type === "object" &&
76
+ ctx.jsonSchema.additionalProperties === undefined
77
+ ) {
78
+ ctx.jsonSchema.additionalProperties = false;
79
+ }
80
+ },
81
+ }) as Record<string, unknown>;
82
+ }
83
+
61
84
  type ToolResult = {
62
85
  content: Array<{ type: "text"; text: string }>;
63
86
  structuredContent?: Record<string, unknown>;
@@ -100,7 +123,7 @@ const JOB_STATUS_SHAPE = {
100
123
  endedAt: z.string().nullable(),
101
124
  logFile: z.string(),
102
125
  logTail: z.array(z.string()),
103
- result: z.record(z.unknown()).optional(),
126
+ result: z.record(z.string(), z.unknown()).optional(),
104
127
  error: z
105
128
  .object({
106
129
  message: z.string(),
@@ -307,12 +330,12 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
307
330
  "JSON Schema for storyboard.json, generated from the engine's own " +
308
331
  "zod schema — the exact contract the engine validates against.",
309
332
  inputSchema: {},
310
- outputSchema: { schema: z.record(z.unknown()) },
333
+ outputSchema: { schema: z.record(z.string(), z.unknown()) },
311
334
  annotations: { readOnlyHint: true },
312
335
  },
313
336
  async () =>
314
337
  jsonResult({
315
- schema: zodToJsonSchema(StoryboardSchema, { $refStrategy: "none" }),
338
+ schema: storyboardJsonSchema(),
316
339
  })
317
340
  );
318
341
 
@@ -533,7 +556,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
533
556
  description:
534
557
  "Check prereqs: node, ffmpeg, Chrome (or the AIDEMO_CHROME_CHANNEL / " +
535
558
  "AIDEMO_CHROME_EXECUTABLE browser in use), TTS/STT endpoint (flags " +
536
- "LLM-only servers like Ollama), API key, playwright, installed skill.",
559
+ "LLM-only servers like Ollama), API key, playwright + its ffmpeg " +
560
+ "helper (records the take; missing = every take fails), installed skill.",
537
561
  inputSchema: {
538
562
  dir: z.string().optional().describe("repo to check for an installed skill"),
539
563
  },
@@ -563,6 +587,15 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
563
587
  elevenLabsKey: z.enum(["set", "missing"]).optional(),
564
588
  }),
565
589
  playwright: z.boolean(),
590
+ recorderFfmpeg: z
591
+ .object({
592
+ found: z.boolean(),
593
+ path: z.string(),
594
+ revision: z.string(),
595
+ install: z.string().describe("exact command to install it"),
596
+ })
597
+ .nullable()
598
+ .describe("Playwright's ffmpeg helper the built-in capture records with"),
566
599
  skill: z.object({
567
600
  target: z.string(),
568
601
  installed: z
@@ -1103,6 +1136,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1103
1136
  ...(walkthrough ? { walkthrough } : {}),
1104
1137
  timeline: lp.timelinePath,
1105
1138
  captionsSrt: lp.captionsSrtPath,
1139
+ // Sidecars timed to the final video (+ the embedded track's language).
1140
+ captions: report.captions,
1106
1141
  report: lp.reportPath,
1107
1142
  durationMs: report.durationMs,
1108
1143
  // warningCount lets an orchestrating agent gate on the render
@@ -1179,7 +1214,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1179
1214
 
1180
1215
  registerJob(
1181
1216
  "compose",
1182
- "Trim, sync, mux and caption into output/final-demo.mp4.",
1217
+ "Trim, sync, mux and caption into output/final-demo.mp4 (+ final-demo.srt/.vtt timed to it).",
1183
1218
  {
1184
1219
  dir: DIR_INPUT,
1185
1220
  gif: z.boolean().optional().describe("also export output/final-demo.gif"),
@@ -1197,6 +1232,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1197
1232
  return {
1198
1233
  output: lp.outputPath,
1199
1234
  ...(gifPath ? { gif: gifPath } : {}),
1235
+ // Sidecars timed to the final video (+ the embedded track's language).
1236
+ captions: report.captions,
1200
1237
  report: lp.reportPath,
1201
1238
  durationMs: report.durationMs,
1202
1239
  // warningCount lets an orchestrating agent gate on the render
@@ -1438,11 +1475,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1438
1475
  {
1439
1476
  uri: uri.href,
1440
1477
  mimeType: "application/json",
1441
- text: JSON.stringify(
1442
- zodToJsonSchema(StoryboardSchema, { $refStrategy: "none" }),
1443
- null,
1444
- 2
1445
- ),
1478
+ text: JSON.stringify(storyboardJsonSchema(), null, 2),
1446
1479
  },
1447
1480
  ],
1448
1481
  })
package/src/project.ts CHANGED
@@ -12,7 +12,7 @@ import { ensureDir, exists, readJson, writeJson, log } from "./util.js";
12
12
  * generated/storyboard.json timeline.json captions.srt captions.vtt
13
13
  * recordings/raw.webm
14
14
  * audio/scene-*.mp3 narration.mp3 voice.json
15
- * output/final-demo.mp4
15
+ * output/final-demo.mp4 final-demo.srt final-demo.vtt
16
16
  * logs/
17
17
  */
18
18
  export class Project {
@@ -117,6 +117,17 @@ export class Project {
117
117
  get outputPath() {
118
118
  return this.p("output", this.lang ? `final-demo.${this.lang}.mp4` : "final-demo.mp4");
119
119
  }
120
+ /**
121
+ * Caption sidecars named after the video (final-demo[.<lang>].srt/.vtt), so
122
+ * a player that loads captions by file name finds them. Timed to the FINAL
123
+ * video — unlike generated/captions.*, which start at the first narrated word.
124
+ */
125
+ get outputSrtPath() {
126
+ return this.outputPath.replace(/\.mp4$/, ".srt");
127
+ }
128
+ get outputVttPath() {
129
+ return this.outputPath.replace(/\.mp4$/, ".vtt");
130
+ }
120
131
  get gifPath() {
121
132
  return this.p("output", this.lang ? `final-demo.${this.lang}.gif` : "final-demo.gif");
122
133
  }
package/src/types.ts CHANGED
@@ -503,6 +503,15 @@ export const CaptionsConfigSchema = z.object({
503
503
  maxLines: z.number().int().min(1).max(3).optional(),
504
504
  /** Characters per line used to size a cue. Default 38. */
505
505
  maxCharsPerLine: z.number().int().min(16).max(80).optional(),
506
+ /**
507
+ * Also embed the captions in the MP4 as a soft subtitle track (mov_text,
508
+ * tagged with the narration language) that players leave OFF until the
509
+ * viewer turns it on (issue #89). Storyboard level only. Pair it with
510
+ * `style: "none"` for toggleable captions instead of burned ones. Compose
511
+ * always writes the same cues beside the video as final-demo.srt/.vtt;
512
+ * this adds the in-file track. Default false.
513
+ */
514
+ track: z.boolean().optional(),
506
515
  });
507
516
  export type CaptionsConfig = z.infer<typeof CaptionsConfigSchema>;
508
517
 
@@ -1236,7 +1245,8 @@ export type Timeline = z.infer<typeof TimelineSchema>;
1236
1245
 
1237
1246
  export const ComposeWarningSchema = z.object({
1238
1247
  /** Stable code: scene-freeze | overrun-trim | blank-tail | stale-captions |
1239
- * focus-dropped | cursor-missing | scene-no-narration */
1248
+ * focus-dropped | cursor-missing | scene-no-narration | caption-track-empty |
1249
+ * caption-track-failed */
1240
1250
  code: z.string(),
1241
1251
  scene: z.string().optional(),
1242
1252
  message: z.string(),
@@ -1287,7 +1297,16 @@ export const ComposeReportSchema = z.object({
1287
1297
  scenes: z.array(ComposeSceneReportSchema),
1288
1298
  zoom: z.object({ focusTotal: z.number(), focusDropped: z.number() }),
1289
1299
  cursorPoints: z.number(),
1290
- captions: z.object({ cues: z.number(), passes: z.number() }),
1300
+ captions: z.object({
1301
+ /** Cues burned into the picture (0 with `style: "none"`). */
1302
+ cues: z.number(),
1303
+ passes: z.number(),
1304
+ /** Sidecars timed to the final video (intro card included), when there are cues. */
1305
+ srt: z.string().optional(),
1306
+ vtt: z.string().optional(),
1307
+ /** ISO 639-2 language of the embedded subtitle track (`captions.track`). */
1308
+ track: z.string().optional(),
1309
+ }),
1291
1310
  /** Attention overlays drawn (highlight/spotlight/callout), key chips, click rings, redact spans. */
1292
1311
  attention: z
1293
1312
  .object({
@@ -12,6 +12,8 @@ import { resolve, isAbsolute, basename } from "node:path";
12
12
  import { Project } from "./project.js";
13
13
  import { ComposeReportSchema, type Storyboard } from "./types.js";
14
14
  import { runFfmpeg } from "./ffmpeg.js";
15
+ import { toSrt, toVtt } from "./captions.js";
16
+ import { finalVideoCues } from "./caption-track.js";
15
17
  import { ensureDir, exists, readJson, writeJson, log, step } from "./util.js";
16
18
 
17
19
  export interface WalkthroughOptions {
@@ -112,17 +114,16 @@ export async function exportWalkthrough(
112
114
  log(`scene ${r.id}: ${mmss(startMs)} → ${image}`);
113
115
  }
114
116
 
115
- // Captions travel with the walkthrough (players and LMSs want the files).
117
+ // Captions travel with the walkthrough (players and LMSs want the files),
118
+ // timed to the video they accompany — generated/captions.* start at the
119
+ // first narrated word, i.e. early by the intro card (issue #89).
116
120
  const captions: { srt?: string; vtt?: string } = {};
117
- for (const [key, src] of [
118
- ["srt", project.captionsSrtPath],
119
- ["vtt", project.captionsVttPath],
120
- ] as const) {
121
- if (await exists(src)) {
122
- const dst = resolve(dir, basename(src));
123
- await fs.copyFile(src, dst);
124
- captions[key] = dst;
125
- }
121
+ const cues = await finalVideoCues(project, introMs);
122
+ if (cues) {
123
+ captions.srt = resolve(dir, basename(project.captionsSrtPath));
124
+ captions.vtt = resolve(dir, basename(project.captionsVttPath));
125
+ await fs.writeFile(captions.srt, toSrt(cues));
126
+ await fs.writeFile(captions.vtt, toVtt(cues));
126
127
  }
127
128
 
128
129
  const videoRel = `../${basename(video)}`;