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,220 @@
1
+ ---
2
+ name: reelson-record
3
+ description: Use when the user wants a screen recording / walkthrough video of a feature of their web app — "record a demo of X", "make a video showing how to Y", "screen capture the Z flow", "docs video". Drives the app with Playwright via `reelson record`, draws a visible human-paced cursor, hides dev chrome, and writes recording.mp4 + markers.json into <videosDir>/<slug>/. Pair with the reelson-compose skill (HyperFrames) for the intro/outro/callouts and the final MP4. Also covers the manual OpenScreen path.
4
+ ---
5
+
6
+ # reelson-record
7
+
8
+ Produces the raw footage for a demo video: a scripted, repeatable browser walkthrough of
9
+ the app, captured as video, with timestamps for every step you want to call out later.
10
+
11
+ **Read `docs/style-guide.md` in the kit first** (next to this skill's real path: `../../docs/`
12
+ — resolve the symlink with `realpath` if the skill is linked into a project). Its rules are
13
+ the house style every video follows; the scripts implement them, so don't work around them
14
+ with raw Playwright calls.
15
+
16
+ ```
17
+ <videosDir>/<slug>/
18
+ ├── scenario.ts ← you write this (committed)
19
+ ├── recording.mp4 ← generated: H.264, 30 fps, 2x viewport, no audio (git-ignored)
20
+ └── markers.json ← generated: duration, viewport, markers, clicks, cuts (committed)
21
+ ```
22
+
23
+ `recording.mp4` + `markers.json` are the input contract of the `reelson-compose` skill. Everything
24
+ runs through the `reelson` CLI (`reelson help`); if it is missing, install it: `npm install -g reelson && reelson install` (or the kit checkout's `install.sh`).
25
+
26
+ ## Project config
27
+
28
+ Everything project-specific lives in `demo.config.json` at the project root (found by
29
+ walking up from the scenario). Read it before writing a scenario — it tells you the videos
30
+ directory, the UI language (callouts and personas follow it), the brand, and what to hide.
31
+ If it is missing, run `reelson init` and ask the user for the brand name/colour and UI
32
+ language. It is validated against `schemas/demo.config.schema.json` — a mistyped key is an
33
+ error with a "did you mean" hint. Fields (all optional, see `scripts/config.ts`):
34
+
35
+ | Field | Used for |
36
+ |--------------------------------|-------------------------------------------------------------------|
37
+ | `videosDir` | where `<slug>/` folders live (default `docs/videos`) |
38
+ | `language`, `locale` | persona names, browser locale, `<html lang>`, and the built-in recap title + chip plurals (en, ro, de, fr, es, it, pt, nl, pl, ru, uk, cs, sv, da, nb, fi, hu, tr; `strings` overrides) |
39
+ | `brand.color` | click-ring colour (and the cards in reelson-compose) |
40
+ | `record.viewport` | default 1440x900; keep 16:10 or 16:9 |
41
+ | `record.hideSelectors` | extra local-only UI to hide (e.g. an app's env badge); Laravel Debugbar, Filament developer logins, Symfony/Django toolbars, rack-mini-profiler, Vite/Next/Nuxt/Astro dev UI are always hidden |
42
+ | `record.extraHTTPHeaders` | default `X-Demo-Recording: 1`, so the app can skip dev prefills |
43
+ | `record.personaDomain` | email domain for `demo.persona()` (must resolve if a gateway checks) |
44
+
45
+ Common dev overlays (Laravel Debugbar, Vite/Next.js/webpack error overlays) are always hidden.
46
+
47
+ ## What the recorder adds on top of plain Playwright video
48
+
49
+ - **Smooth footage**: the page is filmed with Chrome's screencast — every frame it paints
50
+ (up to 60 fps, full 2x pixels, stamped on the same clock as the markers and the cursor) —
51
+ and assembled into a constant 30 fps recording.mp4, so scrolls, hovers and transitions don't
52
+ stutter. `record.capture: "playwright"` falls back to Playwright's 25 fps video.
53
+ - **Visible cursor**: a large black macOS-style arrow with a press squash and a brand-coloured
54
+ double ring on click (Playwright's synthetic mouse is otherwise invisible). By default
55
+ (`record.cursor: "layer"`) the page only *logs* it: every move and press goes into
56
+ markers.json `cursor` (video seconds, ~40 ms capture latency already added), and the video
57
+ draws it — smooth, the same size under zooms, and restyled (size, ripple, brand colour)
58
+ without re-recording. `"recorded"` films it into recording.mp4 instead (the pre-0.4 way).
59
+ Clicks land where the glide ended (no snap to the element's centre).
60
+ - **Human pacing**: curved, eased glides with a faint tremor, landing slightly off-centre;
61
+ typing with uneven delays and beats after spaces/punctuation. Seeded per scenario, so
62
+ re-records are identical. Never slow.
63
+ - **Cursor starts mid-screen**: parked at a random point in the middle third before the first
64
+ action and before every `demo.goto()`, so the video never opens with a jump from a corner.
65
+ Raw `page.click()`/`fill()` (e.g. a login helper) move the mouse without telling `demo`; the
66
+ next `demo.goto()` re-syncs, so keep such raw steps before a goto.
67
+ - **Markers**: `demo.marker('label')` stamps the video time; `reelson-compose` turns each into a
68
+ pre-timed callout and a row in the recap.
69
+ - **Cuts**: `demo.cut(fn)` removes slow waits (3-D Secure, spinners, queued jobs) from the
70
+ video and shifts the markers.
71
+ - **Personas**: `demo.persona()` gives a believable customer in the UI's language (name,
72
+ unique email, phone, company) — never `test@` or `e2e-123` strings on camera.
73
+ - **2x capture + MP4 transcode** (ffmpeg, CRF 15, yuv420p, 30 fps, keyframe every second).
74
+
75
+ ## Prerequisites
76
+
77
+ - The app running and reachable at the scenario's `baseURL`.
78
+ - reelson installed once per machine (`npm install -g reelson`, then `reelson install` in the
79
+ project: Playwright's Chromium and the skill links; a kit checkout's `install.sh` does both).
80
+ - `ffmpeg` on PATH (`brew install ffmpeg`). Node 22.18+ (TypeScript runs directly).
81
+ - `reelson doctor` checks the tools and films a local test page to confirm the cursor layer
82
+ lines up with the footage on this machine (offset and px error; run it on a new machine).
83
+
84
+ ## Workflow
85
+
86
+ 1. **Pick the origin, panel and account.** Look for existing e2e helpers in the project
87
+ (login, table/modal locators) and reuse them from the scenario — a scenario is mostly an
88
+ e2e test with a cursor. Check the project's CLAUDE.md / e2e docs for seeded accounts.
89
+
90
+ 2. **Write `<videosDir>/<slug>/scenario.ts`.** `reelson new <slug> --url <origin>` creates a
91
+ stub with the right type import; the kit's `examples/todo-add-item/scenario.ts` is a full
92
+ example. Rules:
93
+ - `demo.marker('...')` right **after** the UI reaches each state worth a callout: it ends
94
+ that step, and its callout is shown from the step's first glide/click (after the previous
95
+ marker) until just past the marker — so it names what the viewer is watching. Labels
96
+ become the callouts in video.json (and its `marker` keys) — write them in the UI language
97
+ as imperative steps, and keep them stable: video.json refers to them by label.
98
+ **At most 10 markers** (the recap holds ten); fold small steps together.
99
+ - **Don't pause for zooms.** Zooms ride along with the cursor's glide; `demo.click` /
100
+ `demo.type` log every glide and click into `markers.json` (`clicks`), and a zoom in
101
+ video.json is anchored to click numbers and timed from them.
102
+ - Hold ~2–3 s after the last marker so its callout can be read.
103
+ - Nothing pre-filled on camera: if the app pre-fills forms or ticks consents locally,
104
+ gate that on the `X-Demo-Recording` header in the app rather than working around it.
105
+ Tick checkboxes with `demo.click` on camera.
106
+ - Keep it 10–40 s of footage, one feature per video. Login is recorded and trimmed away
107
+ later (video.json `trim.start`, suggested automatically).
108
+ - Use `demo.click` / `demo.type` / `demo.moveTo` / `demo.scroll`, not raw `page.click` or
109
+ `fill()` (those teleport the cursor and paste text). Raw `demo.page` is for waits, reads
110
+ and `selectOption` (glide there with `demo.moveTo` first).
111
+ - Data hygiene: read real values from the page instead of hard-coding seeded names; type
112
+ `demo.persona()` data into forms.
113
+ - Cookie banners mount after load: `waitFor({ timeout: 3000 })` then `demo.click` it.
114
+ - Wrap slow waits in `demo.cut(() => page.waitForURL(...), { keepMs: 900 })`.
115
+ - External pages (payment gateways, OAuth) are fair game; use their test modes and cut
116
+ the waits.
117
+
118
+ 3. **Record.**
119
+
120
+ ```bash
121
+ reelson record <slug>
122
+ reelson record <slug> --headed # watch it
123
+ ```
124
+
125
+ Prints the duration and marker count. On a scenario error the partial capture is kept as
126
+ `recording.failed.mp4`. Flows that create records do so for real — point scenarios at a
127
+ local environment, never staging/prod.
128
+
129
+ 4. **Verify before handing off.** Pull frames at the marker times and look at them:
130
+
131
+ ```bash
132
+ ffmpeg -y -loglevel error -ss <marker at> -i <videosDir>/<slug>/recording.mp4 -frames:v 1 /tmp/m1.png
133
+ ```
134
+
135
+ Check: the intended state is on screen, no dev toolbars/env badges/dev-login buttons,
136
+ nothing personal (real emails, tokens), cursor where the callout will point. New dev UI
137
+ on screen → add its selector to `record.hideSelectors`.
138
+
139
+ 5. **Compose** with the `reelson-compose` skill (`reelson build <slug> --title "..."`).
140
+
141
+ ## Scenario API (`scripts/scenario.ts`)
142
+
143
+ ```ts
144
+ export default {
145
+ name: 'slug',
146
+ baseURL: 'https://app.test',
147
+ viewport: { width: 1440, height: 900 }, // default from config; keep 16:10 or 16:9
148
+ leadInMs: 800, leadOutMs: 1200, // silence at both ends
149
+ async run(demo) {
150
+ demo.page // Playwright Page for anything else
151
+ demo.marker(label) // stamp current video time
152
+ demo.persona() // { firstName, lastName, fullName, email, phone, company }
153
+ demo.cut(fn, { keepMs }) // run fn; drop that stretch from the video
154
+ demo.transition(fn, { title, subtitle, from, to }) // actor hand-off, see below
155
+ demo.pause(ms)
156
+ demo.goto(path) // waits for networkidle + settle
157
+ demo.moveTo(loc) // glide cursor to the element (scrolls into view)
158
+ demo.click(loc, { settleMs })
159
+ demo.type(loc, text)
160
+ demo.scroll(deltaY, { stepPx })
161
+ demo.popup(() => demo.click(link)) // continue in the tab/pop-up it opens (returned)
162
+ demo.switchTo(page) // continue on another page of the context
163
+ },
164
+ } satisfies Scenario
165
+ ```
166
+
167
+ **Two actors (e.g. manager → employee)**: wrap the account switch in `demo.transition()`.
168
+ Everything inside `fn` (clear cookies, log in as the other account, `demo.goto` their first
169
+ page) is cut, and `markers.json` gets a `transitions` entry; reelson-compose splits the footage
170
+ there and shows a hand-off card (`from` → `to`, title, subtitle). Leave ~3 s after the last
171
+ marker before it.
172
+
173
+ **Phone take (for the portrait video)**: `reelson record <slug> --mobile` runs the same
174
+ scenario on a phone — the Playwright device `record.mobile.device` (default "Pixel 7": its
175
+ viewport, touch, mobile user agent) — into `recording.mobile.mp4` + `markers.mobile.json`
176
+ (commit the markers like the desktop ones). `render --portrait` then shows it whole in a phone
177
+ frame, with the taps as ripples: real mobile layout, nothing cropped. Use the same marker labels
178
+ so video.json's callouts fit both takes; branch on `demo.mobile` where the mobile UI differs
179
+ (`if (demo.mobile) await demo.click(page.getByRole('button', { name: 'Menu' }))`). The page
180
+ needs `<meta name="viewport" content="width=device-width">` — without it a phone lays it out
181
+ at desktop width, tiny.
182
+
183
+ **Square take (for the square video)**: `reelson record <slug> --square` runs the same scenario
184
+ in a square browser (`record.square.viewport`, default 1080x1080 — shown 1:1 on the square
185
+ stage) into `recording.square.mp4` + `markers.square.json` (commit the markers). `render --square`
186
+ fills the whole 1080x1080 frame with it. Branch on `demo.square` where the narrower window
187
+ changes the UI; keep the same clicks where you can, so video.json's zooms (anchored by click
188
+ number) carry over. Go below 1080 (for bigger text) only if the app keeps its desktop layout at
189
+ that width — many collapse their sidebar under ~1024 px, which loses the menu steps.
190
+
191
+ `reelson record <slug> --all-takes` records the desktop, phone and square takes one after the
192
+ other; `reelson verify` re-records and checks every take a demo has.
193
+
194
+ **Pop-ups and new tabs** (target="_blank", OAuth, a PDF preview): `const tab = await
195
+ demo.popup(() => demo.click(link))`. From then on `demo.page` and every demo action use the new
196
+ page, and the video cuts to it; when it closes (`window.close()`, `tab.close()`) the demo and
197
+ the video return to the page that opened it. A page opened any other way is filmed but never
198
+ shown — the recorder warns about it. (`record.capture: "playwright"` films the first page
199
+ only.)
200
+
201
+ Uploading a file on camera: generate it (e.g. a PDF via
202
+ `browser.newPage().pdf()`) and answer the `filechooser` event.
203
+
204
+ ## Alternative: OpenScreen (manual recordings)
205
+
206
+ [OpenScreen](https://github.com/getopenscreen/openscreen) is a free desktop recorder with
207
+ auto-zoom, cursor smoothing and on-device captions. Use it when a human narrates, or the flow
208
+ can't be scripted (native dialogs, hardware keys).
209
+
210
+ ```bash
211
+ OS=/Applications/Openscreen.app/Contents/MacOS/Openscreen
212
+ $OS sources --json
213
+ $OS record --window "Chrome" --duration 30 --project <videosDir>/<slug>/demo.openscreen --json
214
+ $OS export <videosDir>/<slug>/demo.openscreen -o <videosDir>/<slug>/recording.mp4 --auto-zoom --json
215
+ ```
216
+
217
+ Then `reelson build <slug>` as usual — without `markers.json` it probes the file; give the
218
+ callouts an `at` (recording seconds) in video.json. Zooms need manual `at`/`x`/`y` (no clicks
219
+ are logged). **Don't combine OpenScreen with the Playwright recorder**: its
220
+ effects follow the OS cursor, which Playwright never moves.
@@ -0,0 +1,269 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "reelson project config (demo.config.json)",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "properties": {
7
+ "videosDir": {
8
+ "type": "string",
9
+ "description": "Where <slug>/ folders live, relative to the project root. Default docs/videos."
10
+ },
11
+ "language": {
12
+ "type": "string",
13
+ "description": "BCP 47 language of the product UI (en, ro, de-DE…). Drives <html lang>, plural rules and persona names."
14
+ },
15
+ "locale": {
16
+ "type": "string",
17
+ "description": "Browser locale while recording (Accept-Language, Intl formatting). Default en-US."
18
+ },
19
+ "brand": {
20
+ "type": "object",
21
+ "additionalProperties": false,
22
+ "properties": {
23
+ "name": {
24
+ "type": "string",
25
+ "description": "Wordmark on the cover and the closing card."
26
+ },
27
+ "tagline": {
28
+ "type": "string",
29
+ "description": "Small spaced line under the wordmark. Empty hides it."
30
+ },
31
+ "eyebrow": {
32
+ "type": "string",
33
+ "description": "Label above the recap title."
34
+ },
35
+ "color": {
36
+ "type": "string",
37
+ "description": "Primary accent: numbers, lines, click ring. Any CSS colour."
38
+ },
39
+ "colorSoft": {
40
+ "type": "string",
41
+ "description": "Lighter accent for gradients."
42
+ },
43
+ "logo": {
44
+ "type": [
45
+ "string",
46
+ "null"
47
+ ],
48
+ "description": "Logo image (.svg, .png or .webp; relative to the project root) drawn instead of the text wordmark. SVG stays sharpest; a PNG should be ≥ 340 px tall. null = text wordmark."
49
+ }
50
+ }
51
+ },
52
+ "template": {
53
+ "type": "string",
54
+ "description": "Template (the stage: background, frame, callouts) folder name: skills/reelson-compose/templates/<name> or <videosDir>/_templates/<name>. Default classic."
55
+ },
56
+ "sections": {
57
+ "type": "object",
58
+ "additionalProperties": false,
59
+ "description": "Section per slot, over the template's defaults (classic: poster / steps / wordmark). Run `reelson templates` for the list.",
60
+ "properties": {
61
+ "intro": {
62
+ "type": "string",
63
+ "description": "Opening section; frame 0 is the poster. Kit: poster, minimal, split."
64
+ },
65
+ "recap": {
66
+ "type": "string",
67
+ "description": "Steps card after the recording, or \"none\". Kit: steps, compact, none."
68
+ },
69
+ "outro": {
70
+ "type": "string",
71
+ "description": "Closing brand card. Kit: wordmark, compact, endcard."
72
+ }
73
+ }
74
+ },
75
+ "strings": {
76
+ "type": "object",
77
+ "additionalProperties": false,
78
+ "properties": {
79
+ "recapTitle": {
80
+ "type": "string",
81
+ "description": "Title of the recap card."
82
+ },
83
+ "stepsLabel": {
84
+ "$ref": "#/definitions/label",
85
+ "description": "Cover chip noun after the step count: a string, or plural forms (see definitions.plural)."
86
+ },
87
+ "secondsLabel": {
88
+ "$ref": "#/definitions/label",
89
+ "description": "Cover chip noun after the length in seconds; same forms as stepsLabel."
90
+ }
91
+ }
92
+ },
93
+ "voice": {
94
+ "type": "object",
95
+ "additionalProperties": false,
96
+ "description": "Voice-over, for videos with \"voice\": true in video.json: each callout (its `say`, else its text) spoken by a text-to-speech provider. Lines are trimmed and cached in <demo>/voice/, so each is made once.",
97
+ "properties": {
98
+ "provider": { "enum": ["openai", "elevenlabs", "piper", "command"], "description": "openai (default; OPENAI_API_KEY — or a local OpenAI-compatible server via baseURL), elevenlabs (ELEVENLABS_API_KEY), piper (a local neural voice: pipx install piper-tts; the voice downloads on first use), command (any local program). Keys can live in a .env next to demo.config.json." },
99
+ "model": { "type": "string", "description": "The provider's model. Defaults: openai gpt-4o-mini-tts, elevenlabs eleven_multilingual_v2 (eleven_flash_v2_5 is faster and cheaper, eleven_v3 the most expressive)." },
100
+ "voice": { "type": "string", "description": "The provider's voice. openai: alloy (default), ash, ballad, cedar, coral, echo, fable, marin, nova, onyx, sage, shimmer, verse. elevenlabs: a voice id or the name of a voice in your account (default George). piper: a voice name like ro_RO-mihai-medium (default: one for `language`)." },
101
+ "instructions": { "type": "string", "description": "How to speak: tone, pace, accent (gpt-4o-mini-tts; other providers ignore it)." },
102
+ "speed": { "type": "number", "minimum": 0.5, "maximum": 2, "description": "Speaking rate, 1 = normal. openai: tts-1 / tts-1-hd (for gpt-4o-mini-tts describe the pace in instructions); elevenlabs: 0.7–1.2; piper; command: {speed}." },
103
+ "baseURL": { "type": "string", "description": "openai: the API root of a local OpenAI-compatible server, e.g. http://localhost:8880/v1 (Kokoro-FastAPI). No key needed unless the server wants one." },
104
+ "command": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "command: the program and its arguments; {text} {out} {voice} {model} {speed} {language} are filled in and the text also comes on stdin. It must write the line to {out} (any audio ffmpeg reads)." },
105
+ "options": { "type": "object", "description": "Passed through: extra request fields (openai; elevenlabs, e.g. {\"voice_settings\": {\"stability\": 0.4}}) or --flags (piper, e.g. {\"sentence-silence\": 0.1})." },
106
+ "lufs": { "type": "number", "maximum": 0, "description": "Loudness of the voice track. Default -16." }
107
+ }
108
+ },
109
+ "music": {
110
+ "type": "object",
111
+ "additionalProperties": false,
112
+ "properties": {
113
+ "file": {
114
+ "type": [
115
+ "string",
116
+ "null"
117
+ ],
118
+ "description": "Music bed under every video, relative to the project root. null = no music."
119
+ },
120
+ "lufs": {
121
+ "type": "number",
122
+ "maximum": 0,
123
+ "description": "Bed loudness under silent footage. Default -28."
124
+ },
125
+ "lufsUnderNarration": {
126
+ "type": "number",
127
+ "maximum": 0,
128
+ "description": "Bed loudness under narration. Default -34."
129
+ }
130
+ }
131
+ },
132
+ "record": {
133
+ "type": "object",
134
+ "additionalProperties": false,
135
+ "properties": {
136
+ "viewport": {
137
+ "type": "object",
138
+ "additionalProperties": false,
139
+ "required": [
140
+ "width",
141
+ "height"
142
+ ],
143
+ "properties": {
144
+ "width": {
145
+ "type": "integer",
146
+ "minimum": 320
147
+ },
148
+ "height": {
149
+ "type": "integer",
150
+ "minimum": 240
151
+ }
152
+ }
153
+ },
154
+ "deviceScaleFactor": {
155
+ "type": "number",
156
+ "minimum": 1,
157
+ "maximum": 4
158
+ },
159
+ "extraHTTPHeaders": {
160
+ "type": "object",
161
+ "additionalProperties": {
162
+ "type": "string"
163
+ },
164
+ "description": "Sent with every request while recording. Default { \"X-Demo-Recording\": \"1\" }."
165
+ },
166
+ "cursor": {
167
+ "enum": [
168
+ "layer",
169
+ "recorded"
170
+ ],
171
+ "description": "layer (default): the cursor is logged, not filmed, and the video draws it — same size under zooms, restyled without re-recording. recorded: drawn into the page and filmed (the pre-0.4 behaviour)."
172
+ },
173
+ "mobile": {
174
+ "type": "object",
175
+ "additionalProperties": false,
176
+ "properties": {
177
+ "device": {
178
+ "type": "string",
179
+ "description": "Playwright device for `reelson record --mobile` (the phone take behind the portrait video), e.g. \"Pixel 7\" (default), \"iPhone 14\", \"Galaxy S9+\"."
180
+ }
181
+ }
182
+ },
183
+ "square": {
184
+ "type": "object",
185
+ "additionalProperties": false,
186
+ "properties": {
187
+ "viewport": {
188
+ "type": "object",
189
+ "additionalProperties": false,
190
+ "required": [
191
+ "width",
192
+ "height"
193
+ ],
194
+ "properties": {
195
+ "width": {
196
+ "type": "integer",
197
+ "minimum": 320
198
+ },
199
+ "height": {
200
+ "type": "integer",
201
+ "minimum": 320
202
+ }
203
+ },
204
+ "description": "Browser size (CSS px) for `reelson record --square` (the take behind the square video). Default 1080x1080: shown at 1:1 on the 1080x1080 stage. Keep it square; go smaller (e.g. 900x900) for bigger text if the app still shows its desktop layout at that width."
205
+ }
206
+ }
207
+ },
208
+ "capture": {
209
+ "enum": [
210
+ "screencast",
211
+ "playwright"
212
+ ],
213
+ "description": "screencast (default): every frame Chrome paints, up to 60 fps, assembled into a smooth 30 fps recording. playwright: Playwright's own 25 fps video (stutters one frame in six at 30 fps)."
214
+ },
215
+ "hideSelectors": {
216
+ "type": "array",
217
+ "items": {
218
+ "type": "string"
219
+ },
220
+ "description": "CSS selectors hidden on every recorded page (dev toolbars, env badges)."
221
+ },
222
+ "personaDomain": {
223
+ "type": "string",
224
+ "description": "Email domain for demo.persona()."
225
+ }
226
+ }
227
+ }
228
+ },
229
+ "definitions": {
230
+ "label": {
231
+ "anyOf": [
232
+ {
233
+ "type": "string"
234
+ },
235
+ {
236
+ "$ref": "#/definitions/plural"
237
+ }
238
+ ]
239
+ },
240
+ "plural": {
241
+ "type": "object",
242
+ "additionalProperties": false,
243
+ "required": [
244
+ "other"
245
+ ],
246
+ "properties": {
247
+ "zero": {
248
+ "type": "string"
249
+ },
250
+ "one": {
251
+ "type": "string"
252
+ },
253
+ "two": {
254
+ "type": "string"
255
+ },
256
+ "few": {
257
+ "type": "string"
258
+ },
259
+ "many": {
260
+ "type": "string"
261
+ },
262
+ "other": {
263
+ "type": "string"
264
+ }
265
+ },
266
+ "description": "Plural forms keyed by Intl.PluralRules category, e.g. { \"one\": \"step\", \"other\": \"steps\" }."
267
+ }
268
+ }
269
+ }
@@ -0,0 +1,142 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdirSync, readFileSync } from 'node:fs';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
5
+ /** Starts filming `page`; frames are written into `dir` as they arrive, tagged `pageId`. */
6
+ export async function startScreencast(context, page, dir, pageId = 0, quality = 92) {
7
+ mkdirSync(dir, { recursive: true });
8
+ const cdp = await context.newCDPSession(page);
9
+ const frames = [];
10
+ const writes = [];
11
+ cdp.on('Page.screencastFrame', (event) => {
12
+ // Ack first: Chrome sends the next frame only after the previous one is acknowledged.
13
+ cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId }).catch(() => { });
14
+ const file = resolve(dir, `${pageId ? `p${pageId}-` : ''}f${String(frames.length).padStart(6, '0')}.jpg`);
15
+ frames.push({ file, t: (event.metadata.timestamp ?? Date.now() / 1000) * 1000, page: pageId });
16
+ writes.push(writeFile(file, Buffer.from(event.data, 'base64')));
17
+ });
18
+ await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 });
19
+ return {
20
+ stop: async () => {
21
+ await cdp.send('Page.stopScreencast').catch(() => { });
22
+ await Promise.all(writes);
23
+ await cdp.detach().catch(() => { });
24
+ return frames;
25
+ },
26
+ };
27
+ }
28
+ /**
29
+ * How long each frame stays on screen in the finished video (seconds), in order. Video
30
+ * time 0 is `startedAt` (the recording clock of createDemo); a frame shows from the moment
31
+ * it was painted until the next one; the first frame also covers anything before it.
32
+ * `cuts` are ranges of that clock (seconds) left out of the video. With `switches`, only
33
+ * the page the camera is on counts at each moment (a pop-up, then back to its opener, which
34
+ * shows its last frame from before); without, every frame counts.
35
+ */
36
+ export function frameSchedule(frames, startedAt, endedAt, cuts, switches) {
37
+ const end = (endedAt - startedAt) / 1000;
38
+ const seconds = (t) => (t - startedAt) / 1000;
39
+ const camera = switches?.length
40
+ ? [...switches].sort((a, b) => a.t - b.t).map((s) => ({ at: Math.max(0, seconds(s.t)), page: s.page }))
41
+ : [{ at: 0, page: -1 }];
42
+ const pieces = camera.map((c, i) => ({ from: i === 0 ? 0 : c.at, to: i + 1 < camera.length ? camera[i + 1].at : end, page: c.page }));
43
+ const kept = keptRanges(cuts, end);
44
+ const out = [];
45
+ const add = (file, from, to) => {
46
+ for (const range of kept) {
47
+ const overlap = Math.min(to, range.to) - Math.max(from, range.from);
48
+ if (overlap <= 0) {
49
+ continue;
50
+ }
51
+ const last = out[out.length - 1];
52
+ if (last?.file === file) {
53
+ last.duration += overlap;
54
+ }
55
+ else {
56
+ out.push({ file, duration: overlap });
57
+ }
58
+ }
59
+ };
60
+ for (const piece of pieces.filter((p) => p.to > p.from)) {
61
+ const own = frames
62
+ .filter((f) => piece.page === -1 || (f.page ?? 0) === piece.page)
63
+ .sort((a, b) => a.t - b.t)
64
+ .map((f) => ({ file: f.file, t: seconds(f.t) }));
65
+ if (!own.length) {
66
+ continue;
67
+ }
68
+ // On screen at the start of the piece: the page's latest frame so far, else its first.
69
+ let i = Math.max(0, own.findLastIndex((f) => f.t <= piece.from));
70
+ for (; i < own.length && own[i].t < piece.to; i++) {
71
+ const from = Math.max(piece.from, own[i].t);
72
+ const to = Math.min(piece.to, i + 1 < own.length ? own[i + 1].t : piece.to);
73
+ if (to > from) {
74
+ add(own[i].file, i === 0 && own[i].t > piece.from ? piece.from : from, to);
75
+ }
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ /** [0, end] minus the cuts. */
81
+ function keptRanges(cuts, end) {
82
+ const ranges = [];
83
+ let at = 0;
84
+ for (const c of [...cuts].sort((a, b) => a.from - b.from)) {
85
+ if (c.from > at) {
86
+ ranges.push({ from: at, to: Math.min(c.from, end) });
87
+ }
88
+ at = Math.max(at, c.to);
89
+ }
90
+ if (at < end) {
91
+ ranges.push({ from: at, to: end });
92
+ }
93
+ return ranges.filter((r) => r.to > r.from);
94
+ }
95
+ /** The finished recording's frame rate. */
96
+ export const FPS = 30;
97
+ /**
98
+ * Encodes the schedule as the H.264 recording.mp4 HyperFrames consumes: output frame k
99
+ * (at k / FPS s) is the latest frame painted by then, piped straight into ffmpeg at a
100
+ * constant rate. (ffmpeg's concat demuxer would snap image timestamps to 25 fps.)
101
+ */
102
+ export async function encodeFrames(schedule, mp4) {
103
+ if (!schedule.length) {
104
+ return false;
105
+ }
106
+ const total = schedule.reduce((sum, e) => sum + e.duration, 0);
107
+ const count = Math.max(1, Math.round(total * FPS));
108
+ const ffmpeg = spawn('ffmpeg', [
109
+ '-y', '-loglevel', 'error',
110
+ '-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'mjpeg', '-i', '-',
111
+ '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
112
+ '-c:v', 'libx264', '-preset', 'slow', '-crf', '15', '-pix_fmt', 'yuv420p', '-r', String(FPS),
113
+ // A keyframe every second: HyperFrames seeks frame by frame.
114
+ '-g', String(FPS), '-keyint_min', String(FPS),
115
+ '-movflags', '+faststart', '-an', mp4,
116
+ ], { stdio: ['pipe', 'inherit', 'inherit'] });
117
+ const done = new Promise((resolveDone) => {
118
+ ffmpeg.on('error', () => resolveDone(false));
119
+ ffmpeg.on('close', (code) => resolveDone(code === 0));
120
+ });
121
+ ffmpeg.stdin.on('error', () => { }); // ffmpeg exiting early is reported by `done`
122
+ let index = 0;
123
+ let endOfCurrent = schedule[0].duration;
124
+ let loaded = -1;
125
+ let bytes = Buffer.alloc(0);
126
+ for (let k = 0; k < count; k++) {
127
+ const t = k / FPS;
128
+ while (index < schedule.length - 1 && t >= endOfCurrent - 1e-9) {
129
+ index++;
130
+ endOfCurrent += schedule[index].duration;
131
+ }
132
+ if (index !== loaded) {
133
+ bytes = readFileSync(schedule[index].file);
134
+ loaded = index;
135
+ }
136
+ if (!ffmpeg.stdin.write(bytes)) {
137
+ await new Promise((drained) => ffmpeg.stdin.once('drain', drained));
138
+ }
139
+ }
140
+ ffmpeg.stdin.end();
141
+ return done;
142
+ }