@tandryukha/aidemo 0.15.2 → 0.16.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 +22 -1
- package/docs/AUTHORING.md +21 -6
- package/package.json +4 -2
- package/src/attention.ts +5 -4
- package/src/browser.ts +108 -0
- package/src/caption-render.ts +2 -2
- package/src/cards.ts +2 -2
- package/src/compose.ts +13 -6
- package/src/config.ts +120 -1
- package/src/cursor.ts +2 -2
- package/src/distribute.ts +76 -4
- package/src/ffmpeg.ts +167 -114
- package/src/frame.ts +2 -2
- package/src/inspect.ts +3 -3
- package/src/login.ts +6 -2
- package/src/mcp/server.ts +29 -5
- package/src/qa.ts +30 -1
- package/src/recorder.ts +11 -3
- package/src/types.ts +4 -2
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
|
[](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
|
|
623
|
-
|
|
624
|
-
always *measures* the opening either way
|
|
625
|
-
|
|
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
|
-
-
|
|
1221
|
-
|
|
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.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -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.
|
|
38
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/caption-render.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
1098
|
-
// at a fixed offset, which on a demo
|
|
1099
|
-
// white load frame; pick the first
|
|
1100
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
87
|
-
*
|
|
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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* the
|
|
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
|
-
*
|
|
97
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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(
|
|
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<
|
|
139
|
-
let
|
|
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
|
-
|
|
153
|
-
|
|
157
|
+
cur = { t: parseFloat(ts[1]) };
|
|
158
|
+
frames.push(cur);
|
|
154
159
|
continue;
|
|
155
160
|
}
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
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
|
-
|
|
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
|
-
*
|
|
176
|
-
* are
|
|
177
|
-
*
|
|
178
|
-
* a
|
|
179
|
-
* probe
|
|
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
|
-
|
|
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
|
-
|
|
249
|
+
pxScale = 1
|
|
185
250
|
): Promise<number> {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
846
|
-
* span is kept.
|
|
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. */
|