@tandryukha/aidemo 0.17.1 → 0.18.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 20+, system Chrome, ffmpeg on PATH
157
+ npm install # Node 22+, 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 records the take: `aidemo doctor` checks for it
169
- and prints the exact install command (`npx playwright install ffmpeg`).
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
  [![Quickstart output — the bundled fixture rendered end-to-end](docs/quickstart.gif)](https://github.com/tandryukha/aidemo/releases/download/v0.3.0/quickstart-demo.mp4)
172
173
 
@@ -253,9 +254,9 @@ Codex).
253
254
 
254
255
  ## Setup
255
256
 
256
- Prereqs: **Node 20+**, **Google Chrome**, **ffmpeg + ffprobe** on `PATH`, and
257
- Playwright's ~2 MB ffmpeg helper that records the take (one per Playwright
258
- version; `aidemo doctor` prints the exact install command).
257
+ Prereqs: **Node 22+**, **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,9 +299,11 @@ 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`), and
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.
306
+ otherwise fully local. The MCP server is **stdio-only** — no listener.
304
307
  - **Small, auditable surface:** ~20 source files, 7 runtime deps, MIT. Pin an
305
308
  immutable ref if you're wary of the moving `#stable` tag:
306
309
  `npx -y github:tandryukha/aidemo#v0.14.0` (any released tag).
package/bin/aidemo.mjs CHANGED
@@ -6,6 +6,20 @@ 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). 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) {
15
+ 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.`
19
+ );
20
+ process.exit(1);
21
+ }
22
+
9
23
  const here = dirname(fileURLToPath(import.meta.url));
10
24
  const cli = resolve(here, "..", "src", "cli.ts");
11
25
  const require = createRequire(import.meta.url);
package/docs/AUTHORING.md CHANGED
@@ -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, the TTS/STT endpoint
1260
- (and flags LLM-only servers like Ollama, which have no audio endpoints — point
1261
- `OPENAI_BASE_URL` at a speech server such as speaches instead).
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.17.1",
3
+ "version": "0.18.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
+ },
10
13
  "publishConfig": {
11
14
  "access": "public"
12
15
  },
@@ -37,7 +40,7 @@
37
40
  "dependencies": {
38
41
  "@modelcontextprotocol/sdk": "1.32.2",
39
42
  "commander": "^12.1.0",
40
- "openai": "^6.49.0",
43
+ "openai": "^7.47.0",
41
44
  "playwright": "^1.55.1",
42
45
  "re2js": "2.9.1",
43
46
  "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 { readFileSync } from "node:fs";
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(): { path: string; revision: string; install: string } | null {
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/distribute.ts CHANGED
@@ -25,7 +25,12 @@ import {
25
25
  chromeLaunchOptions,
26
26
  type ChromeLaunchTarget,
27
27
  } from "./config.js";
28
- import { describeChromeTarget, probeConfiguredBrowser, recorderFfmpeg } from "./browser.js";
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). null when
665
- * its location can't be determined.
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: { found: boolean; path: string; revision: string; install: string } | null;
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,11 @@ export interface DoctorReport {
673
685
  ok: boolean;
674
686
  }
675
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;
692
+
676
693
  /** Build the structured preflight report (used by CLI doctor + MCP doctor). */
677
694
  export async function doctorReport(dir: string | undefined): Promise<DoctorReport> {
678
695
  const base = openAiBaseUrl();
@@ -732,7 +749,15 @@ export async function doctorReport(dir: string | undefined): Promise<DoctorRepor
732
749
  : {}),
733
750
  },
734
751
  playwright: playwrightOk,
735
- recorderFfmpeg: helper ? { found: await exists(helper.path), ...helper } : null,
752
+ recorderFfmpeg: helper
753
+ ? {
754
+ found: await exists(helper.path),
755
+ path: helper.path,
756
+ revision: helper.revision,
757
+ install: helper.install,
758
+ autoInstall: recorderAutoInstall(),
759
+ }
760
+ : null,
736
761
  skill: {
737
762
  target,
738
763
  installed: manifest
@@ -746,10 +771,11 @@ export async function doctorReport(dir: string | undefined): Promise<DoctorRepor
746
771
  ok: false,
747
772
  };
748
773
  report.ok = Boolean(
749
- report.ffmpeg &&
774
+ nodeSupported(report.node) &&
775
+ report.ffmpeg &&
750
776
  report.browser.found &&
751
777
  report.playwright &&
752
- (report.recorderFfmpeg?.found ?? true) &&
778
+ (report.recorderFfmpeg ? report.recorderFfmpeg.found || report.recorderFfmpeg.autoInstall : true) &&
753
779
  // Local TTS needs no key: voice is in-process, captions auto-fall-back
754
780
  // to the offline derivation in `render`.
755
781
  (report.apiKey !== "missing" || report.tts.provider === "local") &&
@@ -767,7 +793,8 @@ export async function doctor(dir: string | undefined): Promise<void> {
767
793
  const line = (label: string, val: string | null) =>
768
794
  val ? ok(`${label}: ${val}`) : fail(`${label}: NOT FOUND`);
769
795
 
770
- line("node", r.node);
796
+ 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)`);
771
798
  line("ffmpeg", r.ffmpeg);
