@tandryukha/aidemo 0.17.1 → 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 +13 -10
- package/bin/aidemo.mjs +15 -0
- package/docs/AUTHORING.md +10 -5
- package/package.json +7 -3
- package/src/browser.ts +77 -2
- package/src/caption-track.ts +15 -0
- package/src/capture.ts +1 -6
- package/src/distribute.ts +48 -14
- package/src/embed.ts +19 -2
- package/src/mcp/server.ts +63 -21
- package/src/recorder.ts +5 -1
- package/src/walkthrough.ts +18 -4
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
|
|
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
|
|
@@ -165,8 +165,9 @@ Voice/captions need `OPENAI_API_KEY` in `.env` — **or** `AIDEMO_TTS_PROVIDER=l
|
|
|
165
165
|
[docs/LOCAL_MODELS.md](docs/LOCAL_MODELS.md). No Playwright browser download is
|
|
166
166
|
needed — the engine uses your system Chrome (`channel: "chrome"`; see
|
|
167
167
|
[Setup](#setup) to use a different browser). The one Playwright download is
|
|
168
|
-
its ~2 MB ffmpeg helper, which
|
|
169
|
-
|
|
168
|
+
its ~2 MB ffmpeg helper, which encodes the take: the first take installs it
|
|
169
|
+
automatically (`AIDEMO_NO_AUTO_INSTALL=1` to manage it yourself with
|
|
170
|
+
`npx playwright install ffmpeg`).
|
|
170
171
|
|
|
171
172
|
[](https://github.com/tandryukha/aidemo/releases/download/v0.3.0/quickstart-demo.mp4)
|
|
172
173
|
|
|
@@ -253,9 +254,9 @@ Codex).
|
|
|
253
254
|
|
|
254
255
|
## Setup
|
|
255
256
|
|
|
256
|
-
Prereqs: **Node
|
|
257
|
-
Playwright's ~2 MB ffmpeg helper
|
|
258
|
-
version; `
|
|
257
|
+
Prereqs: **Node 22.12+**, **Google Chrome**, **ffmpeg + ffprobe** on `PATH`. The
|
|
258
|
+
first take also fetches Playwright's ~2 MB ffmpeg helper, which encodes the
|
|
259
|
+
recording (once per Playwright version; `AIDEMO_NO_AUTO_INSTALL=1` opts out).
|
|
259
260
|
Developed and tested on macOS; Linux works for the default (Playwright) capture
|
|
260
261
|
and `--capture obs`. Run `aidemo doctor` to check your setup.
|
|
261
262
|
|
|
@@ -298,12 +299,14 @@ demos/<name>/ ← your working area (untracked; scaffold with `aidemo i
|
|
|
298
299
|
`postinstall`/`preinstall`).
|
|
299
300
|
- **Network is user-initiated only:** `api.openai.com` (only `voice`/`captions`,
|
|
300
301
|
your key — or a local server via `OPENAI_BASE_URL`), `api.elevenlabs.io` (opt-in),
|
|
301
|
-
`huggingface.co` (download-only, once, for `AIDEMO_TTS_PROVIDER=local`),
|
|
302
|
+
`huggingface.co` (download-only, once, for `AIDEMO_TTS_PROVIDER=local`),
|
|
303
|
+
`cdn.playwright.dev` (download-only, once per Playwright version: the ~2 MB
|
|
304
|
+
ffmpeg helper that encodes the take; off with `AIDEMO_NO_AUTO_INSTALL=1`), and
|
|
302
305
|
`github.com` (your own `gh`, for `aidemo feedback`). Recording/composing are
|
|
303
|
-
fully local. The MCP server is **stdio-only** — no listener.
|
|
304
|
-
- **Small, auditable surface:** ~
|
|
306
|
+
otherwise fully local. The MCP server is **stdio-only** — no listener.
|
|
307
|
+
- **Small, auditable surface:** ~50 source files, 7 runtime deps, MIT. Pin an
|
|
305
308
|
immutable ref if you're wary of the moving `#stable` tag:
|
|
306
|
-
`npx -y github:tandryukha/aidemo#v0.
|
|
309
|
+
`npx -y github:tandryukha/aidemo#v0.18.0` (any released tag).
|
|
307
310
|
- Full detail: [docs/LOCAL_MODELS.md](docs/LOCAL_MODELS.md#what-leaves-the-machine)
|
|
308
311
|
· report vulnerabilities privately per [SECURITY.md](SECURITY.md).
|
|
309
312
|
|
package/bin/aidemo.mjs
CHANGED
|
@@ -6,6 +6,21 @@ import { createRequire } from "node:module";
|
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import { dirname, resolve } from "node:path";
|
|
8
8
|
|
|
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, 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])) {
|
|
16
|
+
console.error(
|
|
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.`
|
|
20
|
+
);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
25
|
const cli = resolve(here, "..", "src", "cli.ts");
|
|
11
26
|
const require = createRequire(import.meta.url);
|
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
|
|
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
|
|
|
@@ -1256,9 +1256,14 @@ CLI. If nothing came up, skip this.
|
|
|
1256
1256
|
…) is set; the executable wins. Use `chromium` when a managed Chrome closes
|
|
1257
1257
|
the automated session after a few seconds ("Target page, context or browser
|
|
1258
1258
|
has been closed").
|
|
1259
|
-
- `doctor` checks Node, ffmpeg, the browser in use,
|
|
1260
|
-
(and flags LLM-only servers like Ollama, which
|
|
1261
|
-
`OPENAI_BASE_URL` at a speech server such as
|
|
1259
|
+
- `doctor` checks Node (22+), ffmpeg, the browser in use, Playwright's ffmpeg
|
|
1260
|
+
helper, the TTS/STT endpoint (and flags LLM-only servers like Ollama, which
|
|
1261
|
+
have no audio endpoints — point `OPENAI_BASE_URL` at a speech server such as
|
|
1262
|
+
speaches instead).
|
|
1263
|
+
- The first take installs Playwright's ~2 MB ffmpeg helper (it encodes the
|
|
1264
|
+
recording; once per Playwright version) and logs `installing Playwright's
|
|
1265
|
+
ffmpeg helper`. If that download is blocked, the error names the manual
|
|
1266
|
+
command; `AIDEMO_NO_AUTO_INSTALL=1` turns the download off.
|
|
1262
1267
|
|
|
1263
1268
|
## Import from Playwright (trace or test file)
|
|
1264
1269
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tandryukha/aidemo",
|
|
3
|
-
"version": "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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"aidemo": "bin/aidemo.mjs"
|
|
9
9
|
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22.12"
|
|
12
|
+
},
|
|
10
13
|
"publishConfig": {
|
|
11
14
|
"access": "public"
|
|
12
15
|
},
|
|
@@ -28,6 +31,7 @@
|
|
|
28
31
|
"build": "tsc --noEmit",
|
|
29
32
|
"serve-example": "node examples/local-demo/serve.mjs",
|
|
30
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",
|
|
31
35
|
"test:blank-open": "tsx --test test/blank-open.mjs",
|
|
32
36
|
"mcp-env-test": "node test/mcp-env.mjs",
|
|
33
37
|
"guard-openai-key": "tsx scripts/guard-openai-key.ts",
|
|
@@ -36,8 +40,8 @@
|
|
|
36
40
|
},
|
|
37
41
|
"dependencies": {
|
|
38
42
|
"@modelcontextprotocol/sdk": "1.32.2",
|
|
39
|
-
"commander": "^
|
|
40
|
-
"openai": "^
|
|
43
|
+
"commander": "^15.0.0",
|
|
44
|
+
"openai": "^7.47.0",
|
|
41
45
|
"playwright": "^1.55.1",
|
|
42
46
|
"re2js": "2.9.1",
|
|
43
47
|
"tsx": "^4.23.1",
|
package/src/browser.ts
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
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 {
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
13
|
import { createRequire } from "node:module";
|
|
13
14
|
import { homedir } from "node:os";
|
|
14
15
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
15
16
|
import { BrowserSetupError, chromeLaunchOptions, type ChromeLaunchTarget } from "./config.js";
|
|
17
|
+
import { log, ok } from "./util.js";
|
|
16
18
|
|
|
17
19
|
type PersistentOptions = NonNullable<Parameters<typeof chromium.launchPersistentContext>[1]>;
|
|
18
20
|
|
|
@@ -34,7 +36,13 @@ const FFMPEG_HELPER_EXE: Partial<Record<NodeJS.Platform, string>> = {
|
|
|
34
36
|
* ms-playwright cache dir). `install` is the exact command for THIS engine's
|
|
35
37
|
* Playwright. null when the location can't be determined.
|
|
36
38
|
*/
|
|
37
|
-
export function recorderFfmpeg(): {
|
|
39
|
+
export function recorderFfmpeg(): {
|
|
40
|
+
path: string;
|
|
41
|
+
revision: string;
|
|
42
|
+
/** This engine's playwright/cli.js — runs Playwright's own installer. */
|
|
43
|
+
cli: string;
|
|
44
|
+
install: string;
|
|
45
|
+
} | null {
|
|
38
46
|
try {
|
|
39
47
|
const exe = FFMPEG_HELPER_EXE[process.platform];
|
|
40
48
|
if (!exe) return null;
|
|
@@ -58,6 +66,7 @@ export function recorderFfmpeg(): { path: string; revision: string; install: str
|
|
|
58
66
|
return {
|
|
59
67
|
path: join(registry, `ffmpeg-${revision}`, exe),
|
|
60
68
|
revision,
|
|
69
|
+
cli,
|
|
61
70
|
install: `node "${cli}" install ffmpeg`,
|
|
62
71
|
};
|
|
63
72
|
} catch {
|
|
@@ -71,6 +80,72 @@ function defaultPlaywrightCacheDir(): string {
|
|
|
71
80
|
return process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
|
|
72
81
|
}
|
|
73
82
|
|
|
83
|
+
/** AIDEMO_NO_AUTO_INSTALL=1: never download the helper (air-gapped / managed caches). */
|
|
84
|
+
export const recorderAutoInstall = (): boolean =>
|
|
85
|
+
!/^(1|true|yes)$/i.test(process.env.AIDEMO_NO_AUTO_INSTALL?.trim() ?? "");
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Before a built-in capture launches, make sure Playwright's ffmpeg helper is
|
|
89
|
+
* there, installing it with Playwright's own installer when it is not, so a
|
|
90
|
+
* fresh install records out of the box (the GitHub Action runs the same step).
|
|
91
|
+
* ~2 MB from Playwright's CDN, once per helper revision.
|
|
92
|
+
* PLAYWRIGHT_SKIP_BROWSER_GC=1 keeps the installer from pruning browsers other
|
|
93
|
+
* Playwright projects on the machine installed. With AIDEMO_NO_AUTO_INSTALL=1,
|
|
94
|
+
* an unknown layout or an already-installed helper this is a no-op (a missing
|
|
95
|
+
* helper then fails the launch with explainLaunchError's install command).
|
|
96
|
+
*/
|
|
97
|
+
export async function ensureRecorderFfmpeg(): Promise<void> {
|
|
98
|
+
const helper = recorderFfmpeg();
|
|
99
|
+
if (!helper || existsSync(helper.path) || !recorderAutoInstall()) return;
|
|
100
|
+
log(
|
|
101
|
+
`installing Playwright's ffmpeg helper (revision ${helper.revision}, ~2 MB, once per ` +
|
|
102
|
+
`Playwright version; it encodes the take — AIDEMO_NO_AUTO_INSTALL=1 skips this)`
|
|
103
|
+
);
|
|
104
|
+
let output = "";
|
|
105
|
+
const keep = (d: Buffer | string): void => {
|
|
106
|
+
output = (output + d.toString()).slice(-4000);
|
|
107
|
+
};
|
|
108
|
+
const code = await new Promise<number | null>((done) => {
|
|
109
|
+
const child = spawn(process.execPath, [helper.cli, "install", "ffmpeg"], {
|
|
110
|
+
env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_GC: "1" },
|
|
111
|
+
// Off stdout: under `aidemo mcp`, stdout is the JSON-RPC channel.
|
|
112
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
113
|
+
});
|
|
114
|
+
child.stdout.on("data", keep);
|
|
115
|
+
child.stderr.on("data", keep);
|
|
116
|
+
const timer = setTimeout(() => child.kill(), 5 * 60_000);
|
|
117
|
+
child.on("error", (err) => {
|
|
118
|
+
keep(String(err));
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
done(null);
|
|
121
|
+
});
|
|
122
|
+
child.on("close", (c) => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
done(c);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
if (code !== 0 || !existsSync(helper.path)) {
|
|
128
|
+
// The installer prints the cause as "Error: …" between stack frames and an
|
|
129
|
+
// error-object dump; surface that line, not the frames.
|
|
130
|
+
const lines = output
|
|
131
|
+
.replace(/\u001b\[[0-9;]*m/g, "")
|
|
132
|
+
.split("\n")
|
|
133
|
+
.map((l) => l.trim())
|
|
134
|
+
.filter((l) => l && !l.startsWith("at ") && !/^[{}]$/.test(l));
|
|
135
|
+
// Root cause first ("connect ECONNREFUSED …", "server returned code 403"),
|
|
136
|
+
// then the installer's own summary line.
|
|
137
|
+
const errors = [...new Set(lines.filter((l) => /error/i.test(l)))];
|
|
138
|
+
const why = (errors.length ? errors.slice(0, 2) : lines.slice(-1))
|
|
139
|
+
.map((l) => l.slice(0, 200))
|
|
140
|
+
.join(" — ");
|
|
141
|
+
throw new BrowserSetupError(
|
|
142
|
+
`could not install Playwright's ffmpeg helper (revision ${helper.revision}), which the ` +
|
|
143
|
+
`take is recorded with${why ? `: ${why}` : ""}. Install it yourself: ${helper.install}`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
ok(`Playwright ffmpeg helper installed (revision ${helper.revision})`);
|
|
147
|
+
}
|
|
148
|
+
|
|
74
149
|
/** A launch that failed on Playwright's missing ffmpeg helper, not on the browser. */
|
|
75
150
|
const FFMPEG_HELPER_RE = /playwright install ffmpeg|[\\/]ffmpeg-\d+[\\/]ffmpeg/i;
|
|
76
151
|
|
package/src/caption-track.ts
CHANGED
|
@@ -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
|
|
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
|
@@ -25,7 +25,12 @@ import {
|
|
|
25
25
|
chromeLaunchOptions,
|
|
26
26
|
type ChromeLaunchTarget,
|
|
27
27
|
} from "./config.js";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
describeChromeTarget,
|
|
30
|
+
probeConfiguredBrowser,
|
|
31
|
+
recorderAutoInstall,
|
|
32
|
+
recorderFfmpeg,
|
|
33
|
+
} from "./browser.js";
|
|
29
34
|
import { localTtsStatus } from "./voice-local.js";
|
|
30
35
|
import { Project } from "./project.js";
|
|
31
36
|
import { STARTER_BRIEF, STARTER_STORYBOARD, briefFromInspect, storyboardFromInspect } from "./starter.js";
|
|
@@ -661,10 +666,17 @@ export interface DoctorReport {
|
|
|
661
666
|
playwright: boolean;
|
|
662
667
|
/**
|
|
663
668
|
* Playwright's ffmpeg helper for THIS engine's Playwright — the encoder the
|
|
664
|
-
* built-in capture records every take with (see recorderFfmpeg).
|
|
665
|
-
*
|
|
669
|
+
* built-in capture records every take with (see recorderFfmpeg). Missing is
|
|
670
|
+
* only fatal with `autoInstall` off: the first take installs it otherwise.
|
|
671
|
+
* null when its location can't be determined.
|
|
666
672
|
*/
|
|
667
|
-
recorderFfmpeg: {
|
|
673
|
+
recorderFfmpeg: {
|
|
674
|
+
found: boolean;
|
|
675
|
+
path: string;
|
|
676
|
+
revision: string;
|
|
677
|
+
install: string;
|
|
678
|
+
autoInstall: boolean;
|
|
679
|
+
} | null;
|
|
668
680
|
skill: {
|
|
669
681
|
target: string;
|
|
670
682
|
installed: { version: string; stableAvailable: string | null } | null;
|
|
@@ -673,6 +685,13 @@ export interface DoctorReport {
|
|
|
673
685
|
ok: boolean;
|
|
674
686
|
}
|
|
675
687
|
|
|
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
|
+
};
|
|
694
|
+
|
|
676
695
|
/** Build the structured preflight report (used by CLI doctor + MCP doctor). */
|
|
677
696
|
export async function doctorReport(dir: string | undefined): Promise<DoctorReport> {
|
|
678
697
|
const base = openAiBaseUrl();
|
|
@@ -732,7 +751,15 @@ export async function doctorReport(dir: string | undefined): Promise<DoctorRepor
|
|
|
732
751
|
: {}),
|
|
733
752
|
},
|
|
734
753
|
playwright: playwrightOk,
|
|
735
|
-
recorderFfmpeg: helper
|
|
754
|
+
recorderFfmpeg: helper
|
|
755
|
+
? {
|
|
756
|
+
found: await exists(helper.path),
|
|
757
|
+
path: helper.path,
|
|
758
|
+
revision: helper.revision,
|
|
759
|
+
install: helper.install,
|
|
760
|
+
autoInstall: recorderAutoInstall(),
|
|
761
|
+
}
|
|
762
|
+
: null,
|
|
736
763
|
skill: {
|
|
737
764
|
target,
|
|
738
765
|
installed: manifest
|
|
@@ -746,10 +773,11 @@ export async function doctorReport(dir: string | undefined): Promise<DoctorRepor
|
|
|
746
773
|
ok: false,
|
|
747
774
|
};
|
|
748
775
|
report.ok = Boolean(
|
|
749
|
-
report.
|
|
776
|
+
nodeSupported(report.node) &&
|
|
777
|
+
report.ffmpeg &&
|
|
750
778
|
report.browser.found &&
|
|
751
779
|
report.playwright &&
|
|
752
|
-
(report.recorderFfmpeg
|
|
780
|
+
(report.recorderFfmpeg ? report.recorderFfmpeg.found || report.recorderFfmpeg.autoInstall : true) &&
|
|
753
781
|
// Local TTS needs no key: voice is in-process, captions auto-fall-back
|
|
754
782
|
// to the offline derivation in `render`.
|
|
755
783
|
(report.apiKey !== "missing" || report.tts.provider === "local") &&
|
|
@@ -767,7 +795,8 @@ export async function doctor(dir: string | undefined): Promise<void> {
|
|
|
767
795
|
const line = (label: string, val: string | null) =>
|
|
768
796
|
val ? ok(`${label}: ${val}`) : fail(`${label}: NOT FOUND`);
|
|
769
797
|
|
|
770
|
-
|
|
798
|
+
if (nodeSupported(r.node)) ok(`node: ${r.node}`);
|
|
799
|
+
else fail(`node: ${r.node} — aidemo needs Node ${MIN_NODE.join(".")}+ (Node 20 is end-of-life)`);
|
|
771
800
|
line("ffmpeg", r.ffmpeg);
|
|
772
801
|
if (r.browser.source === "default") {
|
|
773
802
|
line("chrome", r.chrome);
|
|
@@ -815,13 +844,18 @@ export async function doctor(dir: string | undefined): Promise<void> {
|
|
|
815
844
|
);
|
|
816
845
|
if (r.playwright) ok(`playwright: resolvable`);
|
|
817
846
|
else fail(`playwright: not installed`);
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
847
|
+
const helper = r.recorderFfmpeg;
|
|
848
|
+
if (helper?.found) {
|
|
849
|
+
ok(`playwright ffmpeg helper: revision ${helper.revision} (records the take)`);
|
|
850
|
+
} else if (helper?.autoInstall) {
|
|
851
|
+
log(
|
|
852
|
+
`playwright ffmpeg helper: revision ${helper.revision} not installed yet — the first ` +
|
|
853
|
+
`take installs it (~2 MB from Playwright's CDN); or now: ${helper.install}`
|
|
854
|
+
);
|
|
855
|
+
} else if (helper) {
|
|
821
856
|
fail(
|
|
822
|
-
`playwright ffmpeg helper: revision ${
|
|
823
|
-
|
|
824
|
-
`Playwright version): ${r.recorderFfmpeg.install}`
|
|
857
|
+
`playwright ffmpeg helper: revision ${helper.revision} NOT FOUND at ${helper.path} and ` +
|
|
858
|
+
`AIDEMO_NO_AUTO_INSTALL is set — every take fails without it: ${helper.install}`
|
|
825
859
|
);
|
|
826
860
|
}
|
|
827
861
|
|
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 = ``;
|
|
145
159
|
const markdownStill = ``;
|
|
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(),
|
|
@@ -557,10 +594,11 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
557
594
|
"Check prereqs: node, ffmpeg, Chrome (or the AIDEMO_CHROME_CHANNEL / " +
|
|
558
595
|
"AIDEMO_CHROME_EXECUTABLE browser in use), TTS/STT endpoint (flags " +
|
|
559
596
|
"LLM-only servers like Ollama), API key, playwright + its ffmpeg " +
|
|
560
|
-
"helper (records the take;
|
|
561
|
-
|
|
597
|
+
"helper (records the take; installed on the first take unless " +
|
|
598
|
+
"AIDEMO_NO_AUTO_INSTALL=1), installed skill.",
|
|
599
|
+
inputSchema: closedInput({
|
|
562
600
|
dir: z.string().optional().describe("repo to check for an installed skill"),
|
|
563
|
-
},
|
|
601
|
+
}),
|
|
564
602
|
outputSchema: {
|
|
565
603
|
engineVersion: z.string(),
|
|
566
604
|
node: z.string(),
|
|
@@ -593,6 +631,9 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
593
631
|
path: z.string(),
|
|
594
632
|
revision: z.string(),
|
|
595
633
|
install: z.string().describe("exact command to install it"),
|
|
634
|
+
autoInstall: z
|
|
635
|
+
.boolean()
|
|
636
|
+
.describe("the first take installs it (off with AIDEMO_NO_AUTO_INSTALL=1)"),
|
|
596
637
|
})
|
|
597
638
|
.nullable()
|
|
598
639
|
.describe("Playwright's ffmpeg helper the built-in capture records with"),
|
|
@@ -629,7 +670,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
629
670
|
"Owner/repo come from the repo's origin remote; the demo name from the " +
|
|
630
671
|
"directory basename. Pair with the demo-publish workflow so CI keeps " +
|
|
631
672
|
"the URLs fresh. See docs/EMBEDS.md.",
|
|
632
|
-
inputSchema: {
|
|
673
|
+
inputSchema: closedInput({
|
|
633
674
|
dir: DIR_INPUT,
|
|
634
675
|
repo: z
|
|
635
676
|
.string()
|
|
@@ -639,7 +680,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
639
680
|
.string()
|
|
640
681
|
.optional()
|
|
641
682
|
.describe("still-frame basename under stills/ (default: poster)"),
|
|
642
|
-
},
|
|
683
|
+
}),
|
|
643
684
|
outputSchema: {
|
|
644
685
|
owner: z.string(),
|
|
645
686
|
repo: z.string(),
|
|
@@ -651,6 +692,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
651
692
|
mp4: z.string(),
|
|
652
693
|
still: z.string(),
|
|
653
694
|
pagesMp4: z.string(),
|
|
695
|
+
pagesVtt: z.string().describe("captions for the <video> embed's <track>"),
|
|
654
696
|
pagesBase: z.string(),
|
|
655
697
|
}),
|
|
656
698
|
snippets: z.object({
|
|
@@ -694,7 +736,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
694
736
|
"network call. Call this whenever something felt broken, surprising, " +
|
|
695
737
|
"or needed a workaround; skip it if the session was clean. Set dryRun " +
|
|
696
738
|
"to preview the assembled title/body without filing anything.",
|
|
697
|
-
inputSchema: {
|
|
739
|
+
inputSchema: closedInput({
|
|
698
740
|
title: z.string().describe("short issue title"),
|
|
699
741
|
body: z
|
|
700
742
|
.string()
|
|
@@ -715,7 +757,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
715
757
|
.boolean()
|
|
716
758
|
.optional()
|
|
717
759
|
.describe("assemble the title/body but don't file anything — preview only"),
|
|
718
|
-
},
|
|
760
|
+
}),
|
|
719
761
|
outputSchema: {
|
|
720
762
|
filed: z.boolean(),
|
|
721
763
|
title: z.string(),
|
|
@@ -747,10 +789,10 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
747
789
|
"compose), log tail, and final result or error (with failure-artifact " +
|
|
748
790
|
"paths and, on a genuine failure, a feedbackHint pointing at the " +
|
|
749
791
|
"feedback tool) of a pipeline job.",
|
|
750
|
-
inputSchema: {
|
|
792
|
+
inputSchema: closedInput({
|
|
751
793
|
jobId: z.string(),
|
|
752
794
|
tailLines: z.number().optional().describe("log lines to return (default 40)"),
|
|
753
|
-
},
|
|
795
|
+
}),
|
|
754
796
|
outputSchema: JOB_STATUS_SHAPE,
|
|
755
797
|
annotations: { readOnlyHint: true },
|
|
756
798
|
},
|
|
@@ -810,7 +852,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
810
852
|
"Best-effort cancel (checked between actions/scenes/stages). A " +
|
|
811
853
|
"mid-record cancel salvages the partial timeline + footage. The job " +
|
|
812
854
|
"settles asynchronously — poll job_status for the final state.",
|
|
813
|
-
inputSchema: { jobId: z.string() },
|
|
855
|
+
inputSchema: closedInput({ jobId: z.string() }),
|
|
814
856
|
outputSchema: JOB_STATUS_SHAPE,
|
|
815
857
|
},
|
|
816
858
|
async (args) => {
|
|
@@ -836,7 +878,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
836
878
|
description:
|
|
837
879
|
`${description} Returns a jobId immediately — poll job_status. ` +
|
|
838
880
|
`Rejects if another job is running.`,
|
|
839
|
-
inputSchema: inputShape,
|
|
881
|
+
inputSchema: closedInput(inputShape),
|
|
840
882
|
outputSchema: JOB_START_SHAPE,
|
|
841
883
|
},
|
|
842
884
|
(async (args: z.infer<z.ZodObject<Shape>>) => {
|
|
@@ -1015,12 +1057,12 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1015
1057
|
"and selectors become scenes (cut at navigations and async payoffs), narration " +
|
|
1016
1058
|
"is left as placeholders, and `_notes` lists what was approximated. No LLM, " +
|
|
1017
1059
|
"no browser. Then write the narration and probe.",
|
|
1018
|
-
inputSchema: {
|
|
1060
|
+
inputSchema: closedInput({
|
|
1019
1061
|
file: z.string().describe("absolute path to trace.zip or the test file"),
|
|
1020
1062
|
name: z.string().describe("demo name (creates demos/<name>/)"),
|
|
1021
1063
|
dir: z.string().optional().describe("repo to scaffold into (default: server cwd)"),
|
|
1022
1064
|
force: z.boolean().optional(),
|
|
1023
|
-
},
|
|
1065
|
+
}),
|
|
1024
1066
|
outputSchema: {
|
|
1025
1067
|
demoDir: z.string(),
|
|
1026
1068
|
storyboardPath: z.string(),
|
package/src/recorder.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describeChromeTarget, launchChromePersistent } from "./browser.js";
|
|
1
|
+
import { describeChromeTarget, ensureRecorderFfmpeg, launchChromePersistent } from "./browser.js";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import {
|
|
4
4
|
TimelineSchema,
|
|
@@ -316,6 +316,10 @@ export async function record(
|
|
|
316
316
|
const screenCapPath = join(videoDir, "capture.mkv");
|
|
317
317
|
await fs.rm(screenCapPath, { force: true });
|
|
318
318
|
|
|
319
|
+
// The built-in capture encodes the take with Playwright's ffmpeg helper,
|
|
320
|
+
// which a fresh install (or a Playwright upgrade) may not have yet.
|
|
321
|
+
if (!external) await ensureRecorderFfmpeg();
|
|
322
|
+
|
|
319
323
|
let context;
|
|
320
324
|
try {
|
|
321
325
|
context = await launchChromePersistent(profileDir, {
|
package/src/walkthrough.ts
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
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>
|