@tandryukha/aidemo 0.15.1 → 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/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";
@@ -266,6 +274,9 @@ export async function scaffoldDemo(
266
274
  screenshotPath: project.p("logs", "inspect-init.png"),
267
275
  });
268
276
  await fs.writeFile(project.p("logs", "inspect-init.json"), JSON.stringify(res, null, 2));
277
+ if (res.target.status === "blocked") {
278
+ throw new Error(`Cannot draft from ${opts.fromUrl}: ${res.target.reason}. See logs/inspect-init.json and inspect-init.png.`);
279
+ }
269
280
  await fs.writeFile(project.p("input", "brief.md"), briefFromInspect(name, res));
270
281
  await fs.writeFile(project.storyboardPath, storyboardFromInspect(name, res));
271
282
  ok(
@@ -368,6 +379,43 @@ async function probeChrome(): Promise<string | null> {
368
379
  return null;
369
380
  }
370
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
+
371
419
  /** Tail the most recent logs/*.log under a demo dir (best-effort). */
372
420
  async function recentLogTail(demoDir: string, lines = 40): Promise<string | null> {
373
421
  const logsDir = resolve(demoDir, "logs");
@@ -576,7 +624,24 @@ export interface DoctorReport {
576
624
  engineVersion: string;
577
625
  node: string;
578
626
  ffmpeg: string | null;
627
+ /** System Google Chrome (version or path) — what the default channel launches. */
579
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
+ };
580
645
  gh: string | null;
581
646
  endpoint: { url: string; custom: boolean; warning?: string };
582
647
  apiKey: "set" | "not-required" | "missing";
@@ -627,11 +692,14 @@ export async function doctorReport(dir: string | undefined): Promise<DoctorRepor
627
692
  const manifest = await readManifest(target);
628
693
  const latest = engineVersion();
629
694
  const localTts = ttsProvider() === "local" ? await localTtsStatus() : null;
695
+ const chrome = await probeChrome();
696
+ const browser = await probeBrowserTarget(chrome);
630
697
  const report: DoctorReport = {
631
698
  engineVersion: latest,
632
699
  node: process.version,
633
700
  ffmpeg: await probeCmd("ffmpeg", ["-version"]),
634
- chrome: await probeChrome(),
701
+ chrome,
702
+ browser,
635
703
  gh: await probeCmd("gh", ["--version"]),
636
704
  endpoint: {
637
705
  url: base ?? "https://api.openai.com/v1",
@@ -671,7 +739,7 @@ export async function doctorReport(dir: string | undefined): Promise<DoctorRepor
671
739
  };
672
740
  report.ok = Boolean(
673
741
  report.ffmpeg &&
674
- report.chrome &&
742
+ report.browser.found &&
675
743
  report.playwright &&
676
744
  // Local TTS needs no key: voice is in-process, captions auto-fall-back
677
745
  // to the offline derivation in `render`.
@@ -692,7 +760,14 @@ export async function doctor(dir: string | undefined): Promise<void> {
692
760
 
693
761
  line("node", r.node);
694
762
  line("ffmpeg", r.ffmpeg);
695
- 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
+ }
696
771
  line("gh (feedback)", r.gh);
697
772
  ok(
698
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";
@@ -36,6 +37,8 @@ export interface InspectElement {
36
37
  }
37
38
 
38
39
  export interface InspectResult {
40
+ /** Whether the requested product UI was actually available to inspect. */
41
+ target: { status: "available" | "blocked"; reason?: string };
39
42
  url: string;
40
43
  finalUrl: string;
41
44
  title: string;
@@ -174,11 +177,12 @@ const SCAN_JS = String.raw`(() => {
174
177
  src: (f.getAttribute("src") || "").slice(0, 200),
175
178
  selector: f.id && stableId(f.id) ? "#" + cssEsc(f.id) : (f.getAttribute("name") ? 'iframe[name="' + q(f.getAttribute("name")) + '"]' : "iframe:nth-of-type(" + (i + 1) + ")"),
176
179
  }));
177
- return { title: document.title, elements: items, headings, iframes, truncated: all.length > LIMIT && items.length >= LIMIT, total: all.length };
180
+ return { title: document.title, bodyText: (document.body?.innerText || "").slice(0, 1200), elements: items, headings, iframes, truncated: all.length > LIMIT && items.length >= LIMIT, total: all.length };
178
181
  })()`;
179
182
 
180
183
  interface ScanResult {
181
184
  title: string;
185
+ bodyText: string;
182
186
  elements: InspectElement[];
183
187
  headings: Array<{ level: number; text: string }>;
184
188
  iframes: Array<{ name: string; src: string; selector: string }>;
@@ -186,6 +190,23 @@ interface ScanResult {
186
190
  total: number;
187
191
  }
188
192
 
193
+ /** Conservative signal: report a challenge without guessing how to solve it. */
194
+ export function inspectTargetStatus(scan: Pick<ScanResult, "title" | "headings" | "bodyText">): InspectResult["target"] {
195
+ const title = scan.title.trim().toLowerCase();
196
+ const headings = scan.headings.map((h) => h.text.toLowerCase()).join(" ");
197
+ const body = scan.bodyText.toLowerCase();
198
+ if (/^(just a moment|attention required|security verification|checking your browser)/i.test(title) ||
199
+ /performing security verification|verify (you are|that you are) human|checking your browser/i.test(headings) ||
200
+ (/security verification|verify you are human/i.test(body) && /challenge|cloudflare|captcha/i.test(body))) {
201
+ return { status: "blocked", reason: "security verification page; the requested product UI was not inspected" };
202
+ }
203
+ if (/^(sign in|log in|login|authentication required)$/i.test(scan.title.trim()) &&
204
+ /sign in|log in|email|password/i.test(body)) {
205
+ return { status: "blocked", reason: "login page; the requested product UI was not inspected" };
206
+ }
207
+ return { status: "available" };
208
+ }
209
+
189
210
  /** Scan one frame for visible interactive elements (see SCAN_JS). */
190
211
  export async function scanInteractive(
191
212
  target: Page | Frame,
@@ -199,8 +220,7 @@ export async function inspectPage(opts: InspectOptions): Promise<InspectResult>
199
220
  const profileDir = opts.profileDir ?? chromeProfileDir();
200
221
  await ensureProfileUnlocked(profileDir);
201
222
  const viewport = opts.viewport ?? { width: 1280, height: 720 };
202
- const context = await chromium.launchPersistentContext(profileDir, {
203
- channel: "chrome",
223
+ const context = await launchChromePersistent(profileDir, {
204
224
  headless: opts.headless !== false,
205
225
  args: ["--disable-blink-features=AutomationControlled"],
206
226
  viewport,
@@ -233,6 +253,7 @@ export async function inspectPage(opts: InspectOptions): Promise<InspectResult>
233
253
  screenshot = opts.screenshotPath;
234
254
  }
235
255
  return {
256
+ target: inspectTargetStatus(main),
236
257
  url: opts.url,
237
258
  finalUrl: page.url(),
238
259
  title: main.title,
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:`);