@tandryukha/aidemo 0.8.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 +66 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/aidemo.mjs +53 -0
- package/docs/AUTHORING.md +728 -0
- package/package.json +47 -0
- package/src/caption-render.ts +69 -0
- package/src/captions.ts +441 -0
- package/src/capture.ts +574 -0
- package/src/cards.ts +67 -0
- package/src/cli.ts +646 -0
- package/src/compose.ts +937 -0
- package/src/config.ts +222 -0
- package/src/cursor-overlay.ts +186 -0
- package/src/cursor.ts +134 -0
- package/src/distribute.ts +713 -0
- package/src/embed.ts +202 -0
- package/src/ffmpeg.ts +83 -0
- package/src/gif.ts +65 -0
- package/src/golden.ts +109 -0
- package/src/i18n.ts +86 -0
- package/src/mcp/jobs.ts +285 -0
- package/src/mcp/server.ts +1016 -0
- package/src/music.ts +153 -0
- package/src/params.ts +134 -0
- package/src/player.ts +1173 -0
- package/src/project.ts +287 -0
- package/src/recorder.ts +320 -0
- package/src/starter.ts +141 -0
- package/src/stills.ts +121 -0
- package/src/types.ts +660 -0
- package/src/util.ts +171 -0
- package/src/variants.ts +117 -0
- package/src/voice-local.ts +230 -0
- package/src/voice.ts +405 -0
- package/src/zoom.ts +183 -0
package/src/project.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { resolve, isAbsolute } from "node:path";
|
|
2
|
+
import { StoryboardSchema, type Storyboard } from "./types.js";
|
|
3
|
+
import { applyParams } from "./params.js";
|
|
4
|
+
import { ensureDir, exists, readJson, writeJson, log } from "./util.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolves and creates the standard artifact layout for one demo project.
|
|
8
|
+
*
|
|
9
|
+
* <dir>/
|
|
10
|
+
* input/brief.md
|
|
11
|
+
* generated/storyboard.json timeline.json captions.srt captions.vtt
|
|
12
|
+
* recordings/raw.webm
|
|
13
|
+
* audio/scene-*.mp3 narration.mp3 voice.json
|
|
14
|
+
* output/final-demo.mp4
|
|
15
|
+
* logs/
|
|
16
|
+
*/
|
|
17
|
+
export class Project {
|
|
18
|
+
readonly dir: string;
|
|
19
|
+
/**
|
|
20
|
+
* Optional language code for a multi-language render (see src/i18n.ts). When
|
|
21
|
+
* set, the per-LANGUAGE artifacts move under a language namespace so they
|
|
22
|
+
* don't clobber the default: audio → audio/<lang>/, captions →
|
|
23
|
+
* captions.<lang>.*, output → final-demo.<lang>.mp4. The SHARED take
|
|
24
|
+
* (recordings/raw.*, generated/timeline.json, generated/storyboard.json) is
|
|
25
|
+
* language-independent and stays put — that's what lets one recording feed N
|
|
26
|
+
* language renders. Unset → byte-for-byte the original single-language layout.
|
|
27
|
+
*/
|
|
28
|
+
readonly lang?: string;
|
|
29
|
+
constructor(dir: string, lang?: string) {
|
|
30
|
+
this.dir = isAbsolute(dir) ? dir : resolve(process.cwd(), dir);
|
|
31
|
+
this.lang = lang || undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
p(...parts: string[]): string {
|
|
35
|
+
return resolve(this.dir, ...parts);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Language-scoped audio dir (audio/<lang>/ for a variant; audio/ by default). */
|
|
39
|
+
private audioDir(): string {
|
|
40
|
+
return this.lang ? this.p("audio", this.lang) : this.p("audio");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** captions.<lang>.<ext> for a variant; captions.<ext> by default. */
|
|
44
|
+
private captionPath(ext: string): string {
|
|
45
|
+
return this.p("generated", this.lang ? `captions.${this.lang}.${ext}` : `captions.${ext}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get storyboardPath() {
|
|
49
|
+
return this.p("generated", "storyboard.json");
|
|
50
|
+
}
|
|
51
|
+
get timelinePath() {
|
|
52
|
+
return this.p("generated", "timeline.json");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolved template params of the last load are persisted here so a later
|
|
56
|
+
* stage-only re-run (e.g. `aidemo compose <dir>` after a parameterized record)
|
|
57
|
+
* reuses the SAME values — keeping narration, captions and the take in sync.
|
|
58
|
+
*/
|
|
59
|
+
get paramsPath() {
|
|
60
|
+
return this.p("generated", "params.json");
|
|
61
|
+
}
|
|
62
|
+
get captionsSrtPath() {
|
|
63
|
+
return this.captionPath("srt");
|
|
64
|
+
}
|
|
65
|
+
get captionsVttPath() {
|
|
66
|
+
return this.captionPath("vtt");
|
|
67
|
+
}
|
|
68
|
+
get captionsCuesPath() {
|
|
69
|
+
return this.captionPath("cues.json");
|
|
70
|
+
}
|
|
71
|
+
/** Caption reuse manifest (per-scene input hashes + stored word timings). */
|
|
72
|
+
get captionsManifestPath() {
|
|
73
|
+
return this.p("generated", "captions.manifest.json");
|
|
74
|
+
}
|
|
75
|
+
/** Checked-in golden baseline for `aidemo probe --golden` regression checks. */
|
|
76
|
+
get goldenProbePath() {
|
|
77
|
+
return this.p("golden", "probe.json");
|
|
78
|
+
}
|
|
79
|
+
get rawVideoPath() {
|
|
80
|
+
return this.p("recordings", "raw.webm");
|
|
81
|
+
}
|
|
82
|
+
/** Native/OBS captures are transcoded here (Playwright records .webm). */
|
|
83
|
+
get rawVideoMp4Path() {
|
|
84
|
+
return this.p("recordings", "raw.mp4");
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* One preserved previous-take generation: `record` rotates the existing
|
|
88
|
+
* take (raw video + timeline) to these paths instead of deleting it, so a
|
|
89
|
+
* bad new take never destroys the last good one. Roll back by copying the
|
|
90
|
+
* .prev files over the current ones.
|
|
91
|
+
*/
|
|
92
|
+
get rawVideoPrevPath() {
|
|
93
|
+
return this.p("recordings", "raw.prev.webm");
|
|
94
|
+
}
|
|
95
|
+
get rawVideoMp4PrevPath() {
|
|
96
|
+
return this.p("recordings", "raw.prev.mp4");
|
|
97
|
+
}
|
|
98
|
+
get timelinePrevPath() {
|
|
99
|
+
return this.p("generated", "timeline.prev.json");
|
|
100
|
+
}
|
|
101
|
+
/** The raw recording to compose from, whichever capture path produced it. */
|
|
102
|
+
async resolveRawVideo(): Promise<string> {
|
|
103
|
+
return (await exists(this.rawVideoMp4Path))
|
|
104
|
+
? this.rawVideoMp4Path
|
|
105
|
+
: this.rawVideoPath;
|
|
106
|
+
}
|
|
107
|
+
get narrationPath() {
|
|
108
|
+
return resolve(this.audioDir(), "narration.mp3");
|
|
109
|
+
}
|
|
110
|
+
get voiceManifestPath() {
|
|
111
|
+
return resolve(this.audioDir(), "voice.json");
|
|
112
|
+
}
|
|
113
|
+
sceneAudioPath(id: string) {
|
|
114
|
+
return resolve(this.audioDir(), `scene-${id}.mp3`);
|
|
115
|
+
}
|
|
116
|
+
get outputPath() {
|
|
117
|
+
return this.p("output", this.lang ? `final-demo.${this.lang}.mp4` : "final-demo.mp4");
|
|
118
|
+
}
|
|
119
|
+
get gifPath() {
|
|
120
|
+
return this.p("output", this.lang ? `final-demo.${this.lang}.gif` : "final-demo.gif");
|
|
121
|
+
}
|
|
122
|
+
/** Directory for screenshot-mode stills (one PNG per named `still` marker). */
|
|
123
|
+
get stillsDir() {
|
|
124
|
+
return this.p("output", "stills");
|
|
125
|
+
}
|
|
126
|
+
get composeTmpDir() {
|
|
127
|
+
return this.p(this.lang ? `.compose-tmp-${this.lang}` : ".compose-tmp");
|
|
128
|
+
}
|
|
129
|
+
get gifTmpDir() {
|
|
130
|
+
return this.p(".gif-tmp");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async ensureDirs(): Promise<void> {
|
|
134
|
+
for (const d of [
|
|
135
|
+
"input",
|
|
136
|
+
"generated",
|
|
137
|
+
"recordings",
|
|
138
|
+
"audio",
|
|
139
|
+
"output",
|
|
140
|
+
"logs",
|
|
141
|
+
]) {
|
|
142
|
+
await ensureDir(this.p(d));
|
|
143
|
+
}
|
|
144
|
+
// Language-scoped audio lives in a per-lang subdir (audio/<lang>/).
|
|
145
|
+
if (this.lang) await ensureDir(this.audioDir());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Load + validate the storyboard. `relaxed` (for `aidemo probe`) makes
|
|
150
|
+
* narration optional — a record-only dry run to verify selectors/timing
|
|
151
|
+
* doesn't need a script — by injecting an empty narration where missing
|
|
152
|
+
* before validating against the one schema.
|
|
153
|
+
*
|
|
154
|
+
* `params` are explicit template overrides (CLI `--param`, MCP `params`,
|
|
155
|
+
* variant params); they take precedence and are typo-guarded. When none are
|
|
156
|
+
* given, the last run's persisted params (generated/params.json) are reused
|
|
157
|
+
* so stage-only re-runs stay consistent. The resolved set is persisted here.
|
|
158
|
+
*/
|
|
159
|
+
async loadStoryboard(
|
|
160
|
+
opts: { relaxed?: boolean; params?: Record<string, string> } = {}
|
|
161
|
+
): Promise<Storyboard> {
|
|
162
|
+
const raw = await readJson<unknown>(this.storyboardPath);
|
|
163
|
+
const explicit =
|
|
164
|
+
opts.params && Object.keys(opts.params).length ? opts.params : undefined;
|
|
165
|
+
const provided = explicit ?? (await this.readPersistedParams());
|
|
166
|
+
const parsed = parseStoryboard(raw, {
|
|
167
|
+
relaxed: opts.relaxed,
|
|
168
|
+
params: provided,
|
|
169
|
+
// Typo-guard only explicit user input; persisted params are filtered.
|
|
170
|
+
strict: explicit != null,
|
|
171
|
+
});
|
|
172
|
+
if (!parsed.ok) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Invalid storyboard.json:\n${parsed.issues
|
|
175
|
+
.map((i) => ` - ${i.path}: ${i.message}`)
|
|
176
|
+
.join("\n")}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
for (const w of parsed.warnings) log(w);
|
|
180
|
+
if (Object.keys(parsed.resolved).length) {
|
|
181
|
+
await this.persistParams(parsed.resolved);
|
|
182
|
+
}
|
|
183
|
+
return parsed.storyboard;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Last run's resolved template params, or undefined if none were persisted. */
|
|
187
|
+
async readPersistedParams(): Promise<Record<string, string> | undefined> {
|
|
188
|
+
if (!(await exists(this.paramsPath))) return undefined;
|
|
189
|
+
try {
|
|
190
|
+
const data = await readJson<{ params?: Record<string, string> }>(
|
|
191
|
+
this.paramsPath
|
|
192
|
+
);
|
|
193
|
+
return data && typeof data.params === "object" ? data.params : undefined;
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Persist the resolved template params for later stage-only re-runs. */
|
|
200
|
+
async persistParams(resolved: Record<string, string>): Promise<void> {
|
|
201
|
+
await ensureDir(this.p("generated"));
|
|
202
|
+
await writeJson(this.paramsPath, { params: resolved });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface StoryboardIssue {
|
|
207
|
+
path: string;
|
|
208
|
+
message: string;
|
|
209
|
+
code: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type StoryboardParse =
|
|
213
|
+
| {
|
|
214
|
+
ok: true;
|
|
215
|
+
storyboard: Storyboard;
|
|
216
|
+
/** Resolved template params after applying declared defaults + overrides. */
|
|
217
|
+
resolved: Record<string, string>;
|
|
218
|
+
warnings: string[];
|
|
219
|
+
}
|
|
220
|
+
| { ok: false; issues: StoryboardIssue[] };
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Validate a raw storyboard value against the schema, returning structured
|
|
224
|
+
* issues instead of throwing. `relaxed` (probe) makes narration optional by
|
|
225
|
+
* injecting an empty one where missing before validating — a record-only dry
|
|
226
|
+
* run doesn't need a script.
|
|
227
|
+
*
|
|
228
|
+
* `params` are template overrides; substitution runs AFTER zod validation on
|
|
229
|
+
* the typed storyboard (so placeholders only reach string fields) and reports
|
|
230
|
+
* unresolved/undeclared placeholders as a `params` issue.
|
|
231
|
+
*/
|
|
232
|
+
export function parseStoryboard(
|
|
233
|
+
raw: unknown,
|
|
234
|
+
opts: { relaxed?: boolean; params?: Record<string, string>; strict?: boolean } = {}
|
|
235
|
+
): StoryboardParse {
|
|
236
|
+
if (
|
|
237
|
+
opts.relaxed &&
|
|
238
|
+
raw &&
|
|
239
|
+
typeof raw === "object" &&
|
|
240
|
+
Array.isArray((raw as { scenes?: unknown }).scenes)
|
|
241
|
+
) {
|
|
242
|
+
for (const s of (raw as { scenes: Array<Record<string, unknown>> }).scenes) {
|
|
243
|
+
if (s && typeof s === "object" && s.narration == null) s.narration = "";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const parsed = StoryboardSchema.safeParse(raw);
|
|
247
|
+
if (!parsed.success) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
issues: parsed.error.issues.map((i) => ({
|
|
251
|
+
path: i.path.join("."),
|
|
252
|
+
message: i.message,
|
|
253
|
+
code: i.code,
|
|
254
|
+
})),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
// Resolve + substitute {{template}} params on the typed storyboard.
|
|
258
|
+
const applied = applyParams(parsed.data, {
|
|
259
|
+
provided: opts.params,
|
|
260
|
+
strict: opts.strict,
|
|
261
|
+
});
|
|
262
|
+
if (!applied.ok) {
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
issues: [{ path: "params", message: applied.message, code: "params" }],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
storyboard: applied.storyboard,
|
|
271
|
+
resolved: applied.resolved,
|
|
272
|
+
warnings: storyboardWarnings(applied.storyboard),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Surface silent no-ops in the storyboard so they aren't a surprise. */
|
|
277
|
+
export function storyboardWarnings(sb: Storyboard): string[] {
|
|
278
|
+
const warnings: string[] = [];
|
|
279
|
+
const withCue = sb.scenes.filter((s) => s.music?.cue).map((s) => s.id);
|
|
280
|
+
if (withCue.length) {
|
|
281
|
+
warnings.push(
|
|
282
|
+
`⚠ per-scene music.cue is informational only and currently ignored ` +
|
|
283
|
+
`(scenes: ${withCue.join(", ")})`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return warnings;
|
|
287
|
+
}
|
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import type {
|
|
4
|
+
Storyboard,
|
|
5
|
+
Timeline,
|
|
6
|
+
TimelineScene,
|
|
7
|
+
ProbeGoldenScene,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
import { Project } from "./project.js";
|
|
10
|
+
import { cursorInitScript } from "./cursor.js";
|
|
11
|
+
import { runStoryboard } from "./player.js";
|
|
12
|
+
import {
|
|
13
|
+
chromeProfileDir,
|
|
14
|
+
defaultCaptureMode,
|
|
15
|
+
captureDevice,
|
|
16
|
+
obsUrl,
|
|
17
|
+
obsPassword,
|
|
18
|
+
} from "./config.js";
|
|
19
|
+
import {
|
|
20
|
+
type CaptureMode,
|
|
21
|
+
type CaptureProvider,
|
|
22
|
+
type ViewportGeometry,
|
|
23
|
+
AvfoundationCapture,
|
|
24
|
+
ObsCapture,
|
|
25
|
+
measureViewportGeometry,
|
|
26
|
+
cropCaptureToViewport,
|
|
27
|
+
} from "./capture.js";
|
|
28
|
+
import { ensureDir, exists, writeJson, log, ok, step } from "./util.js";
|
|
29
|
+
import { probeDurationMs } from "./ffmpeg.js";
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
|
|
32
|
+
const FPS = 30;
|
|
33
|
+
|
|
34
|
+
export interface RecordOptions {
|
|
35
|
+
/** Chrome user-data dir (logged-in profile). Defaults to config profile. */
|
|
36
|
+
profileDir?: string;
|
|
37
|
+
/** Show the browser window. Default true (channel chrome needs a display). */
|
|
38
|
+
headed?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Capture path. "playwright" (default) = built-in CDP video; "native" =
|
|
41
|
+
* ffmpeg avfoundation screen grab (macOS); "obs" = OBS via obs-websocket.
|
|
42
|
+
* The external paths record the real screen for higher fidelity, then crop
|
|
43
|
+
* to the viewport. Env default: AIDEMO_CAPTURE.
|
|
44
|
+
*/
|
|
45
|
+
capture?: CaptureMode;
|
|
46
|
+
/** Progress hooks + best-effort cancellation for job runners (MCP). */
|
|
47
|
+
onSceneStart?: (sceneId: string, index: number, total: number) => void;
|
|
48
|
+
onSceneComplete?: (scene: TimelineScene, index: number, total: number) => void;
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
/**
|
|
51
|
+
* Golden-probe capture (see PlayerOptions.probe / src/golden.ts). When present,
|
|
52
|
+
* the run records a normalized per-action outcome into this array and doesn't
|
|
53
|
+
* abort on an action failure — for `aidemo probe --golden/--update-golden`.
|
|
54
|
+
*/
|
|
55
|
+
probe?: ProbeGoldenScene[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Drives the storyboard's actions through a real, logged-in Chrome while
|
|
60
|
+
* recording — either via Playwright's built-in video (raw.webm) or an
|
|
61
|
+
* external screen capture (raw.mp4). Also writes timeline.json.
|
|
62
|
+
*/
|
|
63
|
+
export async function record(
|
|
64
|
+
project: Project,
|
|
65
|
+
storyboard: Storyboard,
|
|
66
|
+
options: RecordOptions = {}
|
|
67
|
+
): Promise<Timeline> {
|
|
68
|
+
step(`Recording "${storyboard.title}"`);
|
|
69
|
+
await project.ensureDirs();
|
|
70
|
+
await ensureDir(dirname(project.rawVideoPath));
|
|
71
|
+
|
|
72
|
+
const mode = (options.capture ?? defaultCaptureMode()) as CaptureMode;
|
|
73
|
+
const external = mode !== "playwright";
|
|
74
|
+
if (external && options.headed === false) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`--capture ${mode} records the real screen; run headed (drop --headless).`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const profileDir = options.profileDir ?? chromeProfileDir();
|
|
81
|
+
const { width, height } = storyboard.video;
|
|
82
|
+
const videoDir = dirname(project.rawVideoPath);
|
|
83
|
+
log(`profile: ${profileDir}`);
|
|
84
|
+
log(`viewport: ${width}x${height}`);
|
|
85
|
+
if (external) log(`capture mode: ${mode}`);
|
|
86
|
+
|
|
87
|
+
// Preserve the previous take as ONE `.prev` generation instead of deleting
|
|
88
|
+
// it: a bad new take (mid-run failure, wrong native-capture crop) must not
|
|
89
|
+
// destroy the last good recording. Roll back by copying raw.prev.* /
|
|
90
|
+
// timeline.prev.json over the current files.
|
|
91
|
+
const prevGeneration: Array<[string, string]> = [
|
|
92
|
+
[project.rawVideoPath, project.rawVideoPrevPath],
|
|
93
|
+
[project.rawVideoMp4Path, project.rawVideoMp4PrevPath],
|
|
94
|
+
[project.timelinePath, project.timelinePrevPath],
|
|
95
|
+
];
|
|
96
|
+
for (const [, prev] of prevGeneration) await fs.rm(prev, { force: true });
|
|
97
|
+
const preserved: string[] = [];
|
|
98
|
+
for (const [current, prev] of prevGeneration) {
|
|
99
|
+
if (await exists(current)) {
|
|
100
|
+
await fs.rename(current, prev);
|
|
101
|
+
preserved.push(prev);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (preserved.length) {
|
|
105
|
+
log(`previous take preserved → ${preserved.join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
// Clear stray recordings so we can unambiguously pick the new one afterwards.
|
|
108
|
+
for (const f of await fs.readdir(videoDir).catch(() => [])) {
|
|
109
|
+
if (f.endsWith(".webm") && !f.includes(".prev")) {
|
|
110
|
+
await fs.rm(join(videoDir, f), { force: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const screenCapPath = join(videoDir, "capture.mkv");
|
|
114
|
+
await fs.rm(screenCapPath, { force: true });
|
|
115
|
+
|
|
116
|
+
let context;
|
|
117
|
+
try {
|
|
118
|
+
context = await chromium.launchPersistentContext(profileDir, {
|
|
119
|
+
channel: "chrome",
|
|
120
|
+
headless: options.headed === false,
|
|
121
|
+
// Hide the automation fingerprint (navigator.webdriver=false) so
|
|
122
|
+
// Cloudflare on chatgpt.com doesn't throw a "Verify you are human"
|
|
123
|
+
// challenge mid-recording. The profile's real cf_clearance cookie is
|
|
124
|
+
// then honored because the browser matches the manual-login fingerprint.
|
|
125
|
+
args: [
|
|
126
|
+
"--disable-blink-features=AutomationControlled",
|
|
127
|
+
// Keep the window at a known spot on the primary screen for capture.
|
|
128
|
+
...(external ? ["--window-position=40,60"] : []),
|
|
129
|
+
],
|
|
130
|
+
// External capture records the REAL screen and needs real window
|
|
131
|
+
// geometry, so it runs UN-emulated (viewport: null — under emulation
|
|
132
|
+
// innerWidth/screen.width/devicePixelRatio all report fake values,
|
|
133
|
+
// which mis-scaled the crop to the whole screen on Retina displays,
|
|
134
|
+
// issue #13). measureViewportGeometry() below sizes the actual window
|
|
135
|
+
// to the storyboard viewport instead. The built-in recorder keeps the
|
|
136
|
+
// emulated viewport and 2x scale for crisper output.
|
|
137
|
+
...(external
|
|
138
|
+
? { viewport: null }
|
|
139
|
+
: {
|
|
140
|
+
viewport: { width, height },
|
|
141
|
+
deviceScaleFactor: 2,
|
|
142
|
+
recordVideo: {
|
|
143
|
+
dir: dirname(project.rawVideoPath),
|
|
144
|
+
size: { width, height },
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Failed to launch Chrome with profile ${profileDir}.\n` +
|
|
151
|
+
`If Chrome is already open on this profile, quit it first (Playwright ` +
|
|
152
|
+
`needs exclusive access to the profile).\nOriginal error: ${
|
|
153
|
+
(err as Error).message
|
|
154
|
+
}`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Cursor rendering. Default: BAKE the animated cursor into every frame before
|
|
159
|
+
// any page script runs. When the storyboard opts into compose-time cursor
|
|
160
|
+
// control (a `cursor` block), skip the bake and record a clean, cursor-free
|
|
161
|
+
// take instead — the player logs the cursor path and compose draws it as an
|
|
162
|
+
// overlay, so hide/resize becomes a recompose rather than a re-record.
|
|
163
|
+
const composeCursor = !!storyboard.cursor;
|
|
164
|
+
if (!composeCursor) {
|
|
165
|
+
await context.addInitScript({ content: cursorInitScript() });
|
|
166
|
+
} else {
|
|
167
|
+
log("cursor: compose-time overlay (recording cursor-free take + path)");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
171
|
+
|
|
172
|
+
// Start from a clean blank so the recording's lead-in isn't the new-tab page.
|
|
173
|
+
await page.goto("about:blank");
|
|
174
|
+
|
|
175
|
+
let capture: CaptureProvider | null = null;
|
|
176
|
+
let geo: ViewportGeometry | null = null;
|
|
177
|
+
if (external) {
|
|
178
|
+
await page.bringToFront();
|
|
179
|
+
// Size the real window to the storyboard viewport and measure where it
|
|
180
|
+
// sits on screen (CDP window bounds + un-emulated page metrics, re-read
|
|
181
|
+
// until stable). Throws BEFORE any screen pixels are captured when the
|
|
182
|
+
// geometry can't be made safe to crop (see capture.ts, issue #13).
|
|
183
|
+
geo = await measureViewportGeometry(page, { width, height });
|
|
184
|
+
capture =
|
|
185
|
+
mode === "native"
|
|
186
|
+
? new AvfoundationCapture(screenCapPath, captureDevice(), FPS)
|
|
187
|
+
: new ObsCapture(obsUrl(), obsPassword());
|
|
188
|
+
log(`starting ${capture.name}`);
|
|
189
|
+
await capture.start();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const t0 = Date.now();
|
|
193
|
+
const logsDir = project.p("logs");
|
|
194
|
+
const doneScenes: TimelineScene[] = [];
|
|
195
|
+
let timeline: Timeline;
|
|
196
|
+
let capFile: string | null = null;
|
|
197
|
+
let tailMs = 0;
|
|
198
|
+
let recordErr: unknown = null;
|
|
199
|
+
try {
|
|
200
|
+
timeline = await runStoryboard(page, storyboard, {
|
|
201
|
+
t0,
|
|
202
|
+
video: { width, height },
|
|
203
|
+
logsDir,
|
|
204
|
+
signal: options.signal,
|
|
205
|
+
probe: options.probe,
|
|
206
|
+
captureCursorPath: composeCursor,
|
|
207
|
+
onSceneStart: options.onSceneStart,
|
|
208
|
+
onSceneComplete: (s, i, total) => {
|
|
209
|
+
doneScenes.push(s);
|
|
210
|
+
options.onSceneComplete?.(s, i, total);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
if (capture) {
|
|
214
|
+
// Stop immediately so the tail past the last scene stays small. Measure
|
|
215
|
+
// the tail at stop-initiation — the recorder stops taking frames at the
|
|
216
|
+
// stop signal, not when it finishes flushing the file.
|
|
217
|
+
const stopAt = Date.now();
|
|
218
|
+
capFile = await capture.stop();
|
|
219
|
+
tailMs = Math.max(0, stopAt - (t0 + timeline.totalMs));
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
// Salvage: a failure in scene 7 of 7 (a phantom click, a platform
|
|
223
|
+
// interruption) otherwise discards minutes of good recording. Keep the
|
|
224
|
+
// 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.
|
|
226
|
+
recordErr = err;
|
|
227
|
+
const lastEnd = doneScenes.length ? doneScenes[doneScenes.length - 1].endMs : 0;
|
|
228
|
+
timeline = { totalMs: lastEnd, leadInMs: 0, scenes: doneScenes };
|
|
229
|
+
if (capture && !capFile) {
|
|
230
|
+
const stopAt = Date.now();
|
|
231
|
+
capFile = await capture.stop().catch(() => null);
|
|
232
|
+
tailMs = Math.max(0, stopAt - (t0 + timeline.totalMs));
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
if (capture && !capFile) await capture.stop().catch(() => {});
|
|
236
|
+
// For built-in capture, closing the context finalizes the video file.
|
|
237
|
+
await context.close();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let finalized = false;
|
|
241
|
+
try {
|
|
242
|
+
if (external) {
|
|
243
|
+
await fs.rm(project.rawVideoPath, { force: true });
|
|
244
|
+
await cropCaptureToViewport(
|
|
245
|
+
capFile!,
|
|
246
|
+
project.rawVideoMp4Path,
|
|
247
|
+
geo!,
|
|
248
|
+
width,
|
|
249
|
+
height,
|
|
250
|
+
FPS
|
|
251
|
+
);
|
|
252
|
+
await fs.rm(capFile!, { force: true });
|
|
253
|
+
const videoMs = await probeDurationMs(project.rawVideoMp4Path);
|
|
254
|
+
// Capture started before t0 and stopped ~tailMs after the last scene.
|
|
255
|
+
timeline.leadInMs = Math.max(0, videoMs - tailMs - timeline.totalMs);
|
|
256
|
+
await writeJson(project.timelinePath, timeline);
|
|
257
|
+
log(
|
|
258
|
+
`video ${videoMs}ms, content ${timeline.totalMs}ms, tail ${tailMs}ms → lead-in ${timeline.leadInMs}ms`
|
|
259
|
+
);
|
|
260
|
+
ok(`recording → ${project.rawVideoMp4Path}`);
|
|
261
|
+
finalized = true;
|
|
262
|
+
} else {
|
|
263
|
+
// saveAs() is unreliable for persistent contexts (needs the closed
|
|
264
|
+
// connection), so locate the finalized .webm on disk and move it. A demo may
|
|
265
|
+
// open a second tab (e.g. a checkout hand-off to an external site), which Playwright
|
|
266
|
+
// records as its own short .webm — so pick the LARGEST file (the full main-page
|
|
267
|
+
// recording), not just the first one, then drop the strays. The preserved
|
|
268
|
+
// previous take (raw.prev.webm) is not a candidate.
|
|
269
|
+
const produced = (await fs.readdir(videoDir)).filter(
|
|
270
|
+
(f) => f.endsWith(".webm") && !f.includes(".prev")
|
|
271
|
+
);
|
|
272
|
+
if (produced.length === 0) {
|
|
273
|
+
throw new Error(`No video produced in ${videoDir}`);
|
|
274
|
+
}
|
|
275
|
+
let mainVideo = produced[0];
|
|
276
|
+
let mainSize = -1;
|
|
277
|
+
for (const f of produced) {
|
|
278
|
+
const { size } = await fs.stat(join(videoDir, f));
|
|
279
|
+
if (size > mainSize) {
|
|
280
|
+
mainSize = size;
|
|
281
|
+
mainVideo = f;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const f of produced) {
|
|
285
|
+
if (f !== mainVideo) await fs.rm(join(videoDir, f), { force: true });
|
|
286
|
+
}
|
|
287
|
+
await fs.rename(join(videoDir, mainVideo), project.rawVideoPath);
|
|
288
|
+
|
|
289
|
+
// The front lead-in (about:blank + launch) = actual video length minus the
|
|
290
|
+
// measured content span. This is more reliable than timing the launch.
|
|
291
|
+
const videoMs = await probeDurationMs(project.rawVideoPath);
|
|
292
|
+
timeline.leadInMs = Math.max(0, videoMs - timeline.totalMs);
|
|
293
|
+
await writeJson(project.timelinePath, timeline);
|
|
294
|
+
log(
|
|
295
|
+
`video ${videoMs}ms, content ${timeline.totalMs}ms → lead-in ${timeline.leadInMs}ms`
|
|
296
|
+
);
|
|
297
|
+
ok(`recording → ${project.rawVideoPath}`);
|
|
298
|
+
finalized = true;
|
|
299
|
+
}
|
|
300
|
+
} catch (finalizeErr) {
|
|
301
|
+
// A partial take may not finalize cleanly (e.g. a truncated webm); tolerate
|
|
302
|
+
// it when we're already salvaging, but surface a real finalize failure.
|
|
303
|
+
if (!recordErr) throw finalizeErr;
|
|
304
|
+
log(`salvage: could not finalize recording (${(finalizeErr as Error).message})`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (finalized) {
|
|
308
|
+
ok(
|
|
309
|
+
`timeline → ${project.timelinePath} (${timeline.scenes.length} scenes, ${timeline.totalMs}ms)`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (recordErr) {
|
|
313
|
+
log(
|
|
314
|
+
`salvaged ${doneScenes.length}/${storyboard.scenes.length} scene(s) before failure; ` +
|
|
315
|
+
`see logs/ for the screenshot + frame dump, then re-run`
|
|
316
|
+
);
|
|
317
|
+
throw recordErr;
|
|
318
|
+
}
|
|
319
|
+
return timeline;
|
|
320
|
+
}
|