@tandryukha/aidemo 0.18.0 → 0.19.0

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
@@ -154,7 +154,7 @@ A bundled fixture store (search → results → cart → checkout) that renders
154
154
  finished demo with zero external dependencies:
155
155
 
156
156
  ```bash
157
- npm install # Node 22+, system Chrome, ffmpeg on PATH
157
+ npm install # Node 22.12+, system Chrome, ffmpeg on PATH
158
158
  node examples/local-demo/serve.mjs # terminal 1: fixture on :8787
159
159
  node bin/aidemo.mjs render examples/local-demo --headless # terminal 2
160
160
  open examples/local-demo/output/final-demo.mp4 # xdg-open on Linux, start on Windows
@@ -254,7 +254,7 @@ Codex).
254
254
 
255
255
  ## Setup
256
256
 
257
- Prereqs: **Node 22+**, **Google Chrome**, **ffmpeg + ffprobe** on `PATH`. The
257
+ Prereqs: **Node 22.12+**, **Google Chrome**, **ffmpeg + ffprobe** on `PATH`. The
258
258
  first take also fetches Playwright's ~2 MB ffmpeg helper, which encodes the
259
259
  recording (once per Playwright version; `AIDEMO_NO_AUTO_INSTALL=1` opts out).
260
260
  Developed and tested on macOS; Linux works for the default (Playwright) capture
@@ -304,9 +304,9 @@ demos/<name>/ ← your working area (untracked; scaffold with `aidemo i
304
304
  ffmpeg helper that encodes the take; off with `AIDEMO_NO_AUTO_INSTALL=1`), and
305
305
  `github.com` (your own `gh`, for `aidemo feedback`). Recording/composing are
306
306
  otherwise fully local. The MCP server is **stdio-only** — no listener.
307
- - **Small, auditable surface:** ~20 source files, 7 runtime deps, MIT. Pin an
307
+ - **Small, auditable surface:** ~50 source files, 7 runtime deps, MIT. Pin an
308
308
  immutable ref if you're wary of the moving `#stable` tag:
