@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/.claude/skills/record-demo/SKILL.md +5 -2
- package/README.md +16 -4
- package/docs/AUTHORING.md +609 -45
- 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 +70 -6
- package/src/capture.ts +113 -0
- package/src/cards.ts +15 -6
- package/src/cli.ts +580 -22
- package/src/compose.ts +1006 -93
- package/src/cursor-overlay.ts +7 -113
- package/src/cursor.ts +9 -3
- package/src/distribute.ts +37 -4
- package/src/expr.ts +130 -0
- package/src/ffmpeg.ts +156 -0
- package/src/frame.ts +205 -0
- package/src/frames.ts +159 -0
- package/src/golden.ts +27 -0
- package/src/guide.ts +102 -0
- package/src/i18n.ts +7 -4
- package/src/import-trace.ts +629 -0
- package/src/inspect.ts +307 -0
- package/src/lint.ts +601 -0
- package/src/login.ts +143 -0
- package/src/mcp/jobs.ts +4 -1
- package/src/mcp/server.ts +403 -26
- package/src/player.ts +975 -32
- package/src/profile.ts +128 -0
- package/src/project.ts +12 -2
- package/src/recorder.ts +252 -15
- package/src/setup.ts +248 -0
- package/src/starter.ts +244 -0
- package/src/stills.ts +15 -4
- package/src/types.ts +643 -5
- 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/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
|
|
280
|
+
storyboard,
|
|
271
281
|
resolved: applied.resolved,
|
|
272
|
-
warnings: storyboardWarnings(
|
|
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
|
|
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,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
|
-
|
|
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 =
|
|
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
|
|
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(
|