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