@tandryukha/aidemo 0.15.2 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -161,7 +161,8 @@ open examples/local-demo/output/final-demo.mp4 # xdg-open on Linux, start on W
161
161
  Voice/captions need `OPENAI_API_KEY` in `.env` — **or** `AIDEMO_TTS_PROVIDER=local`
162
162
  (no key, offline), **or** `OPENAI_BASE_URL` at a local server. See
163
163
  [docs/LOCAL_MODELS.md](docs/LOCAL_MODELS.md). No Playwright browser download is
164
- needed — the engine uses your system Chrome (`channel: "chrome"`).
164
+ needed — the engine uses your system Chrome (`channel: "chrome"`; see
165
+ [Setup](#setup) to use a different browser).
165
166
 
166
167
  [![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)
167
168
 
@@ -214,6 +215,10 @@ Codex).
214
215
  - **Pipeline tools run as jobs** — `probe`/`record`/`render`/`voice`/`captions`/
215
216
  `compose`/`gif` return a `jobId` immediately; `job_status` reports stage,
216
217
  per-scene progress, and (on failure) the screenshot/frame-dump paths.
218
+ - **`.env` works like the CLI** — the server loads its cwd's and the engine's
219
+ `.env` at startup, and since MCP clients often start it outside your repo, each
220
+ tool given a demo dir also applies that repo's `.env` (nearest one walking up
221
+ from the demo dir to the repo root). `doctor {dir}` shows the result.
217
222
 
218
223
  ## Why it's built this way
219
224
 
@@ -253,6 +258,22 @@ npm install
253
258
  cp .env.example .env # add OPENAI_API_KEY, or use AIDEMO_TTS_PROVIDER=local (no key)
254
259
  ```
255
260
 
261
+ **Choosing the browser.** Every launch (recording, `inspect`, caption/card
262
+ rasterizing) uses system Google Chrome by default. Two env vars (shell or
263
+ `.env`) override that, highest precedence first:
264
+
265
+ - `AIDEMO_CHROME_EXECUTABLE=/path/to/chrome` — an explicit Chromium-based binary.
266
+ - `AIDEMO_CHROME_CHANNEL=<channel>` — a Playwright channel: `chrome` (default),
267
+ `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`,
268
+ `msedge-dev`, `msedge-canary`, or `chromium`. `chromium` (Chrome for Testing)
269
+ is the fix when an enterprise-managed Chrome closes automated sessions after
270
+ a few seconds ("Target page, context or browser has been closed"); it needs a
271
+ one-time `npx playwright install chromium`.
272
+
273
+ `aidemo doctor` reports which browser will be used and whether it was found.
274
+ `aidemo login` opens the same browser, so the profile you log into matches the
275
+ one you record with.
276
+
256
277
  ## Project layout (per demo)
257
278
 
258
279
  ```
package/docs/AUTHORING.md CHANGED
@@ -619,12 +619,21 @@ millisecond instead, when you want a specific moment.
619
619
 
620
620
  **Blank opening** (`output.trimLeadingBlank: true`): a take that starts on a
621
621
  page still painting opens on white/blank frames. With this on, compose trims
622
- the flat lead of the first scene (capped by `output.blankOpenCapMs`, default
623
- 1200) and re-aligns the scene's cursor, zoom and anchors to the trim. Compose
624
- always *measures* the opening either way and reports it as `blankOpenMs` plus
625
- a `blank-open` warning past ~400 ms; `aidemo lint` forecasts the same pitfall
622
+ the blank lead of the first scene down to `output.blankOpenCapMs` (default
623
+ 100 ms kept) and re-aligns the scene's cursor, zoom and anchors to the trim.
624
+ Compose always *measures* the opening either way — on the page footage,
625
+ before captions and overlays — and reports it as `blankOpenMs` plus a
626
+ `blank-open` warning past ~250 ms; `aidemo lint` forecasts the same pitfall
626
627
  from a first scene that opens with a `goto` and no wait.
627
628
 
629
+ "Blank" means *empty*, not *static*: one colour covering the frame **and** no
630
+ text or outlines beyond a cursor-sized mark. A white pre-paint or a solid
631
+ splash (any colour, black included) is blank; a motionless page that shows
632
+ something — a dark terminal with a prompt line, a login box on a big solid
633
+ background — is content, so it is never trimmed or warned about. The same
634
+ test guards the freeze-hold tail trim (`blank-tail`). A frame showing only a
635
+ cursor, a lone `$` or a small spinner still reads as blank.
636
+
628
637
  ## Capture resolution (`video.deviceScaleFactor`)
629
638
 
630
639
  `video: {width, height}` is the **logical** viewport — the CSS px the page
@@ -1217,8 +1226,14 @@ CLI. If nothing came up, skip this.
1217
1226
  composed video) to see the whole thing.
1218
1227
  - `AIDEMO_KEEP_TMP=1` preserves `.compose-tmp/` intermediates when debugging
1219
1228
  compose.
