reelson 0.7.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +15 -0
  4. package/README.md +180 -0
  5. package/bin/reelson.js +649 -0
  6. package/bin/reelson.ts +676 -0
  7. package/bin/run.js +5 -0
  8. package/demo.config.example.json +51 -0
  9. package/docs/demo.webp +0 -0
  10. package/docs/prompting.md +72 -0
  11. package/docs/style-guide.md +117 -0
  12. package/examples/demo.config.json +18 -0
  13. package/examples/todo-add-item/scenario.ts +45 -0
  14. package/examples/todo-add-item/video.json +35 -0
  15. package/music/README.md +7 -0
  16. package/package.json +68 -0
  17. package/skills/reelson-compose/SKILL.md +226 -0
  18. package/skills/reelson-compose/schemas/video.schema.json +172 -0
  19. package/skills/reelson-compose/scripts/build.js +424 -0
  20. package/skills/reelson-compose/scripts/build.ts +532 -0
  21. package/skills/reelson-compose/scripts/captions.js +24 -0
  22. package/skills/reelson-compose/scripts/captions.ts +40 -0
  23. package/skills/reelson-compose/scripts/check.js +80 -0
  24. package/skills/reelson-compose/scripts/check.ts +93 -0
  25. package/skills/reelson-compose/scripts/composition.js +163 -0
  26. package/skills/reelson-compose/scripts/composition.ts +241 -0
  27. package/skills/reelson-compose/scripts/hyperframes.js +138 -0
  28. package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
  29. package/skills/reelson-compose/scripts/portrait.js +150 -0
  30. package/skills/reelson-compose/scripts/portrait.ts +218 -0
  31. package/skills/reelson-compose/scripts/project.js +238 -0
  32. package/skills/reelson-compose/scripts/project.ts +324 -0
  33. package/skills/reelson-compose/scripts/studio.js +330 -0
  34. package/skills/reelson-compose/scripts/studio.ts +394 -0
  35. package/skills/reelson-compose/scripts/timeline.js +271 -0
  36. package/skills/reelson-compose/scripts/timeline.ts +514 -0
  37. package/skills/reelson-compose/scripts/tts.js +303 -0
  38. package/skills/reelson-compose/scripts/tts.ts +371 -0
  39. package/skills/reelson-compose/scripts/verify.js +190 -0
  40. package/skills/reelson-compose/scripts/verify.ts +209 -0
  41. package/skills/reelson-compose/scripts/voice.js +178 -0
  42. package/skills/reelson-compose/scripts/voice.ts +224 -0
  43. package/skills/reelson-compose/scripts/zooms.js +259 -0
  44. package/skills/reelson-compose/scripts/zooms.ts +333 -0
  45. package/skills/reelson-compose/sections/README.md +5 -0
  46. package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
  47. package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
  48. package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
  49. package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
  50. package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
  51. package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
  52. package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
  53. package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
  54. package/skills/reelson-compose/sections/intro/split/section.css +30 -0
  55. package/skills/reelson-compose/sections/intro/split/section.html +12 -0
  56. package/skills/reelson-compose/sections/intro/split/section.js +19 -0
  57. package/skills/reelson-compose/sections/intro/split/section.json +9 -0
  58. package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
  59. package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
  60. package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
  61. package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
  62. package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
  63. package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
  64. package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
  65. package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
  66. package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
  67. package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
  68. package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
  69. package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
  70. package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
  71. package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
  72. package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
  73. package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
  74. package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
  75. package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
  76. package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
  77. package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
  78. package/skills/reelson-compose/studio/index.html +81 -0
  79. package/skills/reelson-compose/studio/studio.css +142 -0
  80. package/skills/reelson-compose/studio/studio.js +1031 -0
  81. package/skills/reelson-compose/templates/README.md +193 -0
  82. package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
  83. package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
  84. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
  85. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
  86. package/skills/reelson-compose/templates/classic/stage.html +447 -0
  87. package/skills/reelson-compose/templates/classic/template.json +5 -0
  88. package/skills/reelson-record/SKILL.md +220 -0
  89. package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
  90. package/skills/reelson-record/scripts/capture.js +142 -0
  91. package/skills/reelson-record/scripts/capture.ts +190 -0
  92. package/skills/reelson-record/scripts/config.js +148 -0
  93. package/skills/reelson-record/scripts/config.ts +264 -0
  94. package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
  95. package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
  96. package/skills/reelson-record/scripts/doctor.js +128 -0
  97. package/skills/reelson-record/scripts/doctor.ts +147 -0
  98. package/skills/reelson-record/scripts/languages.js +29 -0
  99. package/skills/reelson-record/scripts/languages.ts +41 -0
  100. package/skills/reelson-record/scripts/record.js +365 -0
  101. package/skills/reelson-record/scripts/record.ts +451 -0
  102. package/skills/reelson-record/scripts/scenario.js +356 -0
  103. package/skills/reelson-record/scripts/scenario.ts +570 -0
  104. package/skills/reelson-record/scripts/validate.js +143 -0
  105. package/skills/reelson-record/scripts/validate.ts +179 -0
