@tandryukha/aidemo 0.8.0 → 0.15.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/.claude/skills/record-demo/SKILL.md +5 -2
- package/README.md +16 -4
- package/docs/AUTHORING.md +734 -48
- package/package.json +3 -2
- package/src/anchors.ts +128 -0
- package/src/attention.ts +365 -0
- package/src/caption-render.ts +17 -8
- package/src/captions.ts +244 -36
- package/src/capture.ts +113 -0
- package/src/cards.ts +15 -6
- package/src/cli.ts +711 -26
- package/src/compose.ts +1318 -101
- package/src/cursor-overlay.ts +7 -113
- package/src/cursor.ts +19 -4
- package/src/distribute.ts +37 -4
- package/src/expr.ts +130 -0
- package/src/ffmpeg.ts +224 -0
- package/src/frame.ts +205 -0
- package/src/frames.ts +208 -0
- package/src/golden.ts +27 -0
- package/src/guide.ts +102 -0
- package/src/i18n.ts +7 -4
- package/src/import-trace.ts +640 -0
- package/src/inspect.ts +316 -0
- package/src/lint.ts +673 -0
- package/src/login.ts +143 -0
- package/src/mcp/jobs.ts +5 -1
- package/src/mcp/server.ts +468 -27
- package/src/player.ts +1286 -41
- package/src/profile.ts +128 -0
- package/src/project.ts +12 -2
- package/src/qa.ts +295 -0
- package/src/recorder.ts +316 -21
- package/src/safe-regex.ts +36 -0
- package/src/setup.ts +248 -0
- package/src/starter.ts +244 -0
- package/src/stills.ts +15 -4
- package/src/types.ts +796 -22
- package/src/variants.ts +1 -1
- package/src/voice-local.ts +7 -1
- package/src/voice.ts +60 -1
- package/src/walkthrough.ts +250 -0
- package/src/zoom.ts +85 -39
package/src/recorder.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { chromium } from "playwright";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import {
|
|
4
|
+
TimelineSchema,
|
|
5
|
+
type Scene,
|
|
6
|
+
type Storyboard,
|
|
7
|
+
type Timeline,
|
|
8
|
+
type TimelineScene,
|
|
9
|
+
type ProbeGoldenScene,
|
|
10
|
+
type SeedCookie,
|
|
8
11
|
} from "./types.js";
|
|
9
12
|
import { Project } from "./project.js";
|
|
10
13
|
import { cursorInitScript } from "./cursor.js";
|
|
14
|
+
import { hideCss } from "./player.js";
|
|
11
15
|
import { runStoryboard } from "./player.js";
|
|
12
16
|
import {
|
|
13
|
-
chromeProfileDir,
|
|
14
17
|
defaultCaptureMode,
|
|
15
18
|
captureDevice,
|
|
16
19
|
obsUrl,
|
|
@@ -24,16 +27,86 @@ import {
|
|
|
24
27
|
ObsCapture,
|
|
25
28
|
measureViewportGeometry,
|
|
26
29
|
cropCaptureToViewport,
|
|
30
|
+
verifyCaptureMatchesPage,
|
|
27
31
|
} from "./capture.js";
|
|
28
|
-
import {
|
|
32
|
+
import { ensureProfileUnlocked } from "./login.js";
|
|
33
|
+
import { resolveProfile } from "./profile.js";
|
|
34
|
+
import { applySeeds, collectSeeds, runPreflight } from "./setup.js";
|
|
35
|
+
import { ensureDir, exists, readJson, writeJson, log, ok, step } from "./util.js";
|
|
29
36
|
import { probeDurationMs } from "./ffmpeg.js";
|
|
30
|
-
import { dirname, join } from "node:path";
|
|
37
|
+
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
38
|
+
import { createHash } from "node:crypto";
|
|
31
39
|
|
|
32
40
|
const FPS = 30;
|
|
33
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Identity of a scene's take: everything that changes what the camera sees
|
|
44
|
+
* for that scene. `record --from-scene` reuses a previous take's scene only
|
|
45
|
+
* when this still matches, so a resumed demo never splices stale footage.
|
|
46
|
+
*/
|
|
47
|
+
export function sceneHash(storyboard: Storyboard, scene: Scene): string {
|
|
48
|
+
return hashIdent({
|
|
49
|
+
...legacyIdent(storyboard, scene),
|
|
50
|
+
// A param change that alters typed text already shows up in `actions`
|
|
51
|
+
// (params resolve at load time), but one that only moves `setup` — a
|
|
52
|
+
// different cookie, a different storageState, another preflight — used to
|
|
53
|
+
// slip through and splice footage of a differently-prepared app.
|
|
54
|
+
setup: storyboard.setup ?? null,
|
|
55
|
+
params: storyboard.params ?? null,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The pre-`setup`/`params` identity — only to recognize takes recorded before it. */
|
|
60
|
+
export function legacySceneHash(storyboard: Storyboard, scene: Scene): string {
|
|
61
|
+
return hashIdent(legacyIdent(storyboard, scene));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function legacyIdent(storyboard: Storyboard, scene: Scene): Record<string, unknown> {
|
|
65
|
+
return {
|
|
66
|
+
id: scene.id,
|
|
67
|
+
actions: scene.actions,
|
|
68
|
+
hide: [...(storyboard.hide ?? []), ...(scene.hide ?? [])],
|
|
69
|
+
redact: [...(storyboard.redact ?? []), ...(scene.redact ?? [])],
|
|
70
|
+
video: storyboard.video,
|
|
71
|
+
cursor: !!storyboard.cursor,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hashIdent(ident: unknown): string {
|
|
76
|
+
return createHash("sha256").update(JSON.stringify(ident)).digest("hex").slice(0, 16);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `recordings/raw.keep-<stamp>.<ext>` — footage a resumed take still references. */
|
|
80
|
+
const KEEP_RE = /^raw\.keep-.*\.(webm|mp4)$/;
|
|
81
|
+
|
|
34
82
|
export interface RecordOptions {
|
|
35
83
|
/** Chrome user-data dir (logged-in profile). Defaults to config profile. */
|
|
36
84
|
profileDir?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Run against a WIPED throwaway profile (`<demo>/.chrome-profile-fresh`)
|
|
87
|
+
* instead of the shared one. Use for any storyboard that shows a first-run
|
|
88
|
+
* gate, onboarding, an empty state or a one-shot flow — carried-over state
|
|
89
|
+
* silently records the wrong story (see src/profile.ts).
|
|
90
|
+
*/
|
|
91
|
+
fresh?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Playwright storageState JSON to seed (cookies + per-origin localStorage)
|
|
94
|
+
* before the first action — on top of the storyboard's `setup.storageState`.
|
|
95
|
+
*/
|
|
96
|
+
storageState?: string;
|
|
97
|
+
/** Cookies to seed before the first action (on top of `setup.cookies`). */
|
|
98
|
+
cookies?: SeedCookie[];
|
|
99
|
+
/**
|
|
100
|
+
* The profile is seeded on purpose (a login, a cookie gate): silence the
|
|
101
|
+
* carried-over-state warning. Implied by seeds and by `setup.expectState`.
|
|
102
|
+
*/
|
|
103
|
+
profileSeeded?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Re-read the storyboard after a `setup.preflight` hook ran — the hook may
|
|
106
|
+
* have patched it (e.g. a selector for today's fixture). Call sites pass the
|
|
107
|
+
* same loader they used, so params/relaxed semantics are preserved.
|
|
108
|
+
*/
|
|
109
|
+
reloadStoryboard?: () => Promise<Storyboard>;
|
|
37
110
|
/** Show the browser window. Default true (channel chrome needs a display). */
|
|
38
111
|
headed?: boolean;
|
|
39
112
|
/**
|
|
@@ -53,6 +126,14 @@ export interface RecordOptions {
|
|
|
53
126
|
* abort on an action failure — for `aidemo probe --golden/--update-golden`.
|
|
54
127
|
*/
|
|
55
128
|
probe?: ProbeGoldenScene[];
|
|
129
|
+
/**
|
|
130
|
+
* Resume from this scene id: the previous take's scenes before it are kept
|
|
131
|
+
* (footage + timeline, verified unchanged by hash) and only replayed at speed
|
|
132
|
+
* to rebuild app state; recording starts at this scene.
|
|
133
|
+
*/
|
|
134
|
+
fromScene?: string;
|
|
135
|
+
/** With `fromScene`: skip the reused scenes' actions instead of replaying them (state-earning flows). */
|
|
136
|
+
noReplay?: boolean;
|
|
56
137
|
}
|
|
57
138
|
|
|
58
139
|
/**
|
|
@@ -62,9 +143,10 @@ export interface RecordOptions {
|
|
|
62
143
|
*/
|
|
63
144
|
export async function record(
|
|
64
145
|
project: Project,
|
|
65
|
-
|
|
146
|
+
storyboardIn: Storyboard,
|
|
66
147
|
options: RecordOptions = {}
|
|
67
148
|
): Promise<Timeline> {
|
|
149
|
+
let storyboard = storyboardIn;
|
|
68
150
|
step(`Recording "${storyboard.title}"`);
|
|
69
151
|
await project.ensureDirs();
|
|
70
152
|
await ensureDir(dirname(project.rawVideoPath));
|
|
@@ -77,13 +159,127 @@ export async function record(
|
|
|
77
159
|
);
|
|
78
160
|
}
|
|
79
161
|
|
|
80
|
-
const profileDir =
|
|
162
|
+
const { dir: profileDir, warning: profileWarning } = await resolveProfile(
|
|
163
|
+
project.dir,
|
|
164
|
+
storyboard,
|
|
165
|
+
{ profileDir: options.profileDir, fresh: options.fresh }
|
|
166
|
+
);
|
|
167
|
+
// Fail fast with the actual fix when Chrome is still running on the profile
|
|
168
|
+
// (a live lock stalls Playwright and then dies with a raw ProcessSingleton
|
|
169
|
+
// error); clean up a stale lock from a crashed Chrome.
|
|
170
|
+
await ensureProfileUnlocked(profileDir);
|
|
171
|
+
log(`profile: ${profileDir}${options.fresh ? " (fresh)" : ""}`);
|
|
172
|
+
|
|
173
|
+
// Preflight hook (issue #44): runs before Chrome launches, with the resolved
|
|
174
|
+
// profile in its env. It may rewrite the storyboard (a rotating fixture), so
|
|
175
|
+
// re-read it afterwards when the caller gave us a loader.
|
|
176
|
+
if (storyboard.setup?.preflight) {
|
|
177
|
+
await runPreflight(storyboard.setup.preflight, {
|
|
178
|
+
demoDir: project.dir,
|
|
179
|
+
storyboardPath: project.storyboardPath,
|
|
180
|
+
profileDir,
|
|
181
|
+
});
|
|
182
|
+
if (options.reloadStoryboard) storyboard = await options.reloadStoryboard();
|
|
183
|
+
}
|
|
184
|
+
const seeds = await collectSeeds(project.dir, storyboard, {
|
|
185
|
+
storageState: options.storageState,
|
|
186
|
+
cookies: options.cookies,
|
|
187
|
+
});
|
|
188
|
+
// A deliberately seeded profile is the point, not the trap the warning
|
|
189
|
+
// names — acknowledge it via --profile-seeded / setup.expectState / seeds.
|
|
190
|
+
const seededOnPurpose =
|
|
191
|
+
!!options.profileSeeded || !!storyboard.setup?.expectState || !!seeds;
|
|
192
|
+
if (profileWarning && !seededOnPurpose) log(` ! ${profileWarning}`);
|
|
193
|
+
|
|
81
194
|
const { width, height } = storyboard.video;
|
|
195
|
+
// Raw-video density (issue #51). The page always lays out at width x height
|
|
196
|
+
// CSS px; `deviceScaleFactor` only decides how many real pixels each of them
|
|
197
|
+
// gets in the recording. Chrome itself has always rendered at 2x for
|
|
198
|
+
// crispness, so 1 (the default) keeps the old, unchanged output size.
|
|
199
|
+
const videoScale = storyboard.video.deviceScaleFactor ?? 1;
|
|
200
|
+
const dsf = Math.max(2, videoScale);
|
|
201
|
+
if (videoScale > 1) {
|
|
202
|
+
log(
|
|
203
|
+
`video: ${width}x${height} CSS px recorded at ${width * videoScale}x${height * videoScale} ` +
|
|
204
|
+
`(deviceScaleFactor ${videoScale})`
|
|
205
|
+
);
|
|
206
|
+
if (external) {
|
|
207
|
+
log(
|
|
208
|
+
` ! --capture ${mode} records real screen pixels — video.deviceScaleFactor is ignored`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
82
212
|
const videoDir = dirname(project.rawVideoPath);
|
|
83
|
-
log(`profile: ${profileDir}`);
|
|
84
213
|
log(`viewport: ${width}x${height}`);
|
|
85
214
|
if (external) log(`capture mode: ${mode}`);
|
|
86
215
|
|
|
216
|
+
// Resume: keep the previous take's leading scenes. Their raw file moves to a
|
|
217
|
+
// `raw.keep-*` name that the reused timeline scenes point at, the take's
|
|
218
|
+
// rotation below then only sees the new recording, and unreferenced keep
|
|
219
|
+
// files are swept once the resumed timeline is written.
|
|
220
|
+
let fromIndex = 0;
|
|
221
|
+
const reused: TimelineScene[] = [];
|
|
222
|
+
if (options.fromScene) {
|
|
223
|
+
fromIndex = storyboard.scenes.findIndex((s) => s.id === options.fromScene);
|
|
224
|
+
if (fromIndex < 0) {
|
|
225
|
+
throw new Error(`--from-scene: no scene "${options.fromScene}" in the storyboard`);
|
|
226
|
+
}
|
|
227
|
+
if (fromIndex === 0) {
|
|
228
|
+
log(`--from-scene ${options.fromScene} is the first scene — recording the full take`);
|
|
229
|
+
} else {
|
|
230
|
+
const prevRaw = await project.resolveRawVideo();
|
|
231
|
+
if (!(await exists(project.timelinePath)) || !(await exists(prevRaw))) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`--from-scene: no previous take to resume from (need ${project.timelinePath} + ${prevRaw}); record the full demo first`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const prev = TimelineSchema.parse(await readJson(project.timelinePath));
|
|
237
|
+
for (let i = 0; i < fromIndex; i++) {
|
|
238
|
+
const sc = storyboard.scenes[i];
|
|
239
|
+
const ps = prev.scenes[i];
|
|
240
|
+
if (!ps || ps.id !== sc.id) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`--from-scene: the previous take has ${ps ? `"${ps.id}"` : "nothing"} at position ${i + 1}, the storyboard has "${sc.id}" — scene order changed; record the full demo`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (!ps.hash) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`--from-scene: the previous take predates resume support (no scene hashes); record the full demo once, then resume`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (ps.hash !== sceneHash(storyboard, sc)) {
|
|
251
|
+
if (ps.hash === legacySceneHash(storyboard, sc)) {
|
|
252
|
+
// Recorded before `setup`/`params` were part of the identity: the
|
|
253
|
+
// action-spec still matches, so reuse the footage, but say plainly
|
|
254
|
+
// what can no longer be checked.
|
|
255
|
+
log(
|
|
256
|
+
`resume: scene "${sc.id}" predates setup/params hashing — its actions match, but a changed \`setup\` (cookies, storageState, preflight) would not be caught; re-record if the app is prepared differently now`
|
|
257
|
+
);
|
|
258
|
+
} else {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`--from-scene: scene "${sc.id}" changed since the previous take (actions, hide, redact, viewport, cursor mode, setup or params) — resume from "${sc.id}" or earlier. ` +
|
|
261
|
+
`A rewritten \`goto\` URL counts: a single-use magic link or a rotated token in an earlier scene changes its hash even though nothing else moved.`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
267
|
+
const keepPath = join(videoDir, `raw.keep-${stamp}${extname(prevRaw)}`);
|
|
268
|
+
await fs.rename(prevRaw, keepPath);
|
|
269
|
+
const keepRel = relative(project.dir, keepPath);
|
|
270
|
+
for (const ps of prev.scenes.slice(0, fromIndex)) {
|
|
271
|
+
reused.push({
|
|
272
|
+
...ps,
|
|
273
|
+
source: ps.source ?? keepRel,
|
|
274
|
+
leadInMs: ps.leadInMs ?? prev.leadInMs,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
log(
|
|
278
|
+
`resume: reusing ${reused.length} scene(s) from the previous take (${keepRel}); recording from "${options.fromScene}"`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
87
283
|
// Preserve the previous take as ONE `.prev` generation instead of deleting
|
|
88
284
|
// it: a bad new take (mid-run failure, wrong native-capture crop) must not
|
|
89
285
|
// destroy the last good recording. Roll back by copying raw.prev.* /
|
|
@@ -106,7 +302,7 @@ export async function record(
|
|
|
106
302
|
}
|
|
107
303
|
// Clear stray recordings so we can unambiguously pick the new one afterwards.
|
|
108
304
|
for (const f of await fs.readdir(videoDir).catch(() => [])) {
|
|
109
|
-
if (f.endsWith(".webm") && !f.includes(".prev")) {
|
|
305
|
+
if (f.endsWith(".webm") && !f.includes(".prev") && !KEEP_RE.test(f)) {
|
|
110
306
|
await fs.rm(join(videoDir, f), { force: true });
|
|
111
307
|
}
|
|
112
308
|
}
|
|
@@ -138,10 +334,13 @@ export async function record(
|
|
|
138
334
|
? { viewport: null }
|
|
139
335
|
: {
|
|
140
336
|
viewport: { width, height },
|
|
141
|
-
deviceScaleFactor:
|
|
337
|
+
deviceScaleFactor: dsf,
|
|
142
338
|
recordVideo: {
|
|
143
339
|
dir: dirname(project.rawVideoPath),
|
|
144
|
-
|
|
340
|
+
// `deviceScaleFactor: n` records n x the CSS viewport, so the
|
|
341
|
+
// product reads at output size (issue #51); the default 1x path
|
|
342
|
+
// still writes exactly width x height.
|
|
343
|
+
size: { width: width * videoScale, height: height * videoScale },
|
|
145
344
|
},
|
|
146
345
|
}),
|
|
147
346
|
});
|
|
@@ -167,10 +366,28 @@ export async function record(
|
|
|
167
366
|
log("cursor: compose-time overlay (recording cursor-free take + path)");
|
|
168
367
|
}
|
|
169
368
|
|
|
369
|
+
// Top-level `hide`: a stylesheet injected before any page script, in every
|
|
370
|
+
// frame and across navigations — teasers, cookie bars, ad slots that would
|
|
371
|
+
// photobomb the take. The ONE record-time exception to compose-time polish.
|
|
372
|
+
if (storyboard.hide?.length) {
|
|
373
|
+
const css = hideCss(storyboard.hide);
|
|
374
|
+
await context.addInitScript({
|
|
375
|
+
content:
|
|
376
|
+
`(() => { const add = () => { const s = document.createElement("style");` +
|
|
377
|
+
` s.id = "__aidemo_hide"; s.textContent = ${JSON.stringify(css)};` +
|
|
378
|
+
` (document.head || document.documentElement).appendChild(s); };` +
|
|
379
|
+
` if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", add); else add(); })();`,
|
|
380
|
+
});
|
|
381
|
+
log(`hide: ${storyboard.hide.length} selector(s) hidden at record time`);
|
|
382
|
+
}
|
|
383
|
+
|
|
170
384
|
const page = context.pages()[0] ?? (await context.newPage());
|
|
171
385
|
|
|
172
386
|
// Start from a clean blank so the recording's lead-in isn't the new-tab page.
|
|
173
387
|
await page.goto("about:blank");
|
|
388
|
+
// Seed cookies / localStorage (issue #44). Any origin visits this needs land
|
|
389
|
+
// in the lead-in, which compose trims.
|
|
390
|
+
if (seeds) await applySeeds(context, page, seeds);
|
|
174
391
|
|
|
175
392
|
let capture: CaptureProvider | null = null;
|
|
176
393
|
let geo: ViewportGeometry | null = null;
|
|
@@ -187,13 +404,36 @@ export async function record(
|
|
|
187
404
|
: new ObsCapture(obsUrl(), obsPassword());
|
|
188
405
|
log(`starting ${capture.name}`);
|
|
189
406
|
await capture.start();
|
|
407
|
+
// Re-assert front-most right before driving: the measure/start dance takes
|
|
408
|
+
// seconds, and a screen grab records whatever window is PAINTED at the
|
|
409
|
+
// crop rect — an occluded browser silently ships someone else's window
|
|
410
|
+
// into the take (issue #21).
|
|
411
|
+
await page.bringToFront();
|
|
190
412
|
}
|
|
191
413
|
|
|
192
414
|
const t0 = Date.now();
|
|
415
|
+
let salvageTailMs = 0;
|
|
416
|
+
let builtinTailMs = 0;
|
|
417
|
+
// Ctrl-C / SIGTERM on a stuck take used to lose everything — the process
|
|
418
|
+
// died before the timeline was written, so there was nothing to resume from
|
|
419
|
+
// (issue #55). Fold the signals into the abort signal the player polls, so
|
|
420
|
+
// an interrupted take takes the salvage path and writes what it has.
|
|
421
|
+
const interrupt = new AbortController();
|
|
422
|
+
const onSignal = (sig: NodeJS.Signals): void => {
|
|
423
|
+
if (interrupt.signal.aborted) return;
|
|
424
|
+
log(`\n${sig} — stopping the take and salvaging the scenes recorded so far`);
|
|
425
|
+
interrupt.abort();
|
|
426
|
+
};
|
|
427
|
+
process.on("SIGINT", onSignal);
|
|
428
|
+
process.on("SIGTERM", onSignal);
|
|
429
|
+
const signal = options.signal
|
|
430
|
+
? AbortSignal.any([options.signal, interrupt.signal])
|
|
431
|
+
: interrupt.signal;
|
|
193
432
|
const logsDir = project.p("logs");
|
|
194
433
|
const doneScenes: TimelineScene[] = [];
|
|
195
434
|
let timeline: Timeline;
|
|
196
435
|
let capFile: string | null = null;
|
|
436
|
+
let captureRefPath: string | null = null;
|
|
197
437
|
let tailMs = 0;
|
|
198
438
|
let recordErr: unknown = null;
|
|
199
439
|
try {
|
|
@@ -201,16 +441,34 @@ export async function record(
|
|
|
201
441
|
t0,
|
|
202
442
|
video: { width, height },
|
|
203
443
|
logsDir,
|
|
204
|
-
signal
|
|
444
|
+
signal,
|
|
205
445
|
probe: options.probe,
|
|
206
446
|
captureCursorPath: composeCursor,
|
|
447
|
+
baseDir: project.dir,
|
|
448
|
+
replayUntil: fromIndex || undefined,
|
|
449
|
+
skipReplay: !!options.noReplay,
|
|
207
450
|
onSceneStart: options.onSceneStart,
|
|
451
|
+
onSceneReplayed: (id, i, total) => {
|
|
452
|
+
// Progress-wise a replayed scene is "done": its footage already exists.
|
|
453
|
+
options.onSceneComplete?.(reused[i], i, total);
|
|
454
|
+
},
|
|
208
455
|
onSceneComplete: (s, i, total) => {
|
|
456
|
+
s.hash = sceneHash(storyboard, storyboard.scenes[i]);
|
|
209
457
|
doneScenes.push(s);
|
|
210
458
|
options.onSceneComplete?.(s, i, total);
|
|
211
459
|
},
|
|
212
460
|
});
|
|
461
|
+
timeline.scenes = [...reused, ...doneScenes];
|
|
213
462
|
if (capture) {
|
|
463
|
+
// Reference frame for the post-crop content verification (issue #21):
|
|
464
|
+
// the CDP page buffer at stop time — immune to window occlusion, so if
|
|
465
|
+
// the cropped screen pixels don't match it, the grab recorded something
|
|
466
|
+
// other than the driven page. Taken BEFORE stopAt so its latency lands
|
|
467
|
+
// in the (trimmed) tail, not in the lead-in math.
|
|
468
|
+
captureRefPath = join(videoDir, "capture-ref.png");
|
|
469
|
+
await page
|
|
470
|
+
.screenshot({ path: captureRefPath, timeout: 5000 })
|
|
471
|
+
.catch(() => (captureRefPath = null));
|
|
214
472
|
// Stop immediately so the tail past the last scene stays small. Measure
|
|
215
473
|
// the tail at stop-initiation — the recorder stops taking frames at the
|
|
216
474
|
// stop signal, not when it finishes flushing the file.
|
|
@@ -222,10 +480,16 @@ export async function record(
|
|
|
222
480
|
// Salvage: a failure in scene 7 of 7 (a phantom click, a platform
|
|
223
481
|
// interruption) otherwise discards minutes of good recording. Keep the
|
|
224
482
|
// scenes that completed, still finalize the video below, then re-throw so
|
|
225
|
-
// the exit code stays 1
|
|
483
|
+
// the exit code stays 1; `record --from-scene <id>` resumes past them.
|
|
226
484
|
recordErr = err;
|
|
227
485
|
const lastEnd = doneScenes.length ? doneScenes[doneScenes.length - 1].endMs : 0;
|
|
228
|
-
timeline = { totalMs: lastEnd, leadInMs: 0, scenes: doneScenes };
|
|
486
|
+
timeline = { totalMs: lastEnd, leadInMs: 0, scenes: [...reused, ...doneScenes] };
|
|
487
|
+
// The take kept recording through the FAILED scene (its goto, waits and
|
|
488
|
+
// the assert/timeout that broke it). That footage sits AFTER the last
|
|
489
|
+
// completed scene, so it must not be mistaken for front lead-in at
|
|
490
|
+
// finalize: otherwise every reused scene of a later `--from-scene` resume
|
|
491
|
+
// is read that many ms too late and plays ahead of its narration.
|
|
492
|
+
salvageTailMs = Math.max(0, Date.now() - t0 - lastEnd);
|
|
229
493
|
if (capture && !capFile) {
|
|
230
494
|
const stopAt = Date.now();
|
|
231
495
|
capFile = await capture.stop().catch(() => null);
|
|
@@ -233,7 +497,12 @@ export async function record(
|
|
|
233
497
|
}
|
|
234
498
|
} finally {
|
|
235
499
|
if (capture && !capFile) await capture.stop().catch(() => {});
|
|
236
|
-
// For built-in capture, closing the context finalizes the video file
|
|
500
|
+
// For built-in capture, closing the context finalizes the video file —
|
|
501
|
+
// and the recorder keeps taking frames until then. Everything after the
|
|
502
|
+
// last scene's end is TAIL, not front lead-in: attributing it to the
|
|
503
|
+
// lead-in shifts every scene's window later by that much, so a reused
|
|
504
|
+
// scene's window ran on into the next scene's navigation (issue #55).
|
|
505
|
+
builtinTailMs = Math.max(0, Date.now() - (t0 + timeline!.totalMs));
|
|
237
506
|
await context.close();
|
|
238
507
|
}
|
|
239
508
|
|
|
@@ -250,6 +519,18 @@ export async function record(
|
|
|
250
519
|
FPS
|
|
251
520
|
);
|
|
252
521
|
await fs.rm(capFile!, { force: true });
|
|
522
|
+
// Content guard (issue #21): geometry can be perfect while the pixels are
|
|
523
|
+
// some other window's (occlusion / wrong display). On mismatch this throws
|
|
524
|
+
// and keeps both files for review; on pass the reference is cleaned up.
|
|
525
|
+
// A salvaged (failed) take skips it — the reference may not exist.
|
|
526
|
+
if (captureRefPath && !recordErr) {
|
|
527
|
+
await verifyCaptureMatchesPage(project.rawVideoMp4Path, captureRefPath);
|
|
528
|
+
await fs.rm(captureRefPath, { force: true });
|
|
529
|
+
} else if (!recordErr) {
|
|
530
|
+
log(
|
|
531
|
+
"⚠ no page reference screenshot — capture content not verified; frame-review the take"
|
|
532
|
+
);
|
|
533
|
+
}
|
|
253
534
|
const videoMs = await probeDurationMs(project.rawVideoMp4Path);
|
|
254
535
|
// Capture started before t0 and stopped ~tailMs after the last scene.
|
|
255
536
|
timeline.leadInMs = Math.max(0, videoMs - tailMs - timeline.totalMs);
|
|
@@ -267,7 +548,7 @@ export async function record(
|
|
|
267
548
|
// recording), not just the first one, then drop the strays. The preserved
|
|
268
549
|
// previous take (raw.prev.webm) is not a candidate.
|
|
269
550
|
const produced = (await fs.readdir(videoDir)).filter(
|
|
270
|
-
(f) => f.endsWith(".webm") && !f.includes(".prev")
|
|
551
|
+
(f) => f.endsWith(".webm") && !f.includes(".prev") && !KEEP_RE.test(f)
|
|
271
552
|
);
|
|
272
553
|
if (produced.length === 0) {
|
|
273
554
|
throw new Error(`No video produced in ${videoDir}`);
|
|
@@ -289,10 +570,14 @@ export async function record(
|
|
|
289
570
|
// The front lead-in (about:blank + launch) = actual video length minus the
|
|
290
571
|
// measured content span. This is more reliable than timing the launch.
|
|
291
572
|
const videoMs = await probeDurationMs(project.rawVideoPath);
|
|
292
|
-
|
|
573
|
+
// salvageTailMs (the failed scene's footage) is part of builtinTailMs —
|
|
574
|
+
// both are measured from the last COMPLETED scene's end — so take the
|
|
575
|
+
// larger of the two rather than subtracting twice.
|
|
576
|
+
const tail = Math.max(builtinTailMs, salvageTailMs);
|
|
577
|
+
timeline.leadInMs = Math.max(0, videoMs - timeline.totalMs - tail);
|
|
293
578
|
await writeJson(project.timelinePath, timeline);
|
|
294
579
|
log(
|
|
295
|
-
`video ${videoMs}ms, content ${timeline.totalMs}ms → lead-in ${timeline.leadInMs}ms`
|
|
580
|
+
`video ${videoMs}ms, content ${timeline.totalMs}ms, tail ${tail}ms${salvageTailMs ? ` (failed-scene footage ${salvageTailMs}ms)` : ""} → lead-in ${timeline.leadInMs}ms`
|
|
296
581
|
);
|
|
297
582
|
ok(`recording → ${project.rawVideoPath}`);
|
|
298
583
|
finalized = true;
|
|
@@ -308,7 +593,17 @@ export async function record(
|
|
|
308
593
|
ok(
|
|
309
594
|
`timeline → ${project.timelinePath} (${timeline.scenes.length} scenes, ${timeline.totalMs}ms)`
|
|
310
595
|
);
|
|
596
|
+
// Sweep keep files no scene of the written timeline points at anymore.
|
|
597
|
+
const referenced = new Set(
|
|
598
|
+
timeline.scenes.filter((s) => s.source).map((s) => resolve(project.dir, s.source!))
|
|
599
|
+
);
|
|
600
|
+
for (const f of await fs.readdir(videoDir).catch(() => [])) {
|
|
601
|
+
const p = join(videoDir, f);
|
|
602
|
+
if (KEEP_RE.test(f) && !referenced.has(p)) await fs.rm(p, { force: true });
|
|
603
|
+
}
|
|
311
604
|
}
|
|
605
|
+
process.off("SIGINT", onSignal);
|
|
606
|
+
process.off("SIGTERM", onSignal);
|
|
312
607
|
if (recordErr) {
|
|
313
608
|
log(
|
|
314
609
|
`salvaged ${doneScenes.length}/${storyboard.scenes.length} scene(s) before failure; ` +
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storyboard-supplied regex patterns (`assert.textMatches`, `assert.url`, and
|
|
3
|
+
* the `textMatches` option the player threads through) are compiled at replay
|
|
4
|
+
* time. A storyboard is data — it can arrive from an import, a template, or a
|
|
5
|
+
* generated variant — so a pattern must not be able to hang the take. Compile
|
|
6
|
+
* every one of them through here: the length cap and the nested-quantifier
|
|
7
|
+
* check turn a catastrophic-backtracking pattern into an authoring error
|
|
8
|
+
* raised before the browser ever touches it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Longest pattern we accept; real storyboard matchers are a few dozen chars. */
|
|
12
|
+
const MAX_PATTERN_LEN = 1000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A quantified group whose body is itself quantified — `(a+)+`, `(a*)*`,
|
|
16
|
+
* `(\w+\s?)+` — is the shape that backtracks exponentially.
|
|
17
|
+
*/
|
|
18
|
+
const NESTED_QUANTIFIER = /\((?:\?[:=!<][a-zA-Z]*)?[^()]*[+*}][^()]*\)\s*[+*]|\((?:\?[:=!<][a-zA-Z]*)?[^()]*[+*][^()]*\)\s*\{\d+,\d*\}/;
|
|
19
|
+
|
|
20
|
+
export function isUnsafeRegexSource(pattern: string): string | null {
|
|
21
|
+
if (pattern.length > MAX_PATTERN_LEN)
|
|
22
|
+
return `pattern is ${pattern.length} characters (limit ${MAX_PATTERN_LEN})`;
|
|
23
|
+
if (NESTED_QUANTIFIER.test(pattern))
|
|
24
|
+
return "pattern nests a quantifier inside a quantified group, which can backtrack exponentially";
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compile a storyboard-supplied pattern, or throw with `where` naming the
|
|
30
|
+
* field so the author can see which matcher to fix.
|
|
31
|
+
*/
|
|
32
|
+
export function compileUserRegex(pattern: string, flags: string, where: string): RegExp {
|
|
33
|
+
const unsafe = isUnsafeRegexSource(pattern);
|
|
34
|
+
if (unsafe) throw new Error(`${where}: unsafe regex — ${unsafe}`);
|
|
35
|
+
return new RegExp(pattern, flags);
|
|
36
|
+
}
|