1220
- - `doctor` checks Node, ffmpeg, Chrome, the TTS/STT endpoint (and flags
1221
- LLM-only servers like Ollama, which have no audio endpoints — point
1229
+ - Browser: every launch uses system Chrome unless `AIDEMO_CHROME_EXECUTABLE`
1230
+ (explicit binary) or `AIDEMO_CHROME_CHANNEL` (`chromium` — Chrome for
1231
+ Testing, after `npx playwright install chromium` — `chrome-beta`, `msedge`,
1232
+ …) is set; the executable wins. Use `chromium` when a managed Chrome closes
1233
+ the automated session after a few seconds ("Target page, context or browser
1234
+ has been closed").
1235
+ - `doctor` checks Node, ffmpeg, the browser in use, the TTS/STT endpoint
1236
+ (and flags LLM-only servers like Ollama, which have no audio endpoints — point
1222
1237
  `OPENAI_BASE_URL` at a speech server such as speaches instead).
1223
1238
 
1224
1239
  ## Import from Playwright (trace or test file)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tandryukha/aidemo",
3
- "version": "0.15.2",
3
+ "version": "0.16.1",
4
4
  "license": "MIT",
5
5
  "description": "AI Demo Engine — turn a storyboard into a narrated, captioned product demo video",
6
6
  "type": "module",
@@ -28,12 +28,14 @@
28
28
  "build": "tsc --noEmit",
29
29
  "serve-example": "node examples/local-demo/serve.mjs",
30
30
  "mcp-smoke": "node test/mcp-smoke.mjs",
31
+ "test:blank-open": "tsx --test test/blank-open.mjs",
32
+ "mcp-env-test": "node test/mcp-env.mjs",
31
33
  "guard-openai-key": "tsx scripts/guard-openai-key.ts",
32
34
  "sync:plugin-skill": "cp .claude/skills/record-demo/SKILL.md plugins/record-demo/skills/record-demo/SKILL.md",
33
35
  "check:plugin-skill": "diff -q .claude/skills/record-demo/SKILL.md plugins/record-demo/skills/record-demo/SKILL.md"
34
36
  },
35
37
  "dependencies": {
36
- "@modelcontextprotocol/sdk": "1.30.0",
38
+ "@modelcontextprotocol/sdk": "1.32.2",
37
39
  "commander": "^12.1.0",
38
40
  "openai": "^4.67.0",
39
41
  "playwright": "^1.55.1",
package/src/attention.ts CHANGED
@@ -12,7 +12,8 @@
12
12
  * every PNG is rendered at deviceScaleFactor = pxScale so it lands 1:1.
13
13
  */
14
14
 
15
- import { chromium, type Page } from "playwright";
15
+ import type { Page } from "playwright";
16
+ import { launchChrome } from "./browser.js";
16
17
  import { resolve } from "node:path";
17
18
  import type { AttentionEvent, AttentionStyle, RedactSpan } from "./types.js";
18
19
  import { ensureDir } from "./util.js";
@@ -107,7 +108,7 @@ export async function renderAttentionPngs(
107
108
  ): Promise<OverlayItem[]> {
108
109
  if (events.length === 0) return [];
109
110
  await ensureDir(outDir);
110
- const browser = await chromium.launch({ channel: "chrome", headless: true });
111
+ const browser = await launchChrome({ headless: true });
111
112
  const context = await browser.newContext({
112
113
  viewport: { width: videoW, height: videoH },
113
114
  deviceScaleFactor: pxScale,
@@ -231,7 +232,7 @@ export async function renderKeyChipPngs(
231
232
  ): Promise<OverlayItem[]> {
232
233
  if (keys.length === 0) return [];
233
234
  await ensureDir(outDir);
234
- const browser = await chromium.launch({ channel: "chrome", headless: true });
235
+ const browser = await launchChrome({ headless: true });
235
236
  const context = await browser.newContext({
236
237
  viewport: { width: 600, height: 120 },
237
238
  deviceScaleFactor: pxScale,
@@ -287,7 +288,7 @@ export async function renderClickRingPngs(
287
288
  ): Promise<OverlayItem[]> {
288
289
  if (clicks.length === 0) return [];
289
290
  await ensureDir(outDir);
290
- const browser = await chromium.launch({ channel: "chrome", headless: true });
291
+ const browser = await launchChrome({ headless: true });
291
292
  const context = await browser.newContext({
292
293
  viewport: { width: 64, height: 64 },
293
294
  deviceScaleFactor: pxScale,
package/src/browser.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Single choke point for every Playwright browser launch (issue #80).
3
+ *
4
+ * Every launch site — recorder, inspect, caption/card/frame/attention/cursor
5
+ * rasterizers — goes through launchChrome()/launchChromePersistent(), which
6
+ * merge chromeLaunchOptions() (AIDEMO_CHROME_EXECUTABLE > AIDEMO_CHROME_CHANNEL
7
+ * > `channel: "chrome"`) into the caller's options. With neither env var set
8
+ * the options are exactly the engine's historical `{ channel: "chrome", ... }`.
9
+ */
10
+ import { chromium, type Browser, type BrowserContext, type LaunchOptions } from "playwright";
11
+ import { BrowserSetupError, chromeLaunchOptions, type ChromeLaunchTarget } from "./config.js";
12
+
13
+ type PersistentOptions = NonNullable<Parameters<typeof chromium.launchPersistentContext>[1]>;
14
+
15
+ /** Human-readable description of the configured browser, for logs/doctor. */
16
+ export function describeChromeTarget(target: ChromeLaunchTarget = chromeLaunchOptions()): string {
17
+ if ("executablePath" in target) return `executable ${target.executablePath} (AIDEMO_CHROME_EXECUTABLE)`;
18
+ return target.channel === "chrome" && !process.env.AIDEMO_CHROME_CHANNEL?.trim()
19
+ ? `channel chrome (default — system Google Chrome)`
20
+ : `channel ${target.channel} (AIDEMO_CHROME_CHANNEL)`;
21
+ }
22
+
23
+ const MISSING_RE =
24
+ /Executable doesn't exist|is not found at|distribution '[^']*' is not found|npx playwright install|ENOENT/i;
25
+
26
+ /**
27
+ * Turn a "browser binary not found" launch failure into an actionable error.
28
+ * Any other failure is returned untouched so callers' own wrapping (e.g. the
29
+ * recorder's profile-lock hint) still sees the original message.
30
+ */
31
+ export function explainLaunchError(err: unknown, target: ChromeLaunchTarget): Error {
32
+ const e = err instanceof Error ? err : new Error(String(err));
33
+ if (!MISSING_RE.test(e.message)) return e;
34
+ let hint: string;
35
+ if ("executablePath" in target) {
36
+ hint =
37
+ `AIDEMO_CHROME_EXECUTABLE=${target.executablePath} could not be launched — ` +
38
+ `point it at an existing Chromium-based browser binary, or unset it.`;
39
+ } else if (target.channel === "chromium") {
40
+ hint =
41
+ `Playwright's Chromium (AIDEMO_CHROME_CHANNEL=chromium) is not installed — ` +
42
+ `run: npx playwright install chromium (or unset AIDEMO_CHROME_CHANNEL to use system Chrome).`;
43
+ } else if (target.channel === "chrome") {
44
+ hint =
45
+ `Google Chrome was not found. Install Chrome, or use Playwright's Chromium: ` +
46
+ `npx playwright install chromium && set AIDEMO_CHROME_CHANNEL=chromium ` +
47
+ `(or AIDEMO_CHROME_EXECUTABLE=/path/to/chrome).`;
48
+ } else {
49
+ hint =
50
+ `Browser channel "${target.channel}" (AIDEMO_CHROME_CHANNEL) is not installed — ` +
51
+ `install it (npx playwright install ${target.channel}) or unset AIDEMO_CHROME_CHANNEL.`;
52
+ }
53
+ const out = new BrowserSetupError(`${hint}\nOriginal error: ${e.message}`);
54
+ (out as Error & { cause?: unknown }).cause = e;
55
+ return out;
56
+ }
57
+
58
+ /** chromium.launch() with the configured browser. */
59
+ export async function launchChrome(options: Omit<LaunchOptions, "channel" | "executablePath"> = {}): Promise<Browser> {
60
+ const target = chromeLaunchOptions();
61
+ try {
62
+ return await chromium.launch({ ...target, ...options });
63
+ } catch (err) {
64
+ throw explainLaunchError(err, target);
65
+ }
66
+ }
67
+
68
+ /** chromium.launchPersistentContext() with the configured browser. */
69
+ export async function launchChromePersistent(
70
+ userDataDir: string,
71
+ options: Omit<PersistentOptions, "channel" | "executablePath"> = {}
72
+ ): Promise<BrowserContext> {
73
+ const target = chromeLaunchOptions();
74
+ try {
75
+ return await chromium.launchPersistentContext(userDataDir, { ...target, ...options });
76
+ } catch (err) {
77
+ throw explainLaunchError(err, target);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Doctor probe for a NON-default browser target: launch it headless once and
83
+ * read its version. Returns `{ version }` or `{ error }` (actionable message).
84
+ */
85
+ export async function probeConfiguredBrowser(): Promise<{ version: string } | { error: string }> {
86
+ let browser: Browser | undefined;
87
+ try {
88
+ browser = await launchChrome({ headless: true, timeout: 20000 });
89
+ return { version: browser.version() };
90
+ } catch (err) {
91
+ return { error: (err as Error).message.split("\n")[0] };
92
+ } finally {
93
+ await browser?.close().catch(() => {});
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Binary for a NON-Playwright launch (`aidemo login` opens a normal window on
99
+ * the profile) when the configured browser isn't system Chrome: the
100
+ * AIDEMO_CHROME_EXECUTABLE path, or Playwright's Chromium for
101
+ * AIDEMO_CHROME_CHANNEL=chromium. null = keep login's system-Chrome launch.
102
+ */
103
+ export function manualLaunchBinary(): string | null {
104
+ const target = chromeLaunchOptions();
105
+ if ("executablePath" in target) return target.executablePath;
106
+ if (target.channel === "chromium") return chromium.executablePath();
107
+ return null;
108
+ }
@@ -1,4 +1,4 @@
1
- import { chromium } from "playwright";
1
+ import { launchChrome } from "./browser.js";
2
2
  import { resolve } from "node:path";
3
3
  import type { Cue } from "./captions.js";
4
4
  import type { CaptionsConfig } from "./types.js";
@@ -28,7 +28,7 @@ export async function renderCaptionPngs(
28
28
  look: CaptionsConfig & { font?: string } = {}
29
29
  ): Promise<RenderedCue[]> {
30
30
  await ensureDir(outDir);
31
- const browser = await chromium.launch({ channel: "chrome", headless: true });
31
+ const browser = await launchChrome({ headless: true });
32
32
  // Layout at the storyboard's logical width; deviceScaleFactor matches the
33
33
  // raw video's pixel density (1 for Playwright capture, 2 for native/OBS
34
34
  // retina capture) so the PNG overlays the frame 1:1.
package/src/cards.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { chromium } from "playwright";
1
+ import { launchChrome } from "./browser.js";
2
2
  import type { Brand, Card } from "./types.js";
3
3
 
4
4
  /**
@@ -17,7 +17,7 @@ export async function renderCardPng(
17
17
  brand?: Brand,
18
18
  logoDataUri?: string
19
19
  ): Promise<void> {
20
- const browser = await chromium.launch({ channel: "chrome", headless: true });
20
+ const browser = await launchChrome({ headless: true });
21
21
  const context = await browser.newContext({
22
22
  viewport: { width: logicalW, height: logicalH },
23
23
  deviceScaleFactor: scale,
package/src/compose.ts CHANGED
@@ -236,7 +236,7 @@ export async function compose(
236
236
  let leadTrimMs = 0;
237
237
  if (i === 0 && storyboard.output?.trimLeadingBlank && srcMs > 1000) {
238
238
  const cap = storyboard.output.blankOpenCapMs ?? 100;
239
- const flatLead = await probeFlatLeadMs(rawSegPath);
239
+ const flatLead = await probeFlatLeadMs(rawSegPath, 4000, 0, pxScale);
240
240
  if (flatLead > cap + 80) {
241
241
  leadTrimMs = Math.min(flatLead - cap, srcMs - 500);
242
242
  srcMs -= leadTrimMs;
@@ -251,7 +251,7 @@ export async function compose(
251
251
  // (issue #36), so drop a flat tail before deciding how long to hold —
252
252
  // but only when we would actually hold, and never the whole segment.
253
253
  if (srcMs > 0 && targetMs > srcMs + 400) {
254
- const flatMs = await probeFlatTailMs(rawSegPath, srcMs);
254
+ const flatMs = await probeFlatTailMs(rawSegPath, srcMs, 4000, pxScale);
255
255
  const trimTo = srcMs - flatMs;
256
256
  if (flatMs > 200 && trimTo > 500) {
257
257
  blankTrimMs = flatMs;
@@ -668,6 +668,13 @@ export async function compose(
668
668
  );
669
669
  }
670
670
 
671
+ // Blank-open measurement (issues #47, #78), taken HERE — on the assembled
672
+ // scenes, before redaction, attention, compose-time cursor, zoom, frame and
673
+ // captions — because the final MP4 can't tell a blank page from content: a
674
+ // caption pill over a white pre-paint has as much text as a terminal prompt.
675
+ // Same timeline as the final video minus the intro card.
676
+ const blankOpenMs = await probeFlatLeadMs(content, 4000, 0, pxScale).catch(() => 0);
677
+
671
678
  // Redaction first: blur boxes ride every later pass (cursor, zoom, blur).
672
679
  if (redactPlaced.length) {
673
680
  let src = content;
@@ -1094,10 +1101,10 @@ export async function compose(
1094
1101
  : null;
1095
1102
  await muxAudio(project, storyboard, finalVideo, introMs, chapters);
1096
1103
  ok(`final video → ${project.outputPath}`);
1097
- // Blank-open measurement + poster (issue #47). The poster used to be taken
1098
- // at a fixed offset, which on a demo that opens with a `goto` is exactly the
1099
- // white load frame; pick the first frame that actually shows content instead.
1100
- const blankOpenMs = await probeFlatLeadMs(project.outputPath, 4000, introMs).catch(() => 0);
1104
+ // Blank-open warning + poster (issue #47; measured after scene assembly,
1105
+ // above). The poster used to be taken at a fixed offset, which on a demo
1106
+ // that opens with a `goto` is exactly the white load frame; pick the first
1107
+ // frame that actually shows content instead.
1101
1108
  if (blankOpenMs > 250) {
1102
1109
  warn(
1103
1110
  "blank-open",
package/src/config.ts CHANGED
@@ -66,7 +66,9 @@ async function readEnvFile(envPath: string): Promise<Record<string, string>> {
66
66
  * instead. See scripts/guard-openai-key.ts, which fails the build if this
67
67
  * skip-if-already-set shape ever comes back. Ambient only wins here when
68
68
  * NEITHER .env file defines the key at all (e.g. a deliberate one-off invocation
69
- * like `OPENAI_API_KEY=sk-... aidemo record ...`). Called once at CLI startup.
69
+ * like `OPENAI_API_KEY=sk-... aidemo record ...`). Called once at CLI startup —
70
+ * which includes `aidemo mcp`; MCP tools then layer the demo's project .env on
71
+ * top via applyProjectEnv(), since the server's cwd is rarely the project.
70
72
  */
71
73
  export async function loadEnv(): Promise<void> {
72
74
  const cwdEnv = resolve(process.cwd(), ".env");
@@ -79,6 +81,71 @@ export async function loadEnv(): Promise<void> {
79
81
  }
80
82
  }
81
83
 
84
+ /**
85
+ * The `.env` a CLI user of this demo dir would have loaded: the nearest
86
+ * `.env` walking up from `dir`, stopping at the enclosing repo root (the
87
+ * first ancestor holding `.git`) — i.e. the cwd/.env of someone running
88
+ * `aidemo <cmd> demos/<name>` from their repo. null when there is none.
89
+ */
90
+ async function findProjectEnv(dir: string): Promise<string | null> {
91
+ let cur = resolve(dir);
92
+ for (;;) {
93
+ const candidate = resolve(cur, ".env");
94
+ try {
95
+ await fs.access(candidate);
96
+ return candidate;
97
+ } catch {
98
+ // keep walking
99
+ }
100
+ try {
101
+ await fs.access(resolve(cur, ".git"));
102
+ return null; // repo root reached without a .env
103
+ } catch {
104
+ // not a repo root
105
+ }
106
+ const parent = resolve(cur, "..");
107
+ if (parent === cur) return null;
108
+ cur = parent;
109
+ }
110
+ }
111
+
112
+ /** Keys a project .env overrode → their value before the first override (undefined = unset). */
113
+ const projectEnvSaved = new Map<string, string | undefined>();
114
+ let projectEnvFile: string | null = null;
115
+
116
+ /**
117
+ * MCP counterpart of loadEnv()'s cwd/.env layer (issue #77). loadEnv() runs
118
+ * once at startup for `aidemo mcp` too, but an MCP client usually spawns the
119
+ * server with a cwd that is NOT the consumer's repo (and an npx engine root
120
+ * has no .env), so the project's .env was never seen. Tools that take a demo
121
+ * dir call this to apply that project's .env (see findProjectEnv) with the
122
+ * same precedence as the CLI's cwd/.env: it overrides the engine .env and the
123
+ * ambient environment. Switching to another project first restores every key
124
+ * the previous project's .env set, so values never leak between repos.
125
+ * Logs only to stderr (stdout is the JSON-RPC channel). Returns the file applied.
126
+ */
127
+ export async function applyProjectEnv(dir: string): Promise<string | null> {
128
+ const envFile = await findProjectEnv(dir);
129
+ const values = envFile ? await readEnvFile(envFile) : {};
130
+ for (const [key, prev] of projectEnvSaved) {
131
+ if (key in values) continue;
132
+ if (prev === undefined) delete process.env[key];
133
+ else process.env[key] = prev;
134
+ projectEnvSaved.delete(key);
135
+ }
136
+ for (const [key, val] of Object.entries(values)) {
137
+ if (!projectEnvSaved.has(key)) projectEnvSaved.set(key, process.env[key]);
138
+ process.env[key] = val;
139
+ }
140
+ if (envFile !== projectEnvFile) {
141
+ process.stderr.write(
142
+ envFile ? ` env: loaded ${envFile}\n` : ` env: no project .env for ${resolve(dir)}\n`
143
+ );
144
+ projectEnvFile = envFile;
145
+ }
146
+ return envFile;
147
+ }
148
+
82
149
  /**
83
150
  * Custom endpoint for any OpenAI-compatible server (LocalAI, speaches,
84
151
  * Kokoro-FastAPI, …). AIDEMO_OPENAI_BASE_URL wins over the SDK-standard
@@ -220,3 +287,55 @@ export const obsPassword = () => process.env.AIDEMO_OBS_PASSWORD;
220
287
  */
221
288
  export const nativeCropUnsafe = () =>
222
289
  process.env.AIDEMO_NATIVE_CROP_UNSAFE === "1";
290
+
291
+ /** Playwright browser channels the engine accepts for AIDEMO_CHROME_CHANNEL. */
292
+ export const CHROME_CHANNELS = [
293
+ "chrome",
294
+ "chrome-beta",
295
+ "chrome-dev",
296
+ "chrome-canary",
297
+ "msedge",
298
+ "msedge-beta",
299
+ "msedge-dev",
300
+ "msedge-canary",
301
+ "chromium",
302
+ ] as const;
303
+ export type ChromeChannel = (typeof CHROME_CHANNELS)[number];
304
+
305
+ /**
306
+ * A browser-selection problem (bad AIDEMO_CHROME_CHANNEL, browser not
307
+ * installed) — already actionable, so callers rethrow it without adding
308
+ * unrelated hints (e.g. the recorder's profile-lock advice).
309
+ */
310
+ export class BrowserSetupError extends Error {
311
+ override name = "BrowserSetupError";
312
+ }
313
+
314
+ /** Which browser binary every Playwright launch uses — spread into launch options. */
315
+ export type ChromeLaunchTarget = { channel: ChromeChannel } | { executablePath: string };
316
+
317
+ /**
318
+ * The browser every launch site uses (issue #80). Precedence:
319
+ * 1. AIDEMO_CHROME_EXECUTABLE — explicit binary path (`{ executablePath }`)
320
+ * 2. AIDEMO_CHROME_CHANNEL — a Playwright channel (`{ channel }`), e.g.
321
+ * `chromium` (Chrome for Testing — for managed Chrome that kills automated
322
+ * sessions; needs `npx playwright install chromium`)
323
+ * 3. default `{ channel: "chrome" }` — system Google Chrome, exactly the
324
+ * engine's historical launch options.
325
+ * Throws on an unknown channel name. Re-read on every call (env is live).
326
+ */
327
+ export function chromeLaunchOptions(): ChromeLaunchTarget {
328
+ const exe = process.env.AIDEMO_CHROME_EXECUTABLE?.trim();
329
+ if (exe) return { executablePath: exe };
330
+ const raw = process.env.AIDEMO_CHROME_CHANNEL?.trim();
331
+ if (!raw) return { channel: "chrome" };
332
+ const channel = raw.toLowerCase();
333
+ if (!(CHROME_CHANNELS as readonly string[]).includes(channel)) {
334
+ throw new BrowserSetupError(
335
+ `AIDEMO_CHROME_CHANNEL="${raw}" is not a supported browser channel. ` +
336
+ `Use one of: ${CHROME_CHANNELS.join(", ")} — or set AIDEMO_CHROME_EXECUTABLE ` +
337
+ `to a Chromium-based browser binary instead.`
338
+ );
339
+ }
340
+ return { channel: channel as ChromeChannel };
341
+ }
package/src/cursor.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { chromium } from "playwright";
1
+ import { launchChrome } from "./browser.js";
2
2
 
3
3
  /**
4
4
  * The classic arrow pointer, shared by both cursor render paths so they look
@@ -30,7 +30,7 @@ export async function renderCursorPng(
30
30
  // Logical canvas is 32 units (24 arrow + shadow headroom); scale to sizePx.
31
31
  const LOGICAL = 32;
32
32
  const dsf = Math.max(1, sizePx / LOGICAL);
33
- const browser = await chromium.launch({ channel: "chrome", headless: true });
33
+ const browser = await launchChrome({ headless: true });
34
34
  const context = await browser.newContext({
35
35
  viewport: { width: LOGICAL, height: LOGICAL },
36
36
  deviceScaleFactor: dsf,
package/src/distribute.ts CHANGED
@@ -17,7 +17,15 @@ import { resolve, dirname, basename } from "node:path";
17
17
  import { execFile } from "node:child_process";
18
18
  import { promisify } from "node:util";
19
19
  import { platform, release } from "node:os";
20
- import { ENGINE_ROOT, engineVersion, openAiBaseUrl, ttsProvider } from "./config.js";
20
+ import {
21
+ ENGINE_ROOT,
22
+ engineVersion,
23
+ openAiBaseUrl,
24
+ ttsProvider,
25
+ chromeLaunchOptions,
26
+ type ChromeLaunchTarget,
27
+ } from "./config.js";
28
+ import { describeChromeTarget, probeConfiguredBrowser } from "./browser.js";
21
29
  import { localTtsStatus } from "./voice-local.js";
22
30
  import { Project } from "./project.js";
23
31
  import { STARTER_BRIEF, STARTER_STORYBOARD, briefFromInspect, storyboardFromInspect } from "./starter.js";
@@ -371,6 +379,43 @@ async function probeChrome(): Promise<string | null> {
371
379
  return null;
372
380
  }
373
381
 
382
+ /** Resolve + probe the browser every launch uses (see chromeLaunchOptions). */
383
+ async function probeBrowserTarget(chrome: string | null): Promise<DoctorReport["browser"]> {
384
+ let target: ChromeLaunchTarget;
385
+ try {
386
+ target = chromeLaunchOptions();
387
+ } catch (err) {
388
+ return {
389
+ source: "channel",
390
+ channel: process.env.AIDEMO_CHROME_CHANNEL,
391
+ description: `channel ${process.env.AIDEMO_CHROME_CHANNEL} (AIDEMO_CHROME_CHANNEL)`,
392
+ found: false,
393
+ error: (err as Error).message,
394
+ };
395
+ }
396
+ const description = describeChromeTarget(target);
397
+ const isDefault = "channel" in target && !process.env.AIDEMO_CHROME_CHANNEL?.trim();
398
+ if (isDefault) {
399
+ // Unchanged default: system Chrome, detected without launching it.
400
+ return {
401
+ source: "default",
402
+ channel: "chrome",
403
+ description,
404
+ found: Boolean(chrome),
405
+ ...(chrome ? { version: chrome } : { error: "Google Chrome not found" }),
406
+ };
407
+ }
408
+ const probe = await probeConfiguredBrowser();
409
+ return {
410
+ ...("executablePath" in target
411
+ ? { source: "executable" as const, executablePath: target.executablePath }
412
+ : { source: "channel" as const, channel: target.channel }),
413
+ description,
414
+ found: "version" in probe,
415
+ ...("version" in probe ? { version: probe.version } : { error: probe.error }),
416
+ };
417
+ }
418
+
374
419
  /** Tail the most recent logs/*.log under a demo dir (best-effort). */
375
420
  async function recentLogTail(demoDir: string, lines = 40): Promise<string | null> {
376
421
  const logsDir = resolve(demoDir, "logs");
@@ -579,7 +624,24 @@ export interface DoctorReport {
579
624
  engineVersion: string;
580
625
  node: string;
581
626
  ffmpeg: string | null;
627
+ /** System Google Chrome (version or path) — what the default channel launches. */
582
628
  chrome: string | null;
629
+ /**
630
+ * The browser every launch will actually use (issue #80):
631
+ * AIDEMO_CHROME_EXECUTABLE > AIDEMO_CHROME_CHANNEL > default channel chrome.
632
+ */
633
+ browser: {
634
+ source: "default" | "channel" | "executable";
635
+ channel?: string;
636
+ executablePath?: string;
637
+ /** e.g. "channel chromium (AIDEMO_CHROME_CHANNEL)". */
638
+ description: string;
639
+ found: boolean;
640
+ /** Version / path when found. */
641
+ version?: string;
642
+ /** Actionable reason when not found (or the env var is invalid). */
643
+ error?: string;
644
+ };
583
645
  gh: string | null;
584
646
  endpoint: { url: string; custom: boolean; warning?: string };
585
647
  apiKey: "set" | "not-required" | "missing";
@@ -630,11 +692,14 @@ export async function doctorReport(dir: string | undefined): Promise<DoctorRepor
630
692
  const manifest = await readManifest(target);
631
693
  const latest = engineVersion();
632
694
  const localTts = ttsProvider() === "local" ? await localTtsStatus() : null;
695
+ const chrome = await probeChrome();
696
+ const browser = await probeBrowserTarget(chrome);
633
697
  const report: DoctorReport = {
634
698
  engineVersion: latest,
635
699
  node: process.version,
636
700
  ffmpeg: await probeCmd("ffmpeg", ["-version"]),
637
- chrome: await probeChrome(),
701
+ chrome,
702
+ browser,
638
703
  gh: await probeCmd("gh", ["--version"]),
639
704
  endpoint: {
640
705
  url: base ?? "https://api.openai.com/v1",
@@ -674,7 +739,7 @@ export async function doctorReport(dir: string | undefined): Promise<DoctorRepor
674
739
  };
675
740
  report.ok = Boolean(
676
741
  report.ffmpeg &&
677
- report.chrome &&
742
+ report.browser.found &&
678
743
  report.playwright &&
679
744
  // Local TTS needs no key: voice is in-process, captions auto-fall-back
680
745
  // to the offline derivation in `render`.
@@ -695,7 +760,14 @@ export async function doctor(dir: string | undefined): Promise<void> {
695
760
 
696
761
  line("node", r.node);
697
762
  line("ffmpeg", r.ffmpeg);
698
- line("chrome", r.chrome);
763
+ if (r.browser.source === "default") {
764
+ line("chrome", r.chrome);
765
+ ok(`browser: ${r.browser.description}`);
766
+ } else {
767
+ if (r.browser.found) ok(`browser: ${r.browser.description} — ${r.browser.version}`);
768
+ else fail(`browser: ${r.browser.description} — ${r.browser.error ?? "NOT FOUND"}`);
769
+ log(`system chrome (unused): ${r.chrome ?? "not found"}`);
770
+ }
699
771
  line("gh (feedback)", r.gh);
700
772
  ok(
701
773
  `voice/captions endpoint: ${r.endpoint.custom ? `${r.endpoint.url} (custom)` : "api.openai.com (default)"}`
package/src/ffmpeg.ts CHANGED
@@ -83,43 +83,56 @@ export function probeDurationMs(file: string): Promise<number> {
83
83
  }
84
84
 
85
85
  /**
86
- * Length (ms) of the *flat* tail of a video — the trailing run of frames that
87
- * are a single solid color.
86
+ * Thresholds for "this frame shows nothing" — a blank pre-paint, a solid
87
+ * splash — as opposed to a page whose content is merely sparse (issue #78: a
88
+ * dark terminal with one prompt line). A frame is blank only when BOTH hold:
88
89
  *
89
- * Compose freeze-holds a scene's last frame when the narration outruns the
90
- * recorded action. When the scene ends right before a `goto`, that last frame
91
- * is the browser's white pre-paint, and the hold clones *white* for as long as
92
- * the narration runs (issue #36: 37 near-blank seconds out of 209). Trimming
93
- * the flat tail first makes the hold clone the last frame that actually shows
94
- * the app.
90
+ * 1. `FLAT_SPREAD` — `YHIGH - YLOW` from `signalstats` (luma 90th minus 10th
91
+ * percentile) is at most 4: one colour covers ≥ 80% of the frame.
92
+ * Percentiles, not min/max, so a cursor arrow or a stray antialiased pixel
93
+ * doesn't read as content; `YDEV` would be the natural measure but many
94
+ * ffmpeg builds don't emit it. On its own this is NOT a blank test: sparse
95
+ * text never reaches the 10%/90% tails, so a terminal prompt (or a login
96
+ * box on a big solid background) measured exactly like a white page.
97
+ * 2. `BLANK_EDGE_PX` — `edgedetect` (Canny, binary 0/255 output) +
98
+ * `signalstats` YAVG gives the fraction of edge pixels; times the frame
99
+ * area and divided by the device-pixel scale, that is the length of
100
+ * visible outlines in CSS px. Glyphs are all outline, so this counts text
101
+ * the percentiles miss. Measured (Chrome screenshots → x264 crf 20, at
102
+ * 1280x720@1x, 1920x1080@1x, 1280x720@2x): the engine's 24 px cursor over
103
+ * white/grey/black ≈ 54–142; a blank page 0; `user@box:~$ ▌` 14 px on
104
+ * black ≈ 288–522; `root@ttyd:~# ▌` 16 px ≈ 447–630; a heading + one
105
+ * sentence ≈ 1210–1560. 200 sits between the cursor and a one-line prompt
106
+ * with ~1.4x margin each side. Below it — a lone `$ ▌` (≈ 75–150), a bare
107
+ * 32 px spinner (≈ 115–135) — still counts as blank, which for an opening
108
+ * frame is what a viewer sees too.
95
109
  *
96
- * Flatness is `YHIGH - YLOW` from `signalstats` — the spread between the luma
97
- * 90th and 10th percentiles. Percentiles rather than min/max so a cursor arrow
98
- * or a stray antialiased pixel doesn't make a blank page look like content;
99
- * `YDEV` would be the natural measure but many ffmpeg builds don't emit it.
100
- *
101
- * Scans at most `windowMs` from the end; returns 0 when the tail has real
102
- * content, or when the probe fails for any reason (best-effort polish, never a
103
- * reason to fail a compose).
110
+ * Both filters are core libavfilter (no external libs), like the
111
+ * `freezedetect`/`signalstats` probes already used here.
104
112
  */
105
- export function probeFlatTailMs(
113
+ const FLAT_SPREAD = 4;
114
+ const BLANK_EDGE_PX = 200;
115
+ const EDGE_FILTER = "edgedetect=low=0.1:high=0.3";
116
+
117
+ /** Per-frame stats from one `signalstats,metadata=print` pass, in frame order. */
118
+ function signalstatsFrames(
106
119
  file: string,
107
- durMs: number,
108
- windowMs = 4000
109
- ): Promise<number> {
110
- const start = Math.max(0, durMs - windowMs) / 1000;
120
+ vf: string,
121
+ ss: number,
122
+ durSec: number | null
123
+ ): Promise<Array<Record<string, number>> | null> {
111
124
  return new Promise((resolvePromise) => {
112
125
  const proc = spawn(
113
126
  "ffmpeg",
114
127
  [
115
128
  "-hide_banner",
116
129
  "-nostats",
117
- "-ss",
118
- start.toFixed(3),
130
+ ...(ss > 0 ? ["-ss", ss.toFixed(3)] : []),
119
131
  "-i",
120
132
  file,
133
+ ...(durSec != null ? ["-t", durSec.toFixed(3)] : []),
121
134
  "-vf",
122
- "signalstats,metadata=print:file=-",
135
+ `${vf}signalstats,metadata=print:file=-`,
123
136
  "-f",
124
137
  "null",
125
138
  "-",
@@ -128,115 +141,155 @@ export function probeFlatTailMs(
128
141
  );
129
142
  let out = "";
130
143
  proc.stdout.on("data", (d) => (out += d.toString()));
131
- proc.on("error", () => resolvePromise(0));
132
- proc.on("close", () => {
144
+ proc.on("error", () => resolvePromise(null));
145
+ proc.on("close", (code) => {
146
+ if (code !== 0) return resolvePromise(null);
133
147
  // The metadata muxer emits a header line per frame followed by one line
134
148
  // per key:
135
149
  // frame:12 pts:... pts_time:0.4
136
150
  // lavfi.signalstats.YLOW=16
137
151
  // ...
138
- const frames: Array<{ t: number; spread: number }> = [];
139
- let t = NaN;
140
- let low = NaN;
141
- let high = NaN;
142
- const flush = (): void => {
143
- if (!Number.isNaN(t) && !Number.isNaN(low) && !Number.isNaN(high)) {
144
- frames.push({ t, spread: high - low });
145
- }
146
- low = NaN;
147
- high = NaN;
148
- };
152
+ const frames: Array<Record<string, number>> = [];
153
+ let cur: Record<string, number> | null = null;
149
154
  for (const line of out.split("\n")) {
150
- const ts = /pts_time:\s*([\d.]+)/.exec(line);
155
+ const ts = /pts_time:\s*(-?[\d.]+)/.exec(line);
151
156
  if (ts) {
152
- flush();
153
- t = parseFloat(ts[1]);
157
+ cur = { t: parseFloat(ts[1]) };
158
+ frames.push(cur);
154
159
  continue;
155
160
  }
156
- const lo = /YLOW=\s*([\d.]+)/.exec(line);
157
- if (lo) low = parseFloat(lo[1]);
158
- const hi = /YHIGH=\s*([\d.]+)/.exec(line);
159
- if (hi) high = parseFloat(hi[1]);
161
+ const kv = /lavfi\.signalstats\.(\w+)=\s*(-?[\d.e+-]+)/.exec(line);
162
+ if (kv && cur) cur[kv[1]] = parseFloat(kv[2]);
160
163
  }
161
- flush();
162
- if (frames.length === 0) return resolvePromise(0);
163
- // Walk back while frames stay flat. A real page — even a mostly-empty
164
- // one — has chrome, text and edges, so its spread is far above this.
165
- const FLAT_SPREAD = 4;
166
- let i = frames.length - 1;
167
- if (frames[i].spread > FLAT_SPREAD) return resolvePromise(0);
168
- while (i > 0 && frames[i - 1].spread <= FLAT_SPREAD) i--;
169
- resolvePromise(Math.max(0, Math.round(durMs - (start + frames[i].t) * 1000)));
164
+ resolvePromise(frames);
170
165
  });
171
166
  });
172
167
  }
173
168
 
169
+ /** Luma flatness per frame of a window; `t` is absolute seconds in `file`. */
170
+ async function flatFrames(
171
+ file: string,
172
+ ss: number,
173
+ durSec: number | null
174
+ ): Promise<Array<{ t: number; flat: boolean }> | null> {
175
+ const frames = await signalstatsFrames(file, "", ss, durSec);
176
+ if (!frames) return null;
177
+ const out: Array<{ t: number; flat: boolean }> = [];
178
+ for (const f of frames) {
179
+ if (f.YLOW == null || f.YHIGH == null) return null;
180
+ out.push({ t: ss + f.t, flat: f.YHIGH - f.YLOW <= FLAT_SPREAD });
181
+ }
182
+ return out;
183
+ }
184
+
174
185
  /**
175
- * Mirror of `probeFlatTailMs` for the HEAD of a file: how many ms at the start
176
- * are a flat, contentless frame (the browser's white pre-paint after a `goto`).
177
- * Used to open the video on the product instead of on a blank page, and to pick
178
- * a poster frame that isn't a white rectangle (issue #47). Best-effort: any
179
- * probe failure resolves to 0.
186
+ * Which frames of a contiguous run of flat frames (absolute times, in order)
187
+ * are also free of outlines — i.e. truly blank. The edge pass is the costly
188
+ * one (Canny on every full-size frame), so it runs only over this run, never
189
+ * over a window whose frames already differ. A frame the second pass can't be
190
+ * matched to counts as content: a probe hiccup must never cause a trim.
180
191
  */
181
- export function probeFlatLeadMs(
192
+ async function blankInRun(
182
193
  file: string,
194
+ run: Array<{ t: number }>,
195
+ pxScale: number
196
+ ): Promise<boolean[] | null> {
197
+ const step =
198
+ run.length > 1 ? (run[run.length - 1].t - run[0].t) / (run.length - 1) : 1 / 30;
199
+ const half = Math.max(step / 2, 0.001);
200
+ // Seek half a frame early: `-ss` is rounded to ms, and an accurate seek
201
+ // drops a frame whose pts lands a hair before it.
202
+ const ss = Math.max(0, run[0].t - half);
203
+ const [dims, edges] = await Promise.all([
204
+ probeVideoDims(file).catch(() => null),
205
+ signalstatsFrames(file, `${EDGE_FILTER},`, ss, run[run.length - 1].t - ss + step),
206
+ ]);
207
+ if (!dims || !edges) return null;
208
+ const area = dims.width * dims.height;
209
+ const scale = pxScale > 0 ? pxScale : 1;
210
+ const samples = edges
211
+ .filter((e) => e.YAVG != null)
212
+ .map((e) => ({ t: ss + e.t, px: ((e.YAVG / 255) * area) / scale }));
213
+ let j = 0;
214
+ return run.map((f) => {
215
+ while (
216
+ j + 1 < samples.length &&
217
+ Math.abs(samples[j + 1].t - f.t) <= Math.abs(samples[j].t - f.t)
218
+ ) {
219
+ j++;
220
+ }
221
+ const s = samples[j];
222
+ return !!s && Math.abs(s.t - f.t) <= half && s.px < BLANK_EDGE_PX;
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Length (ms) of the *blank* tail of a video — the trailing run of frames
228
+ * that show nothing (one solid colour and no text or outlines; see
229
+ * `FLAT_SPREAD` / `BLANK_EDGE_PX`).
230
+ *
231
+ * Compose freeze-holds a scene's last frame when the narration outruns the
232
+ * recorded action. When the scene ends right before a `goto`, that last frame
233
+ * is the browser's white pre-paint, and the hold clones *white* for as long as
234
+ * the narration runs (issue #36: 37 near-blank seconds out of 209). Trimming
235
+ * the blank tail first makes the hold clone the last frame that actually shows
236
+ * the app. A sparse-but-real page (a terminal prompt on a dark background) is
237
+ * not blank, so its tail is never dropped (issue #78).
238
+ *
239
+ * `pxScale` is device px per CSS px (output width / `video.width`), so the
240
+ * edge threshold means the same on a 1x and a 2x take. Scans at most
241
+ * `windowMs` from the end; returns 0 when the tail has real content, or when
242
+ * the probe fails for any reason (best-effort polish, never a reason to fail a
243
+ * compose).
244
+ */
245
+ export async function probeFlatTailMs(
246
+ file: string,
247
+ durMs: number,
183
248
  windowMs = 4000,
184
- startMs = 0
249
+ pxScale = 1
185
250
  ): Promise<number> {
186
- return new Promise((resolvePromise) => {
187
- const proc = spawn(
188
- "ffmpeg",
189
- [
190
- "-hide_banner",
191
- "-nostats",
192
- ...(startMs > 0 ? ["-ss", (startMs / 1000).toFixed(3)] : []),
193
- "-i",
194
- file,
195
- "-t",
196
- (windowMs / 1000).toFixed(3),
197
- "-vf",
198
- "signalstats,metadata=print:file=-",
199
- "-f",
200
- "null",
201
- "-",
202
- ],
203
- { stdio: ["ignore", "pipe", "ignore"] }
204
- );
205
- let out = "";
206
- proc.stdout.on("data", (d) => (out += d.toString()));
207
- proc.on("error", () => resolvePromise(0));
208
- proc.on("close", () => {
209
- const frames: Array<{ t: number; spread: number }> = [];
210
- let t = NaN;
211
- let low = NaN;
212
- let high = NaN;
213
- const flush = (): void => {
214
- if (!Number.isNaN(t) && !Number.isNaN(low) && !Number.isNaN(high)) {
215
- frames.push({ t, spread: high - low });
216
- }
217
- low = NaN;
218
- high = NaN;
219
- };
220
- for (const line of out.split("\n")) {
221
- const ts = /pts_time:\s*([\d.]+)/.exec(line);
222
- if (ts) {
223
- flush();
224
- t = parseFloat(ts[1]);
225
- continue;
226
- }
227
- const lo = /YLOW=\s*([\d.]+)/.exec(line);
228
- if (lo) low = parseFloat(lo[1]);
229
- const hi = /YHIGH=\s*([\d.]+)/.exec(line);
230
- if (hi) high = parseFloat(hi[1]);
231
- }
232
- flush();
233
- const FLAT_SPREAD = 4;
234
- if (!frames.length || frames[0].spread > FLAT_SPREAD) return resolvePromise(0);
235
- let i = 0;
236
- while (i + 1 < frames.length && frames[i + 1].spread <= FLAT_SPREAD) i++;
237
- resolvePromise(Math.max(0, Math.round(frames[i].t * 1000)));
238
- });
239
- });
251
+ const start = Math.max(0, durMs - windowMs) / 1000;
252
+ const frames = await flatFrames(file, start, null);
253
+ if (!frames || !frames.length || !frames[frames.length - 1].flat) return 0;
254
+ let i = frames.length - 1;
255
+ while (i > 0 && frames[i - 1].flat) i--;
256
+ const run = frames.slice(i);
257
+ // A static page (a terminal) is one long flat run: settle it on the last
258
+ // frame alone before paying for the whole run.
259
+ if (!(await blankInRun(file, run.slice(-1), pxScale))?.[0]) return 0;
260
+ const blank = await blankInRun(file, run, pxScale);
261
+ if (!blank || !blank[blank.length - 1]) return 0;
262
+ let k = blank.length - 1;
263
+ while (k > 0 && blank[k - 1]) k--;
264
+ return Math.max(0, Math.round(durMs - run[k].t * 1000));
265
+ }
266
+
267
+ /**
268
+ * Mirror of `probeFlatTailMs` for the HEAD of a file: how many ms after
269
+ * `startMs` are blank frames (the browser's white pre-paint after a `goto`, a
270
+ * solid splash). Used to open the video on the product instead of on a blank
271
+ * page, and to pick a poster frame that isn't a white rectangle (issue #47). A
272
+ * static but populated opening — a dark terminal with a prompt — is content,
273
+ * not blank (issue #78). Best-effort: any probe failure resolves to 0.
274
+ */
275
+ export async function probeFlatLeadMs(
276
+ file: string,
277
+ windowMs = 4000,
278
+ startMs = 0,
279
+ pxScale = 1
280
+ ): Promise<number> {
281
+ const start = startMs / 1000;
282
+ const frames = await flatFrames(file, start, windowMs / 1000);
283
+ if (!frames || !frames.length || !frames[0].flat) return 0;
284
+ let i = 0;
285
+ while (i + 1 < frames.length && frames[i + 1].flat) i++;
286
+ const run = frames.slice(0, i + 1);
287
+ if (!(await blankInRun(file, run.slice(0, 1), pxScale))?.[0]) return 0;
288
+ const blank = await blankInRun(file, run, pxScale);
289
+ if (!blank || !blank[0]) return 0;
290
+ let k = 0;
291
+ while (k + 1 < blank.length && blank[k + 1]) k++;
292
+ return Math.max(0, Math.round((run[k].t - start) * 1000));
240
293
  }
241
294
 
242
295
  /** A motionless span found by `freezedetect`, in ms from the file's start. */
package/src/frame.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * overlays that PNG. Two baseline filters (`pad`, `overlay`), no drawbox.
8
8
  */
9
9
 
10
- import { chromium } from "playwright";
10
+ import { launchChrome } from "./browser.js";
11
11
  import type { Brand, Frame } from "./types.js";
12
12
 
13
13
  export interface FrameLayout {
@@ -189,7 +189,7 @@ export async function renderFramePng(
189
189
  <div class="canvas">${device ? '<div class="bezel"></div>' : ""}<div class="win"></div>${chromeHtml}</div>${deviceHtml}
190
190
  </body></html>`;
191
191
 
192
- const browser = await chromium.launch({ channel: "chrome", headless: true });
192
+ const browser = await launchChrome({ headless: true });
193
193
  const context = await browser.newContext({
194
194
  viewport: { width: W, height: H },
195
195
  deviceScaleFactor: pxScale,
package/src/inspect.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  * fix a selector from the failure artifact instead of guessing.
11
11
  */
12
12
 
13
- import { chromium, type Frame, type Page } from "playwright";
13
+ import type { Frame, Page } from "playwright";
14
+ import { launchChromePersistent } from "./browser.js";
14
15
  import { chromeProfileDir } from "./config.js";
15
16
  import { ensureProfileUnlocked } from "./login.js";
16
17
  import { applySeeds, type Seeds } from "./setup.js";
@@ -219,8 +220,7 @@ export async function inspectPage(opts: InspectOptions): Promise<InspectResult>
219
220
  const profileDir = opts.profileDir ?? chromeProfileDir();
220
221
  await ensureProfileUnlocked(profileDir);
221
222
  const viewport = opts.viewport ?? { width: 1280, height: 720 };
222
- const context = await chromium.launchPersistentContext(profileDir, {
223
- channel: "chrome",
223
+ const context = await launchChromePersistent(profileDir, {
224
224
  headless: opts.headless !== false,
225
225
  args: ["--disable-blink-features=AutomationControlled"],
226
226
  viewport,
package/src/login.ts CHANGED
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { chromeProfileDir } from "./config.js";
5
+ import { manualLaunchBinary } from "./browser.js";
5
6
  import { ensureDir, log, ok, step, sleep } from "./util.js";
6
7
 
7
8
  /**
@@ -98,13 +99,16 @@ export async function login(profileDir?: string, opts: LoginOptions = {}): Promi
98
99
  "--no-default-browser-check",
99
100
  url,
100
101
  ];
101
- if (process.platform === "darwin") {
102
+ // AIDEMO_CHROME_EXECUTABLE / AIDEMO_CHROME_CHANNEL=chromium: log in with
103
+ // the SAME browser the recorder will launch (issue #80).
104
+ const configured = manualLaunchBinary();
105
+ if (process.platform === "darwin" && !configured) {
102
106
  spawn("open", ["-na", "Google Chrome", "--args", ...args], {
103
107
  stdio: "ignore",
104
108
  detached: true,
105
109
  }).unref();
106
110
  } else {
107
- const bin = process.env.CHROME_BIN || "google-chrome";
111
+ const bin = configured || process.env.CHROME_BIN || "google-chrome";
108
112
  const child = spawn(bin, args, { stdio: "ignore", detached: true });
109
113
  child.on("error", () => {
110
114
  log(`could not launch "${bin}" (set CHROME_BIN) — open Chrome yourself with:`);
package/src/mcp/server.ts CHANGED
@@ -4,7 +4,13 @@ import { z } from "zod";
4
4
  import { zodToJsonSchema } from "zod-to-json-schema";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { resolve } from "node:path";
7
- import { ENGINE_ROOT, engineVersion, captionsAutoOffline, chromeProfileDir } from "../config.js";
7
+ import {
8
+ ENGINE_ROOT,
9
+ engineVersion,
10
+ captionsAutoOffline,
11
+ chromeProfileDir,
12
+ applyProjectEnv,
13
+ } from "../config.js";
8
14
  import { Project, parseStoryboard } from "../project.js";
9
15
  import { StoryboardSchema, SeedCookieSchema, type SeedCookie, type Storyboard } from "../types.js";
10
16
  import { generateVoice } from "../voice.js";
@@ -498,6 +504,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
498
504
  },
499
505
  },
500
506
  async (args) => {
507
+ // fromUrl launches Chrome: honour the target repo's .env (#77).
508
+ if (args.dir && !jobs.running()) await applyProjectEnv(args.dir);
501
509
  const demoDir = await scaffoldDemo(
502
510
  args.dir ? resolve(args.dir) : process.cwd(),
503
511
  args.name,
@@ -523,7 +531,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
523
531
  {
524
532
  title: "Environment preflight",
525
533
  description:
526
- "Check prereqs: node, ffmpeg, Chrome, TTS/STT endpoint (flags " +
534
+ "Check prereqs: node, ffmpeg, Chrome (or the AIDEMO_CHROME_CHANNEL / " +
535
+ "AIDEMO_CHROME_EXECUTABLE browser in use), TTS/STT endpoint (flags " +
527
536
  "LLM-only servers like Ollama), API key, playwright, installed skill.",
528
537
  inputSchema: {
529
538
  dir: z.string().optional().describe("repo to check for an installed skill"),
@@ -533,6 +542,15 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
533
542
  node: z.string(),
534
543
  ffmpeg: z.string().nullable(),
535
544
  chrome: z.string().nullable(),
545
+ browser: z.object({
546
+ source: z.enum(["default", "channel", "executable"]),
547
+ channel: z.string().optional(),
548
+ executablePath: z.string().optional(),
549
+ description: z.string(),
550
+ found: z.boolean(),
551
+ version: z.string().optional(),
552
+ error: z.string().optional(),
553
+ }),
536
554
  gh: z.string().nullable(),
537
555
  endpoint: z.object({
538
556
  url: z.string(),
@@ -558,10 +576,13 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
558
576
  },
559
577
  annotations: { readOnlyHint: true },
560
578
  },
561
- async (args) =>
562
- jsonResult(
579
+ async (args) => {
580
+ // Report the env the pipeline would actually run with for this repo (#77).
581
+ if (args.dir && !jobs.running()) await applyProjectEnv(args.dir);
582
+ return jsonResult(
563
583
  (await doctorReport(args.dir)) as unknown as Record<string, unknown>
564
- )
584
+ );
585
+ }
565
586
  );
566
587
 
567
588
  server.registerTool(
@@ -788,6 +809,9 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
788
809
  (async (args: z.infer<z.ZodObject<Shape>>) => {
789
810
  const project = new Project((args as { dir: string }).dir);
790
811
  await project.ensureDirs();
812
+ // The demo's own .env, as the CLI run from its repo would see it
813
+ // (#77). Never swapped under a running job — startJob rejects anyway.
814
+ if (!jobs.running()) await applyProjectEnv(project.dir);
791
815
  try {
792
816
  const job = jobs.startJob(kind, project, makeRunner(project, args));
793
817
  return jsonResult({
package/src/qa.ts CHANGED
@@ -14,10 +14,12 @@
14
14
 
15
15
  import { spawn } from "node:child_process";
16
16
  import { relative } from "node:path";
17
+ import { stat } from "node:fs/promises";
17
18
  import type { Project } from "./project.js";
18
19
  import {
19
20
  probeDurationMs,
20
21
  probeFlatLeadMs,
22
+ probeVideoDims,
21
23
  } from "./ffmpeg.js";
22
24
  import { ComposeReportSchema, type Storyboard } from "./types.js";
23
25
  import { exists, log, ok, readJson } from "./util.js";
@@ -105,6 +107,24 @@ function imageYavg(file: string): Promise<number | null> {
105
107
  });
106
108
  }
107
109
 
110
+ /**
111
+ * `blankOpenMs` from the compose report that belongs to `video` (written at or
112
+ * after it), or null when there is no such report.
113
+ */
114
+ async function reportedBlankOpenMs(project: Project, video: string): Promise<number | null> {
115
+ if (!(await exists(project.reportPath))) return null;
116
+ try {
117
+ const [rs, vs] = await Promise.all([stat(project.reportPath), stat(video)]);
118
+ if (rs.mtimeMs < vs.mtimeMs) return null;
119
+ const report = ComposeReportSchema.safeParse(await readJson(project.reportPath));
120
+ return report.success && typeof report.data.blankOpenMs === "number"
121
+ ? report.data.blankOpenMs
122
+ : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
108
128
  /** Host of a URL-ish string, or null. */
109
129
  function hostOf(url: string): string | null {
110
130
  try {
@@ -137,8 +157,17 @@ export async function runQa(project: Project, storyboard: Storyboard): Promise<Q
137
157
  }
138
158
 
139
159
  // 2. The opening frames: a demo that starts on the browser's white pre-paint.
160
+ // Prefer compose's own measurement (report.json `blankOpenMs`): it is taken
161
+ // on the page footage before captions are burned in, and the finished MP4
162
+ // can't tell a caption pill over a blank page from sparse real content
163
+ // (issue #78). Fall back to probing the MP4 when there's no report for it.
140
164
  const introMs = storyboard.intro?.durationMs ?? 0;
141
- const blankMs = await probeFlatLeadMs(video, 4000, introMs).catch(() => 0);
165
+ let blankMs = await reportedBlankOpenMs(project, video);
166
+ if (blankMs == null) {
167
+ const dims = await probeVideoDims(video).catch(() => null);
168
+ const pxScale = dims ? dims.width / storyboard.video.width : 1;
169
+ blankMs = await probeFlatLeadMs(video, 4000, introMs, pxScale).catch(() => 0);
170
+ }
142
171
  if (blankMs > 250) {
143
172
  add(
144
173
  "blank-open",
package/src/recorder.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { chromium } from "playwright";
1
+ import { describeChromeTarget, launchChromePersistent } from "./browser.js";
2
2
  import { promises as fs } from "node:fs";
3
3
  import {
4
4
  TimelineSchema,
@@ -18,6 +18,8 @@ import {
18
18
  captureDevice,
19
19
  obsUrl,
20
20
  obsPassword,
21
+ BrowserSetupError,
22
+ chromeLaunchOptions,
21
23
  } from "./config.js";
22
24
  import {
23
25
  type CaptureMode,
@@ -169,6 +171,11 @@ export async function record(
169
171
  // error); clean up a stale lock from a crashed Chrome.
170
172
  await ensureProfileUnlocked(profileDir);
171
173
  log(`profile: ${profileDir}${options.fresh ? " (fresh)" : ""}`);
174
+ // Non-default browser (issue #80): resolve it now — a bad
175
+ // AIDEMO_CHROME_CHANNEL fails here, before any preflight hook runs.
176
+ if (process.env.AIDEMO_CHROME_EXECUTABLE?.trim() || process.env.AIDEMO_CHROME_CHANNEL?.trim()) {
177
+ log(`browser: ${describeChromeTarget(chromeLaunchOptions())}`);
178
+ }
172
179
 
173
180
  // Preflight hook (issue #44): runs before Chrome launches, with the resolved
174
181
  // profile in its env. It may rewrite the storyboard (a rotating fixture), so
@@ -311,8 +318,7 @@ export async function record(
311
318
 
312
319
  let context;
313
320
  try {
314
- context = await chromium.launchPersistentContext(profileDir, {
315
- channel: "chrome",
321
+ context = await launchChromePersistent(profileDir, {
316
322
  headless: options.headed === false,
317
323
  // Hide the automation fingerprint (navigator.webdriver=false) so
318
324
  // Cloudflare on chatgpt.com doesn't throw a "Verify you are human"
@@ -345,6 +351,8 @@ export async function record(
345
351
  }),
346
352
  });
347
353
  } catch (err) {
354
+ // Wrong/missing browser (issue #80) is already actionable on its own.
355
+ if (err instanceof BrowserSetupError) throw err;
348
356
  throw new Error(
349
357
  `Failed to launch Chrome with profile ${profileDir}.\n` +
350
358
  `If Chrome is already open on this profile, quit it first (Playwright ` +
package/src/types.ts CHANGED
@@ -842,8 +842,10 @@ export const OutputSchema = z
842
842
  /**
843
843
  * Drop the browser's blank pre-paint at the very start of the video, so
844
844
  * the demo opens on the product instead of on ~0.5 s of white under the
845
- * first caption (issue #47). At most `blankOpenCapMs` of the leading flat
846
- * span is kept. Default false (unchanged opening).
845
+ * first caption (issue #47). At most `blankOpenCapMs` of the leading blank
846
+ * span is kept. Blank = empty (one colour, no text/outlines), not merely
847
+ * static: a motionless terminal or page is never trimmed (issue #78).
848
+ * Default false (unchanged opening).
847
849
  */
848
850
  trimLeadingBlank: z.boolean().optional(),
849
851
  /** With `trimLeadingBlank`: leading blank kept, ms. Default 100. */