@@ -0,0 +1,365 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { spawnSync } from 'node:child_process';
10
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+ import { dirname, resolve } from 'node:path';
13
+ import { pathToFileURL } from 'node:url';
14
+ import { encodeFrames, frameSchedule, startScreencast } from "./capture.js";
15
+ import { COMMON_DEV_CHROME, ConfigError, loadConfig } from "./config.js";
16
+ import { CURSOR_BINDING, cursorOverlayScript, hideDevChromeScript } from "./cursor-overlay.js";
17
+ import { createDemo } from "./scenario.js";
18
+ const args = process.argv.slice(2);
19
+ const scenarioArg = args.find((a) => !a.startsWith('--'));
20
+ if (!scenarioArg) {
21
+ console.error('usage: record.ts <scenario.ts> [--out <dir>] [--headed]');
22
+ process.exit(2);
23
+ }
24
+ const scenarioPath = resolve(scenarioArg);
25
+ const outDir = resolve(flag('--out') ?? dirname(scenarioPath));
26
+ const headed = args.includes('--headed');
27
+ // --mobile: the same scenario on a phone (record.mobile.device), next to the desktop take —
28
+ // recording.mobile.mp4 + markers.mobile.json, for the portrait video.
29
+ const mobile = args.includes('--mobile');
30
+ // --square: the same scenario in a square browser (record.square.viewport) —
31
+ // recording.square.mp4 + markers.square.json, for the square video.
32
+ const square = !mobile && args.includes('--square');
33
+ const suffix = mobile ? '.mobile' : square ? '.square' : '';
34
+ function flag(name) {
35
+ const i = args.indexOf(name);
36
+ return i === -1 ? undefined : args[i + 1];
37
+ }
38
+ const config = (() => {
39
+ try {
40
+ return loadConfig(dirname(scenarioPath));
41
+ }
42
+ catch (error) {
43
+ console.error(error instanceof ConfigError ? `reelson: ${error.message}` : error);
44
+ process.exit(1);
45
+ }
46
+ })();
47
+ // The project's own Playwright when the scenario can see one: its helpers (a login from the
48
+ // e2e suite) import it too, and Playwright refuses to be loaded twice from two places.
49
+ const { chromium, devices } = await import(__rewriteRelativeImportExtension(playwrightFor(scenarioPath)));
50
+ const scenario = (await import(__rewriteRelativeImportExtension(pathToFileURL(scenarioPath).href)))
51
+ .default;
52
+ const device = mobile ? devices[config.record.mobile.device] : undefined;
53
+ if (mobile && !device) {
54
+ console.error(`reelson: record.mobile.device "${config.record.mobile.device}" is not a Playwright device (e.g. "Pixel 7", "iPhone 14")`);
55
+ process.exit(1);
56
+ }
57
+ const viewport = device ? device.viewport : square ? config.record.square.viewport : (scenario.viewport ?? config.record.viewport);
58
+ const captureScale = device ? device.deviceScaleFactor : (scenario.deviceScaleFactor ?? config.record.deviceScaleFactor);
59
+ const rawDir = resolve(outDir, `.raw${suffix}`);
60
+ const screencast = config.record.capture === 'screencast';
61
+ mkdirSync(outDir, { recursive: true });
62
+ rmSync(rawDir, { recursive: true, force: true });
63
+ // Chrome's screencast (what Playwright films) only delivers device pixels when the
64
+ // scale factor is forced at launch; the context's deviceScaleFactor alone films
65
+ // CSS pixels padded into the bigger canvas.
66
+ const browser = await chromium.launch({
67
+ headless: !headed,
68
+ args: [`--force-device-scale-factor=${captureScale}`],
69
+ });
70
+ const context = await browser.newContext({
71
+ // A phone: its user agent, touch and mobile layout (viewport + scale are set below).
72
+ ...(device ? { userAgent: device.userAgent, isMobile: device.isMobile, hasTouch: device.hasTouch } : {}),
73
+ baseURL: scenario.baseURL,
74
+ ignoreHTTPSErrors: true,
75
+ locale: config.locale,
76
+ viewport,
77
+ deviceScaleFactor: captureScale,
78
+ // Film at device pixels (2x by default) so text survives the frame scaling and zooms.
79
+ ...(screencast
80
+ ? {}
81
+ : {
82
+ recordVideo: {
83
+ dir: rawDir,
84
+ size: { width: viewport.width * captureScale, height: viewport.height * captureScale },
85
+ },
86
+ }),
87
+ // Tells the app it is being recorded (X-Demo-Recording by default), so it can skip
88
+ // dev conveniences that would look wrong on camera (pre-filled forms, pre-ticked consents).
89
+ extraHTTPHeaders: config.record.extraHTTPHeaders,
90
+ });
91
+ // The page reports every cursor move and press (its own clock = this machine's clock);
92
+ // with record.cursor "layer" (the default) it draws nothing and the video draws the cursor.
93
+ // Every page gets an id: the first is 0, pop-ups and new tabs count up. Frames and cursor
94
+ // events carry it, so the video (and its cursor) show only the page the demo is on.
95
+ const pageIds = new Map();
96
+ const idOf = (p) => {
97
+ if (!pageIds.has(p))
98
+ pageIds.set(p, pageIds.size);
99
+ return pageIds.get(p);
100
+ };
101
+ const cursorEvents = [];
102
+ await context.exposeBinding(CURSOR_BINDING, (source, event) => {
103
+ cursorEvents.push({ ...event, page: source.page ? idOf(source.page) : 0 });
104
+ });
105
+ await context.addInitScript(cursorOverlayScript(config.brand.color, { draw: config.record.cursor === 'recorded', report: true }));
106
+ // Local-only chrome that must never appear in a docs video.
107
+ const hideScript = hideDevChromeScript([
108
+ ...COMMON_DEV_CHROME,
109
+ ...config.record.hideSelectors,
110
+ ]);
111
+ if (hideScript) {
112
+ await context.addInitScript(hideScript);
113
+ }
114
+ const page = await context.newPage();
115
+ idOf(page);
116
+ const filming = screencast ? [await startScreencast(context, page, rawDir, 0)] : [];
117
+ // Pop-ups and new tabs: filmed from the moment they open; the video shows them once the
118
+ // scenario moves there (demo.popup / demo.switchTo).
119
+ const opened = [];
120
+ const pendingCasts = [];
121
+ context.on('page', (p) => {
122
+ if (p === page)
123
+ return;
124
+ const id = idOf(p);
125
+ opened.push({ id, url: p.url() });
126
+ p.once('load', () => {
127
+ const entry = opened.find((o) => o.id === id);
128
+ if (entry)
129
+ entry.url = p.url();
130
+ });
131
+ if (screencast) {
132
+ pendingCasts.push(startScreencast(context, p, rawDir, id).then((cast) => void filming.push(cast)).catch(() => { }));
133
+ }
134
+ });
135
+ const camera = [];
136
+ const demo = createDemo(page, hashSeed(scenario.name), { domain: config.record.personaDomain, language: config.language }, { onSwitch: (p) => camera.push({ t: Date.now(), page: idOf(p) }), mobile, square });
137
+ camera.push({ t: demo.startedAt, page: 0 });
138
+ let failure = null;
139
+ try {
140
+ // Start with the cursor resting mid-screen (not at 0,0), even before the first goto.
141
+ await demo.rest();
142
+ await demo.pause(scenario.leadInMs ?? 800);
143
+ await scenario.run(demo);
144
+ await demo.pause(scenario.leadOutMs ?? 1200);
145
+ }
146
+ catch (error) {
147
+ failure = error;
148
+ console.error('scenario failed:', error);
149
+ }
150
+ const endedAt = Date.now();
151
+ await Promise.all(pendingCasts);
152
+ const frames = (await Promise.all(filming.map((cast) => cast.stop()))).flat();
153
+ if (!screencast && camera.some((c) => c.page !== 0)) {
154
+ console.warn('warning: record.capture "playwright" films only the first page — the pop-up/new tab is not in the video; use "screencast"');
155
+ }
156
+ const unfilmed = opened.filter((o) => !camera.some((c) => c.page === o.id));
157
+ for (const o of unfilmed) {
158
+ console.warn(`warning: a new tab / pop-up opened (${o.url || 'about:blank'}) and was not filmed — ` +
159
+ 'open it with `await demo.popup(() => demo.click(link))` to show it in the video');
160
+ }
161
+ const videoPath = screencast ? undefined : await page.video()?.path();
162
+ await context.close();
163
+ await browser.close();
164
+ const mp4 = resolve(outDir, `recording${suffix}.mp4`);
165
+ if (screencast) {
166
+ // Frames painted after the scenario ended are not part of it.
167
+ const schedule = frameSchedule(frames.filter((f) => f.t <= endedAt), demo.startedAt, endedAt, failure ? [] : demo.cuts, camera);
168
+ const target = failure ? resolve(outDir, `recording${suffix}.failed.mp4`) : mp4;
169
+ if (!(await encodeFrames(schedule, target))) {
170
+ console.error(schedule.length ? 'ffmpeg could not encode the frames (is ffmpeg installed? brew install ffmpeg)' : 'no frames were captured');
171
+ process.exit(1);
172
+ }
173
+ rmSync(rawDir, { recursive: true, force: true });
174
+ if (failure) {
175
+ console.error(`partial capture kept for debugging: ${target}`);
176
+ process.exit(1);
177
+ }
178
+ }
179
+ else {
180
+ transcodePlaywrightVideo();
181
+ }
182
+ function transcodePlaywrightVideo() {
183
+ const webm = resolve(outDir, failure ? `recording${suffix}.failed.webm` : `recording${suffix}.webm`);
184
+ const produced = videoPath ?? resolve(rawDir, readdirSync(rawDir)[0] ?? '');
185
+ if (!produced || !existsSync(produced)) {
186
+ console.error('no video was produced');
187
+ process.exit(1);
188
+ }
189
+ renameSync(produced, webm);
190
+ rmSync(rawDir, { recursive: true, force: true });
191
+ if (failure) {
192
+ console.error(`partial capture kept for debugging: ${webm}`);
193
+ process.exit(1);
194
+ }
195
+ // Playwright captures VP8/WebM at 25 fps; HyperFrames wants a plain H.264 MP4.
196
+ // Ranges collected by demo.cut() are removed here (trim + concat), so the
197
+ // published recording and markers.json are already tidy.
198
+ const keep = keptRanges(demo.cuts);
199
+ const videoFilter = keep.length > 1
200
+ ? keep
201
+ .map((r, i) => `[0:v]trim=${r.from}:${r.to},setpts=PTS-STARTPTS[v${i}]`)
202
+ .join(';') +
203
+ ';' +
204
+ keep.map((_, i) => `[v${i}]`).join('') +
205
+ `concat=n=${keep.length}:v=1:a=0,scale=trunc(iw/2)*2:trunc(ih/2)*2[v]`
206
+ : null;
207
+ const ffmpeg = spawnSync('ffmpeg', [
208
+ '-y',
209
+ '-loglevel',
210
+ 'error',
211
+ '-i',
212
+ webm,
213
+ ...(videoFilter
214
+ ? ['-filter_complex', videoFilter, '-map', '[v]']
215
+ : ['-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2']),
216
+ '-c:v',
217
+ 'libx264',
218
+ '-preset',
219
+ 'slow',
220
+ '-crf',
221
+ '15',
222
+ '-pix_fmt',
223
+ 'yuv420p',
224
+ '-r',
225
+ '30',
226
+ // A keyframe every second: HyperFrames seeks frame-by-frame and warns (and can
227
+ // freeze frames) on sparse keyframes.
228
+ '-g',
229
+ '30',
230
+ '-keyint_min',
231
+ '30',
232
+ '-movflags',
233
+ '+faststart',
234
+ '-an',
235
+ mp4,
236
+ ], { stdio: 'inherit' });
237
+ if (ffmpeg.status !== 0) {
238
+ console.error('ffmpeg transcode failed (is ffmpeg installed? brew install ffmpeg)');
239
+ process.exit(1);
240
+ }
241
+ }
242
+ /**
243
+ * Seconds between the page handling an input event and the frame that shows it in the
244
+ * capture (measured on the example, see the reelson-record SKILL.md).
245
+ */
246
+ const CAPTURE_LATENCY = screencast ? 0 : 0.04;
247
+ /**
248
+ * The cursor as the video saw it: [t, x, y] per move and per press (video seconds after
249
+ * cuts, CSS px). `drawn`: whether it is already in the footage (record.cursor "recorded").
250
+ */
251
+ function cursorLog(events, startedAt, cuts, drawn) {
252
+ const toVideo = (e) => [
253
+ Number(toCutTime((e.t - startedAt) / 1000 + CAPTURE_LATENCY, cuts, 3).toFixed(3)),
254
+ Number(e.x.toFixed(1)),
255
+ Number(e.y.toFixed(1)),
256
+ ];
257
+ // Moves inside a cut collapse onto its start: keep only the last one per instant, so
258
+ // the cursor jumps once to where it is after the cut.
259
+ const kept = events.filter((e) => e.t >= startedAt).sort((a, b) => a.t - b.t);
260
+ const path = [];
261
+ for (const point of kept.filter((e) => e.type === 'move').map(toVideo)) {
262
+ if (path.length && path[path.length - 1][0] === point[0]) {
263
+ path[path.length - 1] = point;
264
+ }
265
+ else {
266
+ path.push(point);
267
+ }
268
+ }
269
+ const inCut = (e) => {
270
+ const t = (e.t - startedAt) / 1000;
271
+ return cuts.some((c) => t > c.from && t < c.to);
272
+ };
273
+ const presses = kept.filter((e) => e.type === 'down' && !inCut(e)).map(toVideo);
274
+ return { drawn, path, presses };
275
+ }
276
+ /** The cursor events of the page the video showed at that moment. */
277
+ function onCamera(events, switches) {
278
+ const sorted = [...switches].sort((a, b) => a.t - b.t);
279
+ const pageAt = (t) => sorted.filter((s) => s.t <= t).at(-1)?.page ?? 0;
280
+ return events.filter((e) => e.page === pageAt(e.t));
281
+ }
282
+ /** Complement of the cut ranges over [0, ∞): what stays in the video. */
283
+ function keptRanges(cuts) {
284
+ const sorted = [...cuts].sort((a, b) => a.from - b.from);
285
+ const ranges = [];
286
+ let cursor = 0;
287
+ for (const c of sorted) {
288
+ if (c.from > cursor) {
289
+ ranges.push({ from: cursor, to: c.from });
290
+ }
291
+ cursor = Math.max(cursor, c.to);
292
+ }
293
+ ranges.push({ from: cursor, to: 1e9 });
294
+ return ranges;
295
+ }
296
+ /** Recording time → time in the cut video. Times inside a cut collapse to its start. */
297
+ function toCutTime(t, cuts, digits = 2) {
298
+ let removed = 0;
299
+ for (const c of [...cuts].sort((a, b) => a.from - b.from)) {
300
+ if (t >= c.to) {
301
+ removed += c.to - c.from;
302
+ }
303
+ else if (t > c.from) {
304
+ removed += t - c.from;
305
+ }
306
+ }
307
+ return Number((t - removed).toFixed(digits));
308
+ }
309
+ const probe = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', mp4], { encoding: 'utf8' });
310
+ const durationSeconds = Number.parseFloat(probe.stdout.trim()) || demo.elapsedSeconds();
311
+ writeFileSync(resolve(outDir, `markers${suffix}.json`), JSON.stringify({
312
+ scenario: scenario.name,
313
+ baseURL: scenario.baseURL,
314
+ viewport,
315
+ durationSeconds,
316
+ cuts: demo.cuts,
317
+ transitions: demo.transitions.map((t) => ({
318
+ ...t,
319
+ at: toCutTime(t.at, demo.cuts),
320
+ })),
321
+ clicks: demo.clicks
322
+ .filter((c) => !demo.cuts.some((cut) => c.at > cut.from && c.at < cut.to))
323
+ .map((c) => ({
324
+ ...c,
325
+ at: toCutTime(c.at, demo.cuts),
326
+ move: toCutTime(c.move, demo.cuts),
327
+ ...(c.until !== undefined
328
+ ? { until: toCutTime(c.until, demo.cuts) }
329
+ : {}),
330
+ })),
331
+ markers: demo.markers.map((m) => ({
332
+ ...m,
333
+ at: toCutTime(m.at, demo.cuts),
334
+ })),
335
+ focus: demo.focus
336
+ .filter((f) => !demo.cuts.some((cut) => f.at > cut.from && f.at < cut.to))
337
+ .map((f) => ({ ...f, at: toCutTime(f.at, demo.cuts) })),
338
+ cursor: cursorLog(onCamera(cursorEvents, camera), demo.startedAt, demo.cuts, config.record.cursor === 'recorded'),
339
+ }, null, 2)
340
+ // One [t, x, y] per line instead of one number per line.
341
+ .replace(/\[\s+(-?[\d.]+),\s+(-?[\d.]+),\s+(-?[\d.]+)\s+\]/g, '[$1, $2, $3]') + '\n');
342
+ console.log(`recorded ${scenario.name}: ${mp4} (${durationSeconds.toFixed(1)}s, ${demo.markers.length} markers)`);
343
+ /**
344
+ * The @playwright/test the project uses — seen from the scenario, else from the working
345
+ * directory (the project root) — or reelson's own when the project has none.
346
+ */
347
+ function playwrightFor(scenarioFile) {
348
+ for (const from of [scenarioFile, resolve(process.cwd(), 'package.json')]) {
349
+ try {
350
+ const pkg = createRequire(from).resolve('@playwright/test/package.json');
351
+ return pathToFileURL(resolve(dirname(pkg), 'index.mjs')).href;
352
+ }
353
+ catch {
354
+ // not installed there
355
+ }
356
+ }
357
+ return '@playwright/test';
358
+ }
359
+ function hashSeed(text) {
360
+ let h = 2166136261;
361
+ for (const ch of text) {
362
+ h = Math.imul(h ^ ch.charCodeAt(0), 16777619);
363
+ }
364
+ return h >>> 0;
365
+ }