772
799
  if (r.browser.source === "default") {
773
800
  line("chrome", r.chrome);
@@ -815,13 +842,18 @@ export async function doctor(dir: string | undefined): Promise<void> {
815
842
  );
816
843
  if (r.playwright) ok(`playwright: resolvable`);
817
844
  else fail(`playwright: not installed`);
818
- if (r.recorderFfmpeg?.found) {
819
- ok(`playwright ffmpeg helper: revision ${r.recorderFfmpeg.revision} (records the take)`);
820
- } else if (r.recorderFfmpeg) {
845
+ const helper = r.recorderFfmpeg;
846
+ if (helper?.found) {
847
+ ok(`playwright ffmpeg helper: revision ${helper.revision} (records the take)`);
848
+ } else if (helper?.autoInstall) {
849
+ log(
850
+ `playwright ffmpeg helper: revision ${helper.revision} not installed yet — the first ` +
851
+ `take installs it (~2 MB from Playwright's CDN); or now: ${helper.install}`
852
+ );
853
+ } else if (helper) {
821
854
  fail(
822
- `playwright ffmpeg helper: revision ${r.recorderFfmpeg.revision} NOT FOUND at ` +
823
- `${r.recorderFfmpeg.path} — every take fails without it (~2 MB, once per ` +
824
- `Playwright version): ${r.recorderFfmpeg.install}`
855
+ `playwright ffmpeg helper: revision ${helper.revision} NOT FOUND at ${helper.path} and ` +
856
+ `AIDEMO_NO_AUTO_INSTALL is set — every take fails without it: ${helper.install}`
825
857
  );
826
858
  }
827
859
 
package/src/mcp/server.ts CHANGED
@@ -557,7 +557,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
557
557
  "Check prereqs: node, ffmpeg, Chrome (or the AIDEMO_CHROME_CHANNEL / " +
558
558
  "AIDEMO_CHROME_EXECUTABLE browser in use), TTS/STT endpoint (flags " +
559
559
  "LLM-only servers like Ollama), API key, playwright + its ffmpeg " +
560
- "helper (records the take; missing = every take fails), installed skill.",
560
+ "helper (records the take; installed on the first take unless " +
561
+ "AIDEMO_NO_AUTO_INSTALL=1), installed skill.",
561
562
  inputSchema: {
562
563
  dir: z.string().optional().describe("repo to check for an installed skill"),
563
564
  },
@@ -593,6 +594,9 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
593
594
  path: z.string(),
594
595
  revision: z.string(),
595
596
  install: z.string().describe("exact command to install it"),
597
+ autoInstall: z
598
+ .boolean()
599
+ .describe("the first take installs it (off with AIDEMO_NO_AUTO_INSTALL=1)"),
596
600
  })
597
601
  .nullable()
598
602
  .describe("Playwright's ffmpeg helper the built-in capture records with"),
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, {