@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/README.md +22 -1
- package/docs/AUTHORING.md +37 -7
- package/package.json +5 -2
- package/src/attention.ts +5 -4
- package/src/browser.ts +108 -0
- package/src/caption-render.ts +2 -2
- package/src/captions.ts +80 -2
- package/src/cards.ts +2 -2
- package/src/cli.ts +6 -1
- package/src/compose.ts +13 -6
- package/src/config.ts +120 -1
- package/src/cursor.ts +2 -2
- package/src/distribute.ts +79 -4
- package/src/ffmpeg.ts +167 -114
- package/src/frame.ts +2 -2
- package/src/inspect.ts +25 -4
- package/src/login.ts +6 -2
- package/src/mcp/server.ts +32 -6
- package/src/qa.ts +30 -1
- package/src/recorder.ts +11 -3
- package/src/safe-regex.ts +7 -2
- package/src/types.ts +4 -2
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";
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
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";
|
|
@@ -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
|
|
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
|
-
|
|
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:`);
|