@tandryukha/aidemo 0.8.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/profile.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Recording-profile hygiene.
3
+ *
4
+ * Takes replay against a persistent Chrome profile, which is what makes a
5
+ * logged-in demo possible at all — and also the single most dangerous piece of
6
+ * hidden state in the pipeline. A profile that already holds cookies or
7
+ * localStorage for the app skips first-run gates, onboarding, empty states and
8
+ * one-shot flows, and the take then *silently demonstrates the opposite of what
9
+ * the narration says* (issue #39). The reviewer cannot tell; nothing failed.
10
+ *
11
+ * Two remedies live here: `--fresh`, which runs the take against a throwaway
12
+ * profile, and a warning that names the trap when the shared profile already
13
+ * has state for the storyboard's origin.
14
+ */
15
+
16
+ import { promises as fs } from "node:fs";
17
+ import { join, resolve } from "node:path";
18
+ import { chromeProfileDir } from "./config.js";
19
+ import type { Storyboard } from "./types.js";
20
+ import { exists } from "./util.js";
21
+
22
+ /** Where a `--fresh` run's throwaway profile lives (wiped before each take). */
23
+ export function freshProfileDir(demoDir: string): string {
24
+ return resolve(demoDir, ".chrome-profile-fresh");
25
+ }
26
+
27
+ /** Wipe and re-create the throwaway profile for a `--fresh` run. */
28
+ export async function resetProfile(dir: string): Promise<void> {
29
+ await fs.rm(dir, { recursive: true, force: true });
30
+ await fs.mkdir(dir, { recursive: true });
31
+ }
32
+
33
+ /** The origin of the storyboard's first `goto`, or null if it has none. */
34
+ export function firstGotoOrigin(storyboard: Storyboard): string | null {
35
+ for (const scene of storyboard.scenes) {
36
+ for (const action of scene.actions) {
37
+ if (action.op === "goto") {
38
+ try {
39
+ return new URL(action.url).origin;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Does the profile already hold state for `origin`? Checks Chrome's cookie and
51
+ * Local Storage stores as opaque bytes — both record the origin as a plain
52
+ * string, so a substring scan answers the question without launching a browser
53
+ * or navigating anywhere. Best-effort: any read error means "don't warn".
54
+ */
55
+ export async function profileHasStateFor(
56
+ profileDir: string,
57
+ origin: string
58
+ ): Promise<boolean> {
59
+ let host: string;
60
+ try {
61
+ host = new URL(origin).hostname;
62
+ } catch {
63
+ return false;
64
+ }
65
+ const needles = [host, `_${origin}`, origin];
66
+ const roots = [
67
+ join(profileDir, "Default", "Local Storage", "leveldb"),
68
+ join(profileDir, "Default", "Session Storage"),
69
+ ];
70
+ const files: string[] = [join(profileDir, "Default", "Cookies")];
71
+ for (const root of roots) {
72
+ for (const f of await fs.readdir(root).catch(() => [] as string[])) {
73
+ files.push(join(root, f));
74
+ }
75
+ }
76
+ for (const file of files) {
77
+ if (!(await exists(file))) continue;
78
+ let buf: Buffer;
79
+ try {
80
+ buf = await fs.readFile(file);
81
+ } catch {
82
+ continue;
83
+ }
84
+ // Chrome writes leveldb keys as UTF-16 for values and ASCII for keys;
85
+ // "latin1" catches the ASCII origin either way.
86
+ const text = buf.toString("latin1");
87
+ if (needles.some((n) => text.includes(n))) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ export interface ResolveProfileOptions {
93
+ /** Explicit --profile dir, if any. */
94
+ profileDir?: string;
95
+ /** --fresh: run against a wiped throwaway profile in the demo dir. */
96
+ fresh?: boolean;
97
+ }
98
+
99
+ /**
100
+ * Pick the profile a take should run against, preparing it if needed, and
101
+ * return any warning the caller should log. `--fresh` wins; otherwise the
102
+ * shared profile is used and checked for carried-over state.
103
+ */
104
+ export async function resolveProfile(
105
+ demoDir: string,
106
+ storyboard: Storyboard,
107
+ opts: ResolveProfileOptions
108
+ ): Promise<{ dir: string; warning?: string }> {
109
+ if (opts.fresh) {
110
+ const dir = opts.profileDir ?? freshProfileDir(demoDir);
111
+ await resetProfile(dir);
112
+ return { dir };
113
+ }
114
+ const dir = opts.profileDir ?? chromeProfileDir();
115
+ const origin = firstGotoOrigin(storyboard);
116
+ if (origin && (await profileHasStateFor(dir, origin))) {
117
+ return {
118
+ dir,
119
+ warning:
120
+ `profile already holds cookies/localStorage for ${origin} — a first-run ` +
121
+ `gate, onboarding step or one-shot flow may be skipped, and the take ` +
122
+ `would look fine while showing the wrong story. Re-run with --fresh ` +
123
+ `for a clean identity — or, if the profile is seeded on purpose, ` +
124
+ `set setup.expectState: true (CLI --profile-seeded) to silence this.`,
125
+ };
126
+ }
127
+ return { dir };
128
+ }
package/src/project.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { resolve, isAbsolute } from "node:path";
2
2
  import { StoryboardSchema, type Storyboard } from "./types.js";
3
3
  import { applyParams } from "./params.js";
4
+ import { applyAnchors } from "./anchors.js";
4
5
  import { ensureDir, exists, readJson, writeJson, log } from "./util.js";
5
6
 
6
7
  /**
@@ -119,6 +120,14 @@ export class Project {
119
120
  get gifPath() {
120
121
  return this.p("output", this.lang ? `final-demo.${this.lang}.gif` : "final-demo.gif");
121
122
  }
123
+ /** Structured compose report (per-scene retime facts + warnings). */
124
+ get posterPath() {
125
+ return resolve(this.dir, "output", this.lang ? `poster.${this.lang}.png` : "poster.png");
126
+ }
127
+
128
+ get reportPath() {
129
+ return this.p("output", this.lang ? `report.${this.lang}.json` : "report.json");
130
+ }
122
131
  /** Directory for screenshot-mode stills (one PNG per named `still` marker). */
123
132
  get stillsDir() {
124
133
  return this.p("output", "stills");
@@ -265,11 +274,12 @@ export function parseStoryboard(
265
274
  issues: [{ path: "params", message: applied.message, code: "params" }],
266
275
  };
267
276
  }
277
+ const storyboard = applyAnchors(applied.storyboard);
268
278
  return {
269
279
  ok: true,
270
- storyboard: applied.storyboard,
280
+ storyboard,
271
281
  resolved: applied.resolved,
272
- warnings: storyboardWarnings(applied.storyboard),
282
+ warnings: storyboardWarnings(storyboard),
273
283
  };
274
284
  }
275
285
 
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 type {
4
- Storyboard,
5
- Timeline,
6
- TimelineScene,
7
- ProbeGoldenScene,
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 { ensureDir, exists, writeJson, log, ok, step } from "./util.js";
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,12 @@ 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;
56
135
  }
57
136
 
58
137
  /**
@@ -62,9 +141,10 @@ export interface RecordOptions {
62
141
  */
63
142
  export async function record(
64
143
  project: Project,
65
- storyboard: Storyboard,
144
+ storyboardIn: Storyboard,
66
145
  options: RecordOptions = {}
67
146
  ): Promise<Timeline> {
147
+ let storyboard = storyboardIn;
68
148
  step(`Recording "${storyboard.title}"`);
69
149
  await project.ensureDirs();
70
150
  await ensureDir(dirname(project.rawVideoPath));
@@ -77,13 +157,109 @@ export async function record(
77
157
  );
78
158
  }
79
159
 
80
- const profileDir = options.profileDir ?? chromeProfileDir();
160
+ const { dir: profileDir, warning: profileWarning } = await resolveProfile(
161
+ project.dir,
162
+ storyboard,
163
+ { profileDir: options.profileDir, fresh: options.fresh }
164
+ );
165
+ // Fail fast with the actual fix when Chrome is still running on the profile
166
+ // (a live lock stalls Playwright and then dies with a raw ProcessSingleton
167
+ // error); clean up a stale lock from a crashed Chrome.
168
+ await ensureProfileUnlocked(profileDir);
169
+ log(`profile: ${profileDir}${options.fresh ? " (fresh)" : ""}`);
170
+
171
+ // Preflight hook (issue #44): runs before Chrome launches, with the resolved
172
+ // profile in its env. It may rewrite the storyboard (a rotating fixture), so
173
+ // re-read it afterwards when the caller gave us a loader.
174
+ if (storyboard.setup?.preflight) {
175
+ await runPreflight(storyboard.setup.preflight, {
176
+ demoDir: project.dir,
177
+ storyboardPath: project.storyboardPath,
178
+ profileDir,
179
+ });
180
+ if (options.reloadStoryboard) storyboard = await options.reloadStoryboard();
181
+ }
182
+ const seeds = await collectSeeds(project.dir, storyboard, {
183
+ storageState: options.storageState,
184
+ cookies: options.cookies,
185
+ });
186
+ // A deliberately seeded profile is the point, not the trap the warning
187
+ // names — acknowledge it via --profile-seeded / setup.expectState / seeds.
188
+ const seededOnPurpose =
189
+ !!options.profileSeeded || !!storyboard.setup?.expectState || !!seeds;
190
+ if (profileWarning && !seededOnPurpose) log(` ! ${profileWarning}`);
191
+
81
192
  const { width, height } = storyboard.video;
82
193
  const videoDir = dirname(project.rawVideoPath);
83
- log(`profile: ${profileDir}`);
84
194
  log(`viewport: ${width}x${height}`);
85
195
  if (external) log(`capture mode: ${mode}`);
86
196
 
197
+ // Resume: keep the previous take's leading scenes. Their raw file moves to a
198
+ // `raw.keep-*` name that the reused timeline scenes point at, the take's
199
+ // rotation below then only sees the new recording, and unreferenced keep
200
+ // files are swept once the resumed timeline is written.
201
+ let fromIndex = 0;
202
+ const reused: TimelineScene[] = [];
203
+ if (options.fromScene) {
204
+ fromIndex = storyboard.scenes.findIndex((s) => s.id === options.fromScene);
205
+ if (fromIndex < 0) {
206
+ throw new Error(`--from-scene: no scene "${options.fromScene}" in the storyboard`);
207
+ }
208
+ if (fromIndex === 0) {
209
+ log(`--from-scene ${options.fromScene} is the first scene — recording the full take`);
210
+ } else {
211
+ const prevRaw = await project.resolveRawVideo();
212
+ if (!(await exists(project.timelinePath)) || !(await exists(prevRaw))) {
213
+ throw new Error(
214
+ `--from-scene: no previous take to resume from (need ${project.timelinePath} + ${prevRaw}); record the full demo first`
215
+ );
216
+ }
217
+ const prev = TimelineSchema.parse(await readJson(project.timelinePath));
218
+ for (let i = 0; i < fromIndex; i++) {
219
+ const sc = storyboard.scenes[i];
220
+ const ps = prev.scenes[i];
221
+ if (!ps || ps.id !== sc.id) {
222
+ throw new Error(
223
+ `--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`
224
+ );
225
+ }
226
+ if (!ps.hash) {
227
+ throw new Error(
228
+ `--from-scene: the previous take predates resume support (no scene hashes); record the full demo once, then resume`
229
+ );
230
+ }
231
+ if (ps.hash !== sceneHash(storyboard, sc)) {
232
+ if (ps.hash === legacySceneHash(storyboard, sc)) {
233
+ // Recorded before `setup`/`params` were part of the identity: the
234
+ // action-spec still matches, so reuse the footage, but say plainly
235
+ // what can no longer be checked.
236
+ log(
237
+ `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`
238
+ );
239
+ } else {
240
+ throw new Error(
241
+ `--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`
242
+ );
243
+ }
244
+ }
245
+ }
246
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
247
+ const keepPath = join(videoDir, `raw.keep-${stamp}${extname(prevRaw)}`);
248
+ await fs.rename(prevRaw, keepPath);
249
+ const keepRel = relative(project.dir, keepPath);
250
+ for (const ps of prev.scenes.slice(0, fromIndex)) {
251
+ reused.push({
252
+ ...ps,
253
+ source: ps.source ?? keepRel,
254
+ leadInMs: ps.leadInMs ?? prev.leadInMs,
255
+ });
256
+ }
257
+ log(
258
+ `resume: reusing ${reused.length} scene(s) from the previous take (${keepRel}); recording from "${options.fromScene}"`
259
+ );
260
+ }
261
+ }
262
+
87
263
  // Preserve the previous take as ONE `.prev` generation instead of deleting
88
264
  // it: a bad new take (mid-run failure, wrong native-capture crop) must not
89
265
  // destroy the last good recording. Roll back by copying raw.prev.* /
@@ -106,7 +282,7 @@ export async function record(
106
282
  }
107
283
  // Clear stray recordings so we can unambiguously pick the new one afterwards.
108
284
  for (const f of await fs.readdir(videoDir).catch(() => [])) {
109
- if (f.endsWith(".webm") && !f.includes(".prev")) {
285
+ if (f.endsWith(".webm") && !f.includes(".prev") && !KEEP_RE.test(f)) {
110
286
  await fs.rm(join(videoDir, f), { force: true });
111
287
  }
112
288
  }
@@ -167,10 +343,28 @@ export async function record(
167
343
  log("cursor: compose-time overlay (recording cursor-free take + path)");
168
344
  }
169
345
 
346
+ // Top-level `hide`: a stylesheet injected before any page script, in every
347
+ // frame and across navigations — teasers, cookie bars, ad slots that would
348
+ // photobomb the take. The ONE record-time exception to compose-time polish.
349
+ if (storyboard.hide?.length) {
350
+ const css = hideCss(storyboard.hide);
351
+ await context.addInitScript({
352
+ content:
353
+ `(() => { const add = () => { const s = document.createElement("style");` +
354
+ ` s.id = "__aidemo_hide"; s.textContent = ${JSON.stringify(css)};` +
355
+ ` (document.head || document.documentElement).appendChild(s); };` +
356
+ ` if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", add); else add(); })();`,
357
+ });
358
+ log(`hide: ${storyboard.hide.length} selector(s) hidden at record time`);
359
+ }
360
+
170
361
  const page = context.pages()[0] ?? (await context.newPage());
171
362
 
172
363
  // Start from a clean blank so the recording's lead-in isn't the new-tab page.
173
364
  await page.goto("about:blank");
365
+ // Seed cookies / localStorage (issue #44). Any origin visits this needs land
366
+ // in the lead-in, which compose trims.
367
+ if (seeds) await applySeeds(context, page, seeds);
174
368
 
175
369
  let capture: CaptureProvider | null = null;
176
370
  let geo: ViewportGeometry | null = null;
@@ -187,6 +381,11 @@ export async function record(
187
381
  : new ObsCapture(obsUrl(), obsPassword());
188
382
  log(`starting ${capture.name}`);
189
383
  await capture.start();
384
+ // Re-assert front-most right before driving: the measure/start dance takes
385
+ // seconds, and a screen grab records whatever window is PAINTED at the
386
+ // crop rect — an occluded browser silently ships someone else's window
387
+ // into the take (issue #21).
388
+ await page.bringToFront();
190
389
  }
191
390
 
192
391
  const t0 = Date.now();
@@ -194,6 +393,7 @@ export async function record(
194
393
  const doneScenes: TimelineScene[] = [];
195
394
  let timeline: Timeline;
196
395
  let capFile: string | null = null;
396
+ let captureRefPath: string | null = null;
197
397
  let tailMs = 0;
198
398
  let recordErr: unknown = null;
199
399
  try {
@@ -204,13 +404,30 @@ export async function record(
204
404
  signal: options.signal,
205
405
  probe: options.probe,
206
406
  captureCursorPath: composeCursor,
407
+ baseDir: project.dir,
408
+ replayUntil: fromIndex || undefined,
207
409
  onSceneStart: options.onSceneStart,
410
+ onSceneReplayed: (id, i, total) => {
411
+ // Progress-wise a replayed scene is "done": its footage already exists.
412
+ options.onSceneComplete?.(reused[i], i, total);
413
+ },
208
414
  onSceneComplete: (s, i, total) => {
415
+ s.hash = sceneHash(storyboard, storyboard.scenes[i]);
209
416
  doneScenes.push(s);
210
417
  options.onSceneComplete?.(s, i, total);
211
418
  },
212
419
  });
420
+ timeline.scenes = [...reused, ...doneScenes];
213
421
  if (capture) {
422
+ // Reference frame for the post-crop content verification (issue #21):
423
+ // the CDP page buffer at stop time — immune to window occlusion, so if
424
+ // the cropped screen pixels don't match it, the grab recorded something
425
+ // other than the driven page. Taken BEFORE stopAt so its latency lands
426
+ // in the (trimmed) tail, not in the lead-in math.
427
+ captureRefPath = join(videoDir, "capture-ref.png");
428
+ await page
429
+ .screenshot({ path: captureRefPath, timeout: 5000 })
430
+ .catch(() => (captureRefPath = null));
214
431
  // Stop immediately so the tail past the last scene stays small. Measure
215
432
  // the tail at stop-initiation — the recorder stops taking frames at the
216
433
  // stop signal, not when it finishes flushing the file.
@@ -222,10 +439,10 @@ export async function record(
222
439
  // Salvage: a failure in scene 7 of 7 (a phantom click, a platform
223
440
  // interruption) otherwise discards minutes of good recording. Keep the
224
441
  // scenes that completed, still finalize the video below, then re-throw so
225
- // the exit code stays 1. `record --from-scene <id>` resume is future work.
442
+ // the exit code stays 1; `record --from-scene <id>` resumes past them.
226
443
  recordErr = err;
227
444
  const lastEnd = doneScenes.length ? doneScenes[doneScenes.length - 1].endMs : 0;
228
- timeline = { totalMs: lastEnd, leadInMs: 0, scenes: doneScenes };
445
+ timeline = { totalMs: lastEnd, leadInMs: 0, scenes: [...reused, ...doneScenes] };
229
446
  if (capture && !capFile) {
230
447
  const stopAt = Date.now();
231
448
  capFile = await capture.stop().catch(() => null);
@@ -250,6 +467,18 @@ export async function record(
250
467
  FPS
251
468
  );
252
469
  await fs.rm(capFile!, { force: true });
470
+ // Content guard (issue #21): geometry can be perfect while the pixels are
471
+ // some other window's (occlusion / wrong display). On mismatch this throws
472
+ // and keeps both files for review; on pass the reference is cleaned up.
473
+ // A salvaged (failed) take skips it — the reference may not exist.
474
+ if (captureRefPath && !recordErr) {
475
+ await verifyCaptureMatchesPage(project.rawVideoMp4Path, captureRefPath);
476
+ await fs.rm(captureRefPath, { force: true });
477
+ } else if (!recordErr) {
478
+ log(
479
+ "⚠ no page reference screenshot — capture content not verified; frame-review the take"
480
+ );
481
+ }
253
482
  const videoMs = await probeDurationMs(project.rawVideoMp4Path);
254
483
  // Capture started before t0 and stopped ~tailMs after the last scene.
255
484
  timeline.leadInMs = Math.max(0, videoMs - tailMs - timeline.totalMs);
@@ -267,7 +496,7 @@ export async function record(
267
496
  // recording), not just the first one, then drop the strays. The preserved
268
497
  // previous take (raw.prev.webm) is not a candidate.
269
498
  const produced = (await fs.readdir(videoDir)).filter(
270
- (f) => f.endsWith(".webm") && !f.includes(".prev")
499
+ (f) => f.endsWith(".webm") && !f.includes(".prev") && !KEEP_RE.test(f)
271
500
  );
272
501
  if (produced.length === 0) {
273
502
  throw new Error(`No video produced in ${videoDir}`);
@@ -308,6 +537,14 @@ export async function record(
308
537
  ok(
309
538
  `timeline → ${project.timelinePath} (${timeline.scenes.length} scenes, ${timeline.totalMs}ms)`
310
539
  );
540
+ // Sweep keep files no scene of the written timeline points at anymore.
541
+ const referenced = new Set(
542
+ timeline.scenes.filter((s) => s.source).map((s) => resolve(project.dir, s.source!))
543
+ );
544
+ for (const f of await fs.readdir(videoDir).catch(() => [])) {
545
+ const p = join(videoDir, f);
546
+ if (KEEP_RE.test(f) && !referenced.has(p)) await fs.rm(p, { force: true });
547
+ }
311
548
  }
312
549
  if (recordErr) {
313
550
  log(