309
- `npx -y github:tandryukha/aidemo#v0.14.0` (any released tag).
309
+ `npx -y github:tandryukha/aidemo#v0.18.0` (any released tag).
310
310
  - Full detail: [docs/LOCAL_MODELS.md](docs/LOCAL_MODELS.md#what-leaves-the-machine)
311
311
  · report vulnerabilities privately per [SECURITY.md](SECURITY.md).
312
312
 
package/bin/aidemo.mjs CHANGED
@@ -7,15 +7,16 @@ import { existsSync } from "node:fs";
7
7
  import { dirname, resolve } from "node:path";
8
8
 
9
9
  // Fail fast on an unsupported Node with the fix, instead of an arbitrary crash
10
- // deep inside a dependency (openai 7 needs Node 22). Node 20 reached
11
- // end-of-life on 2026-04-30. Keep in sync with package.json "engines".
12
- const MIN_NODE_MAJOR = 22;
13
- const nodeMajor = Number(process.versions.node.split(".")[0]);
14
- if (nodeMajor < MIN_NODE_MAJOR) {
10
+ // deep inside a dependency (openai 7 needs Node 22, commander 15 22.12). Node
11
+ // 20 reached end-of-life on 2026-04-30. Keep in sync with package.json
12
+ // "engines" and the doctor check (src/distribute.ts).
13
+ const MIN_NODE = [22, 12];
14
+ const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number);
15
+ if (nodeMajor < MIN_NODE[0] || (nodeMajor === MIN_NODE[0] && nodeMinor < MIN_NODE[1])) {
15
16
  console.error(
16
- `aidemo: Node.js ${MIN_NODE_MAJOR}+ is required (found ${process.version}). ` +
17
- `Install Node ${MIN_NODE_MAJOR} LTS or newer (nvm install ${MIN_NODE_MAJOR}; in CI, ` +
18
- `actions/setup-node with node-version: ${MIN_NODE_MAJOR}) and re-run.`
17
+ `aidemo: Node.js ${MIN_NODE.join(".")}+ is required (found ${process.version}). ` +
18
+ `Install Node ${MIN_NODE[0]} LTS or newer (nvm install ${MIN_NODE[0]}; in CI, ` +
19
+ `actions/setup-node with node-version: ${MIN_NODE[0]}) and re-run.`
19
20
  );
20
21
  process.exit(1);
21
22
  }
package/docs/AUTHORING.md CHANGED
@@ -625,8 +625,8 @@ artifacts in `output/walkthrough/`: `index.html` (one card per scene with
625
625
  its payoff frame, title, narration and a jump-to-time button over the video;
626
626
  ← / → keys step through, Enter plays from a step), `guide.md` (the same as a
627
627
  README/SOP section: heading + frame + narration per scene), `scene-NN-<id>.png`
628
- frames, the SRT/VTT captions (timed to the video, like the sidecars), and
629
- `walkthrough.json`. Frames come from the
628
+ frames, the SRT/VTT captions (timed to the video, like the sidecars; the
629
+ page's player offers them as a captions track), and `walkthrough.json`. Frames come from the
630
630
  **final** video, so they carry zoom, cursor, overlays and the frame. Scene
631
631
  `title`s name the steps (falling back to ids).
632
632
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tandryukha/aidemo",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "license": "MIT",
5
5
  "description": "AI Demo Engine — turn a storyboard into a narrated, captioned product demo video",
6
6
  "type": "module",
@@ -8,7 +8,7 @@
8
8
  "aidemo": "bin/aidemo.mjs"
9
9
  },
10
10
  "engines": {
11
- "node": ">=22"
11
+ "node": ">=22.12"
12
12
  },
13
13
  "publishConfig": {
14
14
  "access": "public"
@@ -31,6 +31,7 @@
31
31
  "build": "tsc --noEmit",
32
32
  "serve-example": "node examples/local-demo/serve.mjs",
33
33
  "mcp-smoke": "node test/mcp-smoke.mjs",
34
+ "test": "tsx --test test/issue-64.mjs test/issue-89.mjs test/runtime-setup.mjs test/mcp-schemas.mjs",
34
35
  "test:blank-open": "tsx --test test/blank-open.mjs",
35
36
  "mcp-env-test": "node test/mcp-env.mjs",
36
37
  "guard-openai-key": "tsx scripts/guard-openai-key.ts",
@@ -39,7 +40,7 @@
39
40
  },
40
41
  "dependencies": {
41
42
  "@modelcontextprotocol/sdk": "1.32.2",
42
- "commander": "^12.1.0",
43
+ "commander": "^15.0.0",
43
44
  "openai": "^7.47.0",
44
45
  "playwright": "^1.55.1",
45
46
  "re2js": "2.9.1",
@@ -75,6 +75,21 @@ export function mp4Language(tag: string | undefined): string {
75
75
  return ISO639_2T[primary] ?? "und";
76
76
  }
77
77
 
78
+ /**
79
+ * `srclang` + human label for an HTML `<track>` of captions in `tag` (a BCP-47
80
+ * code: "en", "en-GB", "de"), e.g. { srclang: "en-GB", label: "British English" }.
81
+ */
82
+ export function htmlTrackLanguage(tag: string | undefined): { srclang: string; label: string } {
83
+ const srclang = tag?.trim() || "en";
84
+ let label = srclang;
85
+ try {
86
+ label = new Intl.DisplayNames(["en"], { type: "language" }).of(srclang) ?? srclang;
87
+ } catch {
88
+ // not a valid BCP-47 tag — keep the raw code as the label
89
+ }
90
+ return { srclang, label };
91
+ }
92
+
78
93
  /** Track handler types that carry subtitles/captions in ISO-BMFF / QuickTime. */
79
94
  const SUBTITLE_HANDLERS = new Set(["sbtl", "text", "subt"]);
80
95
 
package/src/capture.ts CHANGED
@@ -348,7 +348,7 @@ interface ObsPending {
348
348
  }
349
349
 
350
350
  /**
351
- * Minimal obs-websocket v5 client on Node's global WebSocket (Node 22+).
351
+ * Minimal obs-websocket v5 client on Node's global WebSocket.
352
352
  * Assumes the current OBS scene captures the FULL primary display (Display
353
353
  * Capture) — the recorder crops to the viewport afterwards, same as native.
354
354
  */
@@ -366,11 +366,6 @@ export class ObsCapture implements CaptureProvider {
366
366
  }
367
367
 
368
368
  async start(): Promise<void> {
369
- if (typeof WebSocket === "undefined") {
370
- throw new Error(
371
- "OBS capture needs Node 22+ (built-in WebSocket). Use --capture native instead."
372
- );
373
- }
374
369
  await this.connect();
375
370
  await this.request("StartRecord", {});
376
371
  await sleep(500); // let OBS write its first frames
package/src/distribute.ts CHANGED
@@ -685,10 +685,12 @@ export interface DoctorReport {
685
685
  ok: boolean;
686
686
  }
687
687
 
688
- /** Oldest supported Node major (bin/aidemo.mjs fails fast below it; package.json "engines"). */
689
- const MIN_NODE_MAJOR = 22;
690
- const nodeSupported = (version: string): boolean =>
691
- Number(version.replace(/^v/, "").split(".")[0]) >= MIN_NODE_MAJOR;
688
+ /** Oldest supported Node (bin/aidemo.mjs fails fast below it; package.json "engines"). */
689
+ const MIN_NODE: [number, number] = [22, 12];
690
+ export const nodeSupported = (version: string): boolean => {
691
+ const [major, minor] = version.replace(/^v/, "").split(".").map(Number);
692
+ return major > MIN_NODE[0] || (major === MIN_NODE[0] && minor >= MIN_NODE[1]);
693
+ };
692
694
 
693
695
  /** Build the structured preflight report (used by CLI doctor + MCP doctor). */
694
696
  export async function doctorReport(dir: string | undefined): Promise<DoctorReport> {
@@ -794,7 +796,7 @@ export async function doctor(dir: string | undefined): Promise<void> {
794
796
  val ? ok(`${label}: ${val}`) : fail(`${label}: NOT FOUND`);
795
797
 
796
798
  if (nodeSupported(r.node)) ok(`node: ${r.node}`);
797
- else fail(`node: ${r.node} — aidemo needs Node ${MIN_NODE_MAJOR}+ (Node 20 is end-of-life)`);
799
+ else fail(`node: ${r.node} — aidemo needs Node ${MIN_NODE.join(".")}+ (Node 20 is end-of-life)`);
798
800
  line("ffmpeg", r.ffmpeg);
799
801
  if (r.browser.source === "default") {
800
802
  line("chrome", r.chrome);
package/src/embed.ts CHANGED
@@ -12,10 +12,12 @@
12
12
  * hits the network beyond the local `git` invocation, and never renders.
13
13
  */
14
14
  import { execFile } from "node:child_process";
15
+ import { readFile } from "node:fs/promises";
15
16
  import { promisify } from "node:util";
16
17
  import { resolve, isAbsolute, basename } from "node:path";
17
18
  import { exists } from "./util.js";
18
19
  import { UPSTREAM_REPO } from "./distribute.js";
20
+ import { htmlTrackLanguage } from "./caption-track.js";
19
21
 
20
22
  const execFileAsync = promisify(execFile);
21
23
 
@@ -49,6 +51,8 @@ export interface EmbedResult {
49
51
  still: string;
50
52
  /** GitHub Pages MP4 (correct video/mp4 MIME for <video>) — needs Pages on the branch. */
51
53
  pagesMp4: string;
54
+ /** GitHub Pages WebVTT captions, timed to the video — the <video> embed's <track>. */
55
+ pagesVtt: string;
52
56
  /** GitHub Pages project-site base. */
53
57
  pagesBase: string;
54
58
  };
@@ -140,12 +144,25 @@ export async function buildEmbed(
140
144
  // GitHub Pages project-site host is the lowercased owner login.
141
145
  const pagesBase = `https://${owner.toLowerCase()}.github.io/${repo}`;
142
146
  const pagesMp4 = `${pagesBase}/media/${demo}/latest.mp4`;
147
+ const pagesVtt = `${pagesBase}/media/${demo}/latest.vtt`;
148
+ // Track language = the storyboard's narration language (best-effort read).
149
+ let language: string | undefined;
150
+ try {
151
+ const sb = JSON.parse(await readFile(resolve(demoDir, "generated", "storyboard.json"), "utf8"));
152
+ if (typeof sb?.language === "string") language = sb.language;
153
+ } catch {
154
+ // no storyboard here (or unreadable) — English
155
+ }
156
+ const { srclang, label } = htmlTrackLanguage(language);
143
157
 
144
158
  const markdownGif = `![${demo} demo](${gif})`;
145
159
  const markdownStill = `![${demo}](${stillUrl})`;
160
+ // crossorigin: the page embedding this is on another origin than Pages, and
161
+ // a cross-origin <track> only loads in CORS mode (Pages sends ACAO: *).
146
162
  const htmlVideo =
147
- `<video controls muted loop playsinline width="960" poster="${stillUrl}">\n` +
163
+ `<video controls muted loop playsinline width="960" crossorigin="anonymous" poster="${stillUrl}">\n` +
148
164
  ` <source src="${pagesMp4}" type="video/mp4" />\n` +
165
+ ` <track kind="captions" src="${pagesVtt}" srclang="${srclang}" label="${label}" />\n` +
149
166
  ` <a href="${gif}">▶ Watch the ${demo} demo</a>\n` +
150
167
  `</video>`;
151
168
 
@@ -158,7 +175,7 @@ export async function buildEmbed(
158
175
  demo,
159
176
  branch: DEMO_MEDIA_BRANCH,
160
177
  still,
161
- urls: { gif, mp4, still: stillUrl, pagesMp4, pagesBase },
178
+ urls: { gif, mp4, still: stillUrl, pagesMp4, pagesVtt, pagesBase },
162
179
  snippets: { markdownGif, markdownStill, htmlVideo },
163
180
  workflow: {
164
181
  path: PUBLISH_WORKFLOW_REL,
package/src/mcp/server.ts CHANGED
@@ -81,6 +81,43 @@ function storyboardJsonSchema(): Record<string, unknown> {
81
81
  }) as Record<string, unknown>;
82
82
  }
83
83
 
84
+ /**
85
+ * Tool input schemas advertise `additionalProperties: false` on every object,
86
+ * as they did before the zod 4 migration: the MCP SDK converts zod 4 schemas
87
+ * with io:"input", which leaves objects open. Metadata only — zod still
88
+ * strips unknown arguments at runtime, exactly as before. (An empty input
89
+ * stays a raw `{}`, which the SDK lists as it always has.)
90
+ */
91
+ function closedInput<Shape extends z.ZodRawShape>(shape: Shape) {
92
+ for (const field of Object.values(shape)) closeObjects(field);
93
+ return z.object(shape).meta({ additionalProperties: false });
94
+ }
95
+
96
+ /** Mark every object schema reachable from `schema` closed (keeps existing metadata). */
97
+ function closeObjects(schema: z.core.$ZodType, seen = new Set<unknown>()): void {
98
+ if (seen.has(schema)) return;
99
+ seen.add(schema);
100
+ const def = schema._zod.def as {
101
+ type: string;
102
+ shape?: Record<string, z.core.$ZodType>;
103
+ innerType?: z.core.$ZodType;
104
+ element?: z.core.$ZodType;
105
+ options?: z.core.$ZodType[];
106
+ valueType?: z.core.$ZodType;
107
+ };
108
+ if (def.type === "object") {
109
+ z.globalRegistry.add(schema, { ...z.globalRegistry.get(schema), additionalProperties: false });
110
+ }
111
+ const children = [
112
+ ...Object.values(def.shape ?? {}),
113
+ ...(def.options ?? []),
114
+ def.innerType,
115
+ def.element,
116
+ def.valueType,
117
+ ];
118
+ for (const child of children) if (child) closeObjects(child, seen);
119
+ }
120
+
84
121
  type ToolResult = {
85
122
  content: Array<{ type: "text"; text: string }>;
86
123
  structuredContent?: Record<string, unknown>;
@@ -279,14 +316,14 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
279
316
  `\`topic\` (${GUIDE_TOPIC_NAMES.join(" | ")}, or any H2 heading prefix) ` +
280
317
  "for one slice instead of the whole ~1000-line guide; start with " +
281
318
  "`core`, then fetch `attention`/`polish`/`chatgpt-apps` as needed.",
282
- inputSchema: {
319
+ inputSchema: closedInput({
283
320
  topic: z
284
321
  .string()
285
322
  .optional()
286
323
  .describe(
287
324
  `Slice: ${GUIDE_TOPIC_NAMES.join(", ")} — or an H2 heading prefix. Omit for the full guide.`
288
325
  ),
289
- },
326
+ }),
290
327
  outputSchema: {
291
328
  guide: z.string(),
292
329
  engineVersion: z.string(),
@@ -348,13 +385,13 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
348
385
  "anything. Pass exactly one of: dir (uses generated/storyboard.json), " +
349
386
  "path (a storyboard file), or json (storyboard JSON as a string). " +
350
387
  "relaxed makes narration optional (probe semantics).",
351
- inputSchema: {
388
+ inputSchema: closedInput({
352
389
  dir: z.string().optional().describe("demo dir → generated/storyboard.json"),
353
390
  path: z.string().optional().describe("path to a storyboard .json file"),
354
391
  json: z.string().optional().describe("storyboard JSON as a string"),
355
392
  relaxed: z.boolean().optional(),
356
393
  params: PARAMS_INPUT,
357
- },
394
+ }),
358
395
  outputSchema: {
359
396
  valid: z.boolean(),
360
397
  storyboardPath: z.string().optional(),
@@ -441,13 +478,13 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
441
478
  "Run it after every storyboard edit, before probe/render. Pass exactly " +
442
479
  "one of dir / path / json. lang lints a narrations[lang] translation at " +
443
480
  "that language's speaking rate.",
444
- inputSchema: {
481
+ inputSchema: closedInput({
445
482
  dir: z.string().optional().describe("demo dir → generated/storyboard.json"),
446
483
  path: z.string().optional().describe("path to a storyboard .json file"),
447
484
  json: z.string().optional().describe("storyboard JSON as a string"),
448
485
  lang: z.string().optional(),
449
486
  params: PARAMS_INPUT,
450
- },
487
+ }),
451
488
  outputSchema: {
452
489
  issues: z.array(LINT_ISSUE_SHAPE),
453
490
  estimate: z.array(
@@ -508,7 +545,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
508
545
  "With fromUrl the page is inspected first (no LLM): its headings become " +
509
546
  "scenes and its unique selectors become the beats + a `_candidates` list — " +
510
547
  "you then write the narration and turn the candidate hover into the real click.",
511
- inputSchema: {
548
+ inputSchema: closedInput({
512
549
  name: z.string().describe("demo name (creates demos/<name>/)"),
513
550
  dir: z.string().optional(),
514
551
  force: z.boolean().optional(),
@@ -519,7 +556,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
519
556
  .object({ width: z.number(), height: z.number() })
520
557
  .optional()
521
558
  .describe("fromUrl: viewport (default 1280x720)"),
522
- },
559
+ }),
523
560
  outputSchema: {
524
561
  demoDir: z.string(),
525
562
  storyboardPath: z.string(),
@@ -559,9 +596,9 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
559
596
  "LLM-only servers like Ollama), API key, playwright + its ffmpeg " +
560
597
  "helper (records the take; installed on the first take unless " +
561
598
  "AIDEMO_NO_AUTO_INSTALL=1), installed skill.",
562
- inputSchema: {
599
+ inputSchema: closedInput({
563
600
  dir: z.string().optional().describe("repo to check for an installed skill"),
564
- },
601
+ }),
565
602
  outputSchema: {
566
603
  engineVersion: z.string(),
567
604
  node: z.string(),
@@ -633,7 +670,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
633
670
  "Owner/repo come from the repo's origin remote; the demo name from the " +
634
671
  "directory basename. Pair with the demo-publish workflow so CI keeps " +
635
672
  "the URLs fresh. See docs/EMBEDS.md.",
636
- inputSchema: {
673
+ inputSchema: closedInput({
637
674
  dir: DIR_INPUT,
638
675
  repo: z
639
676
  .string()
@@ -643,7 +680,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
643
680
  .string()
644
681
  .optional()
645
682
  .describe("still-frame basename under stills/ (default: poster)"),
646
- },
683
+ }),
647
684
  outputSchema: {
648
685
  owner: z.string(),
649
686
  repo: z.string(),
@@ -655,6 +692,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
655
692
  mp4: z.string(),
656
693
  still: z.string(),
657
694
  pagesMp4: z.string(),
695
+ pagesVtt: z.string().describe("captions for the <video> embed's <track>"),
658
696
  pagesBase: z.string(),
659
697
  }),
660
698
  snippets: z.object({
@@ -698,7 +736,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
698
736
  "network call. Call this whenever something felt broken, surprising, " +
699
737
  "or needed a workaround; skip it if the session was clean. Set dryRun " +
700
738
  "to preview the assembled title/body without filing anything.",
701
- inputSchema: {
739
+ inputSchema: closedInput({
702
740
  title: z.string().describe("short issue title"),
703
741
  body: z
704
742
  .string()
@@ -719,7 +757,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
719
757
  .boolean()
720
758
  .optional()
721
759
  .describe("assemble the title/body but don't file anything — preview only"),
722
- },
760
+ }),
723
761
  outputSchema: {
724
762
  filed: z.boolean(),
725
763
  title: z.string(),
@@ -751,10 +789,10 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
751
789
  "compose), log tail, and final result or error (with failure-artifact " +
752
790
  "paths and, on a genuine failure, a feedbackHint pointing at the " +
753
791
  "feedback tool) of a pipeline job.",
754
- inputSchema: {
792
+ inputSchema: closedInput({
755
793
  jobId: z.string(),
756
794
  tailLines: z.number().optional().describe("log lines to return (default 40)"),
757
- },
795
+ }),
758
796
  outputSchema: JOB_STATUS_SHAPE,
759
797
  annotations: { readOnlyHint: true },
760
798
  },
@@ -814,7 +852,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
814
852
  "Best-effort cancel (checked between actions/scenes/stages). A " +
815
853
  "mid-record cancel salvages the partial timeline + footage. The job " +
816
854
  "settles asynchronously — poll job_status for the final state.",
817
- inputSchema: { jobId: z.string() },
855
+ inputSchema: closedInput({ jobId: z.string() }),
818
856
  outputSchema: JOB_STATUS_SHAPE,
819
857
  },
820
858
  async (args) => {
@@ -840,7 +878,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
840
878
  description:
841
879
  `${description} Returns a jobId immediately — poll job_status. ` +
842
880
  `Rejects if another job is running.`,
843
- inputSchema: inputShape,
881
+ inputSchema: closedInput(inputShape),
844
882
  outputSchema: JOB_START_SHAPE,
845
883
  },
846
884
  (async (args: z.infer<z.ZodObject<Shape>>) => {
@@ -1019,12 +1057,12 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1019
1057
  "and selectors become scenes (cut at navigations and async payoffs), narration " +
1020
1058
  "is left as placeholders, and `_notes` lists what was approximated. No LLM, " +
1021
1059
  "no browser. Then write the narration and probe.",
1022
- inputSchema: {
1060
+ inputSchema: closedInput({
1023
1061
  file: z.string().describe("absolute path to trace.zip or the test file"),
1024
1062
  name: z.string().describe("demo name (creates demos/<name>/)"),
1025
1063
  dir: z.string().optional().describe("repo to scaffold into (default: server cwd)"),
1026
1064
  force: z.boolean().optional(),
1027
- },
1065
+ }),
1028
1066
  outputSchema: {
1029
1067
  demoDir: z.string(),
1030
1068
  storyboardPath: z.string(),
@@ -13,7 +13,8 @@ import { Project } from "./project.js";
13
13
  import { ComposeReportSchema, type Storyboard } from "./types.js";
14
14
  import { runFfmpeg } from "./ffmpeg.js";
15
15
  import { toSrt, toVtt } from "./captions.js";
16
- import { finalVideoCues } from "./caption-track.js";
16
+ import { finalVideoCues, htmlTrackLanguage } from "./caption-track.js";
17
+ import { resolveNarrationLanguage } from "./i18n.js";
17
18
  import { ensureDir, exists, readJson, writeJson, log, step } from "./util.js";
18
19
 
19
20
  export interface WalkthroughOptions {
@@ -150,7 +151,15 @@ export async function exportWalkthrough(
150
151
  );
151
152
 
152
153
  const index = resolve(dir, "index.html");
153
- await fs.writeFile(index, renderHtml(title, videoRel, scenes, report.durationMs));
154
+ // The page's player offers the shipped VTT as a captions track (off until
155
+ // the viewer turns it on, like the MP4's own subtitle track).
156
+ const track = captions.vtt
157
+ ? {
158
+ src: basename(captions.vtt),
159
+ ...htmlTrackLanguage(resolveNarrationLanguage(storyboard, project.lang)),
160
+ }
161
+ : undefined;
162
+ await fs.writeFile(index, renderHtml(title, videoRel, scenes, report.durationMs, track));
154
163
  const manifest = resolve(dir, "walkthrough.json");
155
164
  await writeJson(manifest, {
156
165
  title,
@@ -167,7 +176,8 @@ function renderHtml(
167
176
  title: string,
168
177
  videoRel: string,
169
178
  scenes: WalkthroughScene[],
170
- durationMs: number
179
+ durationMs: number,
180
+ track?: { src: string; srclang: string; label: string }
171
181
  ): string {
172
182
  const cards = scenes
173
183
  .map(
@@ -215,7 +225,11 @@ function renderHtml(
215
225
  <h1>${esc(title)}</h1>
216
226
  <div class="hint">${scenes.length} steps · ${mmss(durationMs)} · ← → to move between steps, Enter to play from a step</div>
217
227
  </header>
218
- <video id="v" src="${esc(videoRel)}" controls preload="metadata"></video>
228
+ <video id="v" src="${esc(videoRel)}" controls preload="metadata">${
229
+ track
230
+ ? `<track kind="captions" src="${esc(track.src)}" srclang="${esc(track.srclang)}" label="${esc(track.label)}">`
231
+ : ""
232
+ }</video>
219
233
  <main>${cards}
220
234
  </main>
221
235
  <script>