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.
- package/CHANGELOG.md +99 -0
- package/LICENSE +21 -0
- package/NOTICE.md +15 -0
- package/README.md +180 -0
- package/bin/reelson.js +649 -0
- package/bin/reelson.ts +676 -0
- package/bin/run.js +5 -0
- package/demo.config.example.json +51 -0
- package/docs/demo.webp +0 -0
- package/docs/prompting.md +72 -0
- package/docs/style-guide.md +117 -0
- package/examples/demo.config.json +18 -0
- package/examples/todo-add-item/scenario.ts +45 -0
- package/examples/todo-add-item/video.json +35 -0
- package/music/README.md +7 -0
- package/package.json +68 -0
- package/skills/reelson-compose/SKILL.md +226 -0
- package/skills/reelson-compose/schemas/video.schema.json +172 -0
- package/skills/reelson-compose/scripts/build.js +424 -0
- package/skills/reelson-compose/scripts/build.ts +532 -0
- package/skills/reelson-compose/scripts/captions.js +24 -0
- package/skills/reelson-compose/scripts/captions.ts +40 -0
- package/skills/reelson-compose/scripts/check.js +80 -0
- package/skills/reelson-compose/scripts/check.ts +93 -0
- package/skills/reelson-compose/scripts/composition.js +163 -0
- package/skills/reelson-compose/scripts/composition.ts +241 -0
- package/skills/reelson-compose/scripts/hyperframes.js +138 -0
- package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
- package/skills/reelson-compose/scripts/portrait.js +150 -0
- package/skills/reelson-compose/scripts/portrait.ts +218 -0
- package/skills/reelson-compose/scripts/project.js +238 -0
- package/skills/reelson-compose/scripts/project.ts +324 -0
- package/skills/reelson-compose/scripts/studio.js +330 -0
- package/skills/reelson-compose/scripts/studio.ts +394 -0
- package/skills/reelson-compose/scripts/timeline.js +271 -0
- package/skills/reelson-compose/scripts/timeline.ts +514 -0
- package/skills/reelson-compose/scripts/tts.js +303 -0
- package/skills/reelson-compose/scripts/tts.ts +371 -0
- package/skills/reelson-compose/scripts/verify.js +190 -0
- package/skills/reelson-compose/scripts/verify.ts +209 -0
- package/skills/reelson-compose/scripts/voice.js +178 -0
- package/skills/reelson-compose/scripts/voice.ts +224 -0
- package/skills/reelson-compose/scripts/zooms.js +259 -0
- package/skills/reelson-compose/scripts/zooms.ts +333 -0
- package/skills/reelson-compose/sections/README.md +5 -0
- package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
- package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
- package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
- package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
- package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
- package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
- package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
- package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
- package/skills/reelson-compose/sections/intro/split/section.css +30 -0
- package/skills/reelson-compose/sections/intro/split/section.html +12 -0
- package/skills/reelson-compose/sections/intro/split/section.js +19 -0
- package/skills/reelson-compose/sections/intro/split/section.json +9 -0
- package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
- package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
- package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
- package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
- package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
- package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
- package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
- package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
- package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
- package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
- package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
- package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
- package/skills/reelson-compose/studio/index.html +81 -0
- package/skills/reelson-compose/studio/studio.css +142 -0
- package/skills/reelson-compose/studio/studio.js +1031 -0
- package/skills/reelson-compose/templates/README.md +193 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/stage.html +447 -0
- package/skills/reelson-compose/templates/classic/template.json +5 -0
- package/skills/reelson-record/SKILL.md +220 -0
- package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
- package/skills/reelson-record/scripts/capture.js +142 -0
- package/skills/reelson-record/scripts/capture.ts +190 -0
- package/skills/reelson-record/scripts/config.js +148 -0
- package/skills/reelson-record/scripts/config.ts +264 -0
- package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
- package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
- package/skills/reelson-record/scripts/doctor.js +128 -0
- package/skills/reelson-record/scripts/doctor.ts +147 -0
- package/skills/reelson-record/scripts/languages.js +29 -0
- package/skills/reelson-record/scripts/languages.ts +41 -0
- package/skills/reelson-record/scripts/record.js +365 -0
- package/skills/reelson-record/scripts/record.ts +451 -0
- package/skills/reelson-record/scripts/scenario.js +356 -0
- package/skills/reelson-record/scripts/scenario.ts +570 -0
- package/skills/reelson-record/scripts/validate.js +143 -0
- 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
|
+
}
|