@tandryukha/aidemo 0.18.0 → 0.20.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 +5 -5
- package/bin/aidemo.mjs +9 -8
- package/docs/AUTHORING.md +2 -2
- package/package.json +10 -8
- package/src/caption-track.ts +15 -0
- package/src/capture.ts +1 -6
- package/src/distribute.ts +7 -5
- package/src/embed.ts +19 -2
- package/src/mcp/jobs.ts +0 -4
- package/src/mcp/server.ts +120 -70
- 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 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:** ~
|
|
308
|
-
immutable ref if you're wary of the moving
|
|
309
|
-
`npx -y github:tandryukha/aidemo#v0.
|
|
307
|
+
- **Small, auditable surface:** ~50 source files, 7 runtime deps (11 packages
|
|
308
|
+
installed in all), MIT. Pin an immutable ref if you're wary of the moving
|
|
309
|
+
`#stable` tag: `npx -y github:tandryukha/aidemo#v0.20.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
|
|
11
|
-
// end-of-life on 2026-04-30. Keep in sync with package.json
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
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 ${
|
|
17
|
-
`Install Node ${
|
|
18
|
-
`actions/setup-node with node-version: ${
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.20.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 test/mcp-stdio.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",
|
|
@@ -38,16 +39,17 @@
|
|
|
38
39
|
"check:plugin-skill": "diff -q .claude/skills/record-demo/SKILL.md plugins/record-demo/skills/record-demo/SKILL.md"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
|
-
"@modelcontextprotocol/
|
|
42
|
-
"commander": "^
|
|
42
|
+
"@modelcontextprotocol/server": "2.4.0",
|
|
43
|
+
"commander": "^15.0.0",
|
|
43
44
|
"openai": "^7.47.0",
|
|
44
|
-
"playwright": "^1.
|
|
45
|
+
"playwright": "^1.64.1",
|
|
45
46
|
"re2js": "2.9.1",
|
|
46
|
-
"tsx": "^4.23.
|
|
47
|
+
"tsx": "^4.23.15",
|
|
47
48
|
"zod": "^4.6.7"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
|
-
"@
|
|
51
|
-
"
|
|
51
|
+
"@modelcontextprotocol/client": "2.4.0",
|
|
52
|
+
"@types/node": "^26.6.6",
|
|
53
|
+
"typescript": "^7.0.2"
|
|
52
54
|
}
|
|
53
55
|
}
|
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
|
@@ -685,10 +685,12 @@ export interface DoctorReport {
|
|
|
685
685
|
ok: boolean;
|
|
686
686
|
}
|
|
687
687
|
|
|
688
|
-
/** Oldest supported Node
|
|
689
|
-
const
|
|
690
|
-
const nodeSupported = (version: string): boolean =>
|
|
691
|
-
|
|
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 ${
|
|
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 = ``;
|
|
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/jobs.ts
CHANGED
|
@@ -87,9 +87,6 @@ export class JobManager {
|
|
|
87
87
|
private jobs = new Map<string, Job>();
|
|
88
88
|
private activeId: string | null = null;
|
|
89
89
|
|
|
90
|
-
/** Best-effort per-line forwarding (e.g. MCP logging notifications). */
|
|
91
|
-
onLine?: (job: Job, cleanChunk: string) => void;
|
|
92
|
-
|
|
93
90
|
/** Start a job or throw JobBusyError if one is already running. */
|
|
94
91
|
startJob(
|
|
95
92
|
kind: JobKind,
|
|
@@ -284,6 +281,5 @@ export class JobManager {
|
|
|
284
281
|
if (job.ring.length > RING_MAX) {
|
|
285
282
|
job.ring.splice(0, job.ring.length - RING_MAX);
|
|
286
283
|
}
|
|
287
|
-
this.onLine?.(job, chunk);
|
|
288
284
|
}
|
|
289
285
|
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { McpServer } from "@modelcontextprotocol/
|
|
2
|
-
import { StdioServerTransport } from "@modelcontextprotocol/
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { resolve } from "node:path";
|
|
@@ -81,6 +81,44 @@ 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 schemas
|
|
87
|
+
* through zod's Standard JSON Schema with io:"input", which leaves objects
|
|
88
|
+
* open. Metadata only — zod still strips unknown arguments at runtime,
|
|
89
|
+
* exactly as before. (A tool without arguments registers no input schema;
|
|
90
|
+
* the SDK lists it as an empty object.)
|
|
91
|
+
*/
|
|
92
|
+
function closedInput<Shape extends z.ZodRawShape>(shape: Shape) {
|
|
93
|
+
for (const field of Object.values(shape)) closeObjects(field);
|
|
94
|
+
return z.object(shape).meta({ additionalProperties: false });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Mark every object schema reachable from `schema` closed (keeps existing metadata). */
|
|
98
|
+
function closeObjects(schema: z.core.$ZodType, seen = new Set<unknown>()): void {
|
|
99
|
+
if (seen.has(schema)) return;
|
|
100
|
+
seen.add(schema);
|
|
101
|
+
const def = schema._zod.def as {
|
|
102
|
+
type: string;
|
|
103
|
+
shape?: Record<string, z.core.$ZodType>;
|
|
104
|
+
innerType?: z.core.$ZodType;
|
|
105
|
+
element?: z.core.$ZodType;
|
|
106
|
+
options?: z.core.$ZodType[];
|
|
107
|
+
valueType?: z.core.$ZodType;
|
|
108
|
+
};
|
|
109
|
+
if (def.type === "object") {
|
|
110
|
+
z.globalRegistry.add(schema, { ...z.globalRegistry.get(schema), additionalProperties: false });
|
|
111
|
+
}
|
|
112
|
+
const children = [
|
|
113
|
+
...Object.values(def.shape ?? {}),
|
|
114
|
+
...(def.options ?? []),
|
|
115
|
+
def.innerType,
|
|
116
|
+
def.element,
|
|
117
|
+
def.valueType,
|
|
118
|
+
];
|
|
119
|
+
for (const child of children) if (child) closeObjects(child, seen);
|
|
120
|
+
}
|
|
121
|
+
|
|
84
122
|
type ToolResult = {
|
|
85
123
|
content: Array<{ type: "text"; text: string }>;
|
|
86
124
|
structuredContent?: Record<string, unknown>;
|
|
@@ -243,29 +281,20 @@ function throwIfAborted(signal: AbortSignal): void {
|
|
|
243
281
|
if (signal.aborted) throw new CanceledError("canceled between stages");
|
|
244
282
|
}
|
|
245
283
|
|
|
246
|
-
|
|
247
|
-
|
|
284
|
+
/**
|
|
285
|
+
* One MCP server instance over the process-wide job manager. Under stdio the
|
|
286
|
+
* SDK may build more than one instance per process (a discarded
|
|
287
|
+
* `server/discover` probe, then the connection's instance), so job state lives
|
|
288
|
+
* in the shared `jobs`, never in an instance. Progress reaches clients through
|
|
289
|
+
* job_status polling; engine logging goes to stderr and the job's log file
|
|
290
|
+
* (MCP logging notifications are deprecated as of protocol 2026-07-28).
|
|
291
|
+
*/
|
|
292
|
+
export function buildMcpServer(jobs: JobManager = new JobManager()): { server: McpServer; jobs: JobManager } {
|
|
248
293
|
const server = new McpServer(
|
|
249
294
|
{ name: "aidemo", version: engineVersion() },
|
|
250
|
-
{ instructions: INSTRUCTIONS
|
|
295
|
+
{ instructions: INSTRUCTIONS }
|
|
251
296
|
);
|
|
252
297
|
|
|
253
|
-
// Best-effort log forwarding (throttled); job_status polling is the source
|
|
254
|
-
// of truth — many clients don't surface logging notifications.
|
|
255
|
-
const lastSent = new Map<string, number>();
|
|
256
|
-
jobs.onLine = (job, chunk) => {
|
|
257
|
-
const now = Date.now();
|
|
258
|
-
if (now - (lastSent.get(job.jobId) ?? 0) < 1000) return;
|
|
259
|
-
lastSent.set(job.jobId, now);
|
|
260
|
-
void server
|
|
261
|
-
.sendLoggingMessage({
|
|
262
|
-
level: "info",
|
|
263
|
-
logger: "aidemo",
|
|
264
|
-
data: { jobId: job.jobId, stage: job.stage, line: chunk.trim() },
|
|
265
|
-
})
|
|
266
|
-
.catch(() => {});
|
|
267
|
-
};
|
|
268
|
-
|
|
269
298
|
const guidePath = guideFilePath();
|
|
270
299
|
|
|
271
300
|
server.registerTool(
|
|
@@ -279,21 +308,21 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
279
308
|
`\`topic\` (${GUIDE_TOPIC_NAMES.join(" | ")}, or any H2 heading prefix) ` +
|
|
280
309
|
"for one slice instead of the whole ~1000-line guide; start with " +
|
|
281
310
|
"`core`, then fetch `attention`/`polish`/`chatgpt-apps` as needed.",
|
|
282
|
-
inputSchema: {
|
|
311
|
+
inputSchema: closedInput({
|
|
283
312
|
topic: z
|
|
284
313
|
.string()
|
|
285
314
|
.optional()
|
|
286
315
|
.describe(
|
|
287
316
|
`Slice: ${GUIDE_TOPIC_NAMES.join(", ")} — or an H2 heading prefix. Omit for the full guide.`
|
|
288
317
|
),
|
|
289
|
-
},
|
|
290
|
-
outputSchema: {
|
|
318
|
+
}),
|
|
319
|
+
outputSchema: z.object({
|
|
291
320
|
guide: z.string(),
|
|
292
321
|
engineVersion: z.string(),
|
|
293
322
|
topic: z.string().optional(),
|
|
294
323
|
topics: z.array(z.string()),
|
|
295
324
|
sections: z.array(z.string()),
|
|
296
|
-
},
|
|
325
|
+
}),
|
|
297
326
|
annotations: { readOnlyHint: true },
|
|
298
327
|
},
|
|
299
328
|
async ({ topic }) => {
|
|
@@ -329,8 +358,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
329
358
|
description:
|
|
330
359
|
"JSON Schema for storyboard.json, generated from the engine's own " +
|
|
331
360
|
"zod schema — the exact contract the engine validates against.",
|
|
332
|
-
|
|
333
|
-
outputSchema: { schema: z.record(z.string(), z.unknown()) },
|
|
361
|
+
outputSchema: z.object({ schema: z.record(z.string(), z.unknown()) }),
|
|
334
362
|
annotations: { readOnlyHint: true },
|
|
335
363
|
},
|
|
336
364
|
async () =>
|
|
@@ -348,14 +376,14 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
348
376
|
"anything. Pass exactly one of: dir (uses generated/storyboard.json), " +
|
|
349
377
|
"path (a storyboard file), or json (storyboard JSON as a string). " +
|
|
350
378
|
"relaxed makes narration optional (probe semantics).",
|
|
351
|
-
inputSchema: {
|
|
379
|
+
inputSchema: closedInput({
|
|
352
380
|
dir: z.string().optional().describe("demo dir → generated/storyboard.json"),
|
|
353
381
|
path: z.string().optional().describe("path to a storyboard .json file"),
|
|
354
382
|
json: z.string().optional().describe("storyboard JSON as a string"),
|
|
355
383
|
relaxed: z.boolean().optional(),
|
|
356
384
|
params: PARAMS_INPUT,
|
|
357
|
-
},
|
|
358
|
-
outputSchema: {
|
|
385
|
+
}),
|
|
386
|
+
outputSchema: z.object({
|
|
359
387
|
valid: z.boolean(),
|
|
360
388
|
storyboardPath: z.string().optional(),
|
|
361
389
|
title: z.string().optional(),
|
|
@@ -365,7 +393,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
365
393
|
),
|
|
366
394
|
warnings: z.array(z.string()),
|
|
367
395
|
lint: z.array(LINT_ISSUE_SHAPE).optional(),
|
|
368
|
-
},
|
|
396
|
+
}),
|
|
369
397
|
annotations: { readOnlyHint: true },
|
|
370
398
|
},
|
|
371
399
|
async (args) => {
|
|
@@ -441,14 +469,14 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
441
469
|
"Run it after every storyboard edit, before probe/render. Pass exactly " +
|
|
442
470
|
"one of dir / path / json. lang lints a narrations[lang] translation at " +
|
|
443
471
|
"that language's speaking rate.",
|
|
444
|
-
inputSchema: {
|
|
472
|
+
inputSchema: closedInput({
|
|
445
473
|
dir: z.string().optional().describe("demo dir → generated/storyboard.json"),
|
|
446
474
|
path: z.string().optional().describe("path to a storyboard .json file"),
|
|
447
475
|
json: z.string().optional().describe("storyboard JSON as a string"),
|
|
448
476
|
lang: z.string().optional(),
|
|
449
477
|
params: PARAMS_INPUT,
|
|
450
|
-
},
|
|
451
|
-
outputSchema: {
|
|
478
|
+
}),
|
|
479
|
+
outputSchema: z.object({
|
|
452
480
|
issues: z.array(LINT_ISSUE_SHAPE),
|
|
453
481
|
estimate: z.array(
|
|
454
482
|
z.object({
|
|
@@ -462,7 +490,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
462
490
|
),
|
|
463
491
|
narrationTotalMs: z.number(),
|
|
464
492
|
wordsPerSec: z.number(),
|
|
465
|
-
},
|
|
493
|
+
}),
|
|
466
494
|
annotations: { readOnlyHint: true },
|
|
467
495
|
},
|
|
468
496
|
async (args) => {
|
|
@@ -508,7 +536,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
508
536
|
"With fromUrl the page is inspected first (no LLM): its headings become " +
|
|
509
537
|
"scenes and its unique selectors become the beats + a `_candidates` list — " +
|
|
510
538
|
"you then write the narration and turn the candidate hover into the real click.",
|
|
511
|
-
inputSchema: {
|
|
539
|
+
inputSchema: closedInput({
|
|
512
540
|
name: z.string().describe("demo name (creates demos/<name>/)"),
|
|
513
541
|
dir: z.string().optional(),
|
|
514
542
|
force: z.boolean().optional(),
|
|
@@ -519,12 +547,12 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
519
547
|
.object({ width: z.number(), height: z.number() })
|
|
520
548
|
.optional()
|
|
521
549
|
.describe("fromUrl: viewport (default 1280x720)"),
|
|
522
|
-
},
|
|
523
|
-
outputSchema: {
|
|
550
|
+
}),
|
|
551
|
+
outputSchema: z.object({
|
|
524
552
|
demoDir: z.string(),
|
|
525
553
|
storyboardPath: z.string(),
|
|
526
554
|
briefPath: z.string(),
|
|
527
|
-
},
|
|
555
|
+
}),
|
|
528
556
|
},
|
|
529
557
|
async (args) => {
|
|
530
558
|
// fromUrl launches Chrome: honour the target repo's .env (#77).
|
|
@@ -559,10 +587,10 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
559
587
|
"LLM-only servers like Ollama), API key, playwright + its ffmpeg " +
|
|
560
588
|
"helper (records the take; installed on the first take unless " +
|
|
561
589
|
"AIDEMO_NO_AUTO_INSTALL=1), installed skill.",
|
|
562
|
-
inputSchema: {
|
|
590
|
+
inputSchema: closedInput({
|
|
563
591
|
dir: z.string().optional().describe("repo to check for an installed skill"),
|
|
564
|
-
},
|
|
565
|
-
outputSchema: {
|
|
592
|
+
}),
|
|
593
|
+
outputSchema: z.object({
|
|
566
594
|
engineVersion: z.string(),
|
|
567
595
|
node: z.string(),
|
|
568
596
|
ffmpeg: z.string().nullable(),
|
|
@@ -610,7 +638,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
610
638
|
.nullable(),
|
|
611
639
|
}),
|
|
612
640
|
ok: z.boolean(),
|
|
613
|
-
},
|
|
641
|
+
}),
|
|
614
642
|
annotations: { readOnlyHint: true },
|
|
615
643
|
},
|
|
616
644
|
async (args) => {
|
|
@@ -633,7 +661,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
633
661
|
"Owner/repo come from the repo's origin remote; the demo name from the " +
|
|
634
662
|
"directory basename. Pair with the demo-publish workflow so CI keeps " +
|
|
635
663
|
"the URLs fresh. See docs/EMBEDS.md.",
|
|
636
|
-
inputSchema: {
|
|
664
|
+
inputSchema: closedInput({
|
|
637
665
|
dir: DIR_INPUT,
|
|
638
666
|
repo: z
|
|
639
667
|
.string()
|
|
@@ -643,8 +671,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
643
671
|
.string()
|
|
644
672
|
.optional()
|
|
645
673
|
.describe("still-frame basename under stills/ (default: poster)"),
|
|
646
|
-
},
|
|
647
|
-
outputSchema: {
|
|
674
|
+
}),
|
|
675
|
+
outputSchema: z.object({
|
|
648
676
|
owner: z.string(),
|
|
649
677
|
repo: z.string(),
|
|
650
678
|
demo: z.string(),
|
|
@@ -655,6 +683,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
655
683
|
mp4: z.string(),
|
|
656
684
|
still: z.string(),
|
|
657
685
|
pagesMp4: z.string(),
|
|
686
|
+
pagesVtt: z.string().describe("captions for the <video> embed's <track>"),
|
|
658
687
|
pagesBase: z.string(),
|
|
659
688
|
}),
|
|
660
689
|
snippets: z.object({
|
|
@@ -667,7 +696,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
667
696
|
present: z.boolean(),
|
|
668
697
|
template: z.string(),
|
|
669
698
|
}),
|
|
670
|
-
},
|
|
699
|
+
}),
|
|
671
700
|
annotations: { readOnlyHint: true },
|
|
672
701
|
},
|
|
673
702
|
async (args) => {
|
|
@@ -698,7 +727,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
698
727
|
"network call. Call this whenever something felt broken, surprising, " +
|
|
699
728
|
"or needed a workaround; skip it if the session was clean. Set dryRun " +
|
|
700
729
|
"to preview the assembled title/body without filing anything.",
|
|
701
|
-
inputSchema: {
|
|
730
|
+
inputSchema: closedInput({
|
|
702
731
|
title: z.string().describe("short issue title"),
|
|
703
732
|
body: z
|
|
704
733
|
.string()
|
|
@@ -719,15 +748,15 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
719
748
|
.boolean()
|
|
720
749
|
.optional()
|
|
721
750
|
.describe("assemble the title/body but don't file anything — preview only"),
|
|
722
|
-
},
|
|
723
|
-
outputSchema: {
|
|
751
|
+
}),
|
|
752
|
+
outputSchema: z.object({
|
|
724
753
|
filed: z.boolean(),
|
|
725
754
|
title: z.string(),
|
|
726
755
|
body: z.string(),
|
|
727
756
|
url: z.string().optional(),
|
|
728
757
|
localPath: z.string().optional(),
|
|
729
758
|
message: z.string(),
|
|
730
|
-
},
|
|
759
|
+
}),
|
|
731
760
|
},
|
|
732
761
|
async (args) => {
|
|
733
762
|
const demoDir = args.dir ? resolve(args.dir) : null;
|
|
@@ -751,11 +780,11 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
751
780
|
"compose), log tail, and final result or error (with failure-artifact " +
|
|
752
781
|
"paths and, on a genuine failure, a feedbackHint pointing at the " +
|
|
753
782
|
"feedback tool) of a pipeline job.",
|
|
754
|
-
inputSchema: {
|
|
783
|
+
inputSchema: closedInput({
|
|
755
784
|
jobId: z.string(),
|
|
756
785
|
tailLines: z.number().optional().describe("log lines to return (default 40)"),
|
|
757
|
-
},
|
|
758
|
-
outputSchema: JOB_STATUS_SHAPE,
|
|
786
|
+
}),
|
|
787
|
+
outputSchema: z.object(JOB_STATUS_SHAPE),
|
|
759
788
|
annotations: { readOnlyHint: true },
|
|
760
789
|
},
|
|
761
790
|
async (args) => {
|
|
@@ -776,8 +805,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
776
805
|
{
|
|
777
806
|
title: "List jobs",
|
|
778
807
|
description: "All jobs this server session, newest last.",
|
|
779
|
-
|
|
780
|
-
outputSchema: {
|
|
808
|
+
outputSchema: z.object({
|
|
781
809
|
jobs: z.array(
|
|
782
810
|
z.object({
|
|
783
811
|
jobId: z.string(),
|
|
@@ -789,7 +817,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
789
817
|
endedAt: z.string().nullable(),
|
|
790
818
|
})
|
|
791
819
|
),
|
|
792
|
-
},
|
|
820
|
+
}),
|
|
793
821
|
annotations: { readOnlyHint: true },
|
|
794
822
|
},
|
|
795
823
|
async () =>
|
|
@@ -814,8 +842,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
814
842
|
"Best-effort cancel (checked between actions/scenes/stages). A " +
|
|
815
843
|
"mid-record cancel salvages the partial timeline + footage. The job " +
|
|
816
844
|
"settles asynchronously — poll job_status for the final state.",
|
|
817
|
-
inputSchema: { jobId: z.string() },
|
|
818
|
-
outputSchema: JOB_STATUS_SHAPE,
|
|
845
|
+
inputSchema: closedInput({ jobId: z.string() }),
|
|
846
|
+
outputSchema: z.object(JOB_STATUS_SHAPE),
|
|
819
847
|
},
|
|
820
848
|
async (args) => {
|
|
821
849
|
const job = jobs.cancel(args.jobId);
|
|
@@ -840,8 +868,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
840
868
|
description:
|
|
841
869
|
`${description} Returns a jobId immediately — poll job_status. ` +
|
|
842
870
|
`Rejects if another job is running.`,
|
|
843
|
-
inputSchema: inputShape,
|
|
844
|
-
outputSchema: JOB_START_SHAPE,
|
|
871
|
+
inputSchema: closedInput(inputShape),
|
|
872
|
+
outputSchema: z.object(JOB_START_SHAPE),
|
|
845
873
|
},
|
|
846
874
|
(async (args: z.infer<z.ZodObject<Shape>>) => {
|
|
847
875
|
const project = new Project((args as { dir: string }).dir);
|
|
@@ -1019,19 +1047,19 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1019
1047
|
"and selectors become scenes (cut at navigations and async payoffs), narration " +
|
|
1020
1048
|
"is left as placeholders, and `_notes` lists what was approximated. No LLM, " +
|
|
1021
1049
|
"no browser. Then write the narration and probe.",
|
|
1022
|
-
inputSchema: {
|
|
1050
|
+
inputSchema: closedInput({
|
|
1023
1051
|
file: z.string().describe("absolute path to trace.zip or the test file"),
|
|
1024
1052
|
name: z.string().describe("demo name (creates demos/<name>/)"),
|
|
1025
1053
|
dir: z.string().optional().describe("repo to scaffold into (default: server cwd)"),
|
|
1026
1054
|
force: z.boolean().optional(),
|
|
1027
|
-
},
|
|
1028
|
-
outputSchema: {
|
|
1055
|
+
}),
|
|
1056
|
+
outputSchema: z.object({
|
|
1029
1057
|
demoDir: z.string(),
|
|
1030
1058
|
storyboardPath: z.string(),
|
|
1031
1059
|
steps: z.number(),
|
|
1032
1060
|
scenes: z.number(),
|
|
1033
1061
|
notes: z.array(z.string()),
|
|
1034
|
-
},
|
|
1062
|
+
}),
|
|
1035
1063
|
},
|
|
1036
1064
|
async (args) => {
|
|
1037
1065
|
try {
|
|
@@ -1488,9 +1516,30 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1488
1516
|
return { server, jobs };
|
|
1489
1517
|
}
|
|
1490
1518
|
|
|
1491
|
-
/**
|
|
1519
|
+
/**
|
|
1520
|
+
* The process's stdio wire, reporting when the connection ends. serveStdio
|
|
1521
|
+
* owns the wire and closes it when the connection is over (stdin EOF, a
|
|
1522
|
+
* broken stdout, an oversized message, or the connection's server closing);
|
|
1523
|
+
* discarding a `server/discover` probe instance never closes it.
|
|
1524
|
+
*/
|
|
1525
|
+
class ProcessStdioTransport extends StdioServerTransport {
|
|
1526
|
+
constructor(private readonly onEnd: () => void) {
|
|
1527
|
+
super();
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
override async close(): Promise<void> {
|
|
1531
|
+
await super.close();
|
|
1532
|
+
this.onEnd();
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Run the stdio server until the client disconnects. Never resolves.
|
|
1538
|
+
* serveStdio answers both protocol eras: a 2025-era client (`initialize`
|
|
1539
|
+
* handshake, what deployed clients speak) and a 2026-07-28 client.
|
|
1540
|
+
*/
|
|
1492
1541
|
export async function runMcpServer(): Promise<void> {
|
|
1493
|
-
const
|
|
1542
|
+
const jobs = new JobManager();
|
|
1494
1543
|
let shuttingDown = false;
|
|
1495
1544
|
const shutdown = () => {
|
|
1496
1545
|
if (shuttingDown) return;
|
|
@@ -1513,8 +1562,9 @@ export async function runMcpServer(): Promise<void> {
|
|
|
1513
1562
|
};
|
|
1514
1563
|
process.stdin.on("end", shutdown);
|
|
1515
1564
|
process.stdin.on("close", shutdown);
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1565
|
+
serveStdio(() => buildMcpServer(jobs).server, {
|
|
1566
|
+
transport: new ProcessStdioTransport(shutdown),
|
|
1567
|
+
onerror: (err) => log(`mcp: ${err.message}`),
|
|
1568
|
+
});
|
|
1519
1569
|
await new Promise(() => {}); // lives until shutdown() exits the process
|
|
1520
1570
|
}
|
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>
|