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,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API a demo scenario is written against. A scenario file lives next to
|
|
3
|
+
* its output (<videosDir>/<slug>/scenario.ts) and default-exports a Scenario.
|
|
4
|
+
*
|
|
5
|
+
* `Demo` wraps a Playwright Page with human-paced actions: the cursor glides
|
|
6
|
+
* to the target before every click, typing has a per-key delay, and
|
|
7
|
+
* `marker()` stamps the current video time so the composition can time its
|
|
8
|
+
* callouts. Anything not covered here is available on `demo.page`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Locator, Page } from '@playwright/test'
|
|
11
|
+
|
|
12
|
+
export interface Scenario {
|
|
13
|
+
/** Slug used in logs and markers.json. */
|
|
14
|
+
name: string
|
|
15
|
+
/** Origin the scenario runs against, e.g. https://app.test (a local app with seeded data). */
|
|
16
|
+
baseURL: string
|
|
17
|
+
/** Defaults to demo.config.json `record.viewport` (1440x900). Keep 16:10 or 16:9. */
|
|
18
|
+
viewport?: { width: number; height: number }
|
|
19
|
+
/**
|
|
20
|
+
* Capture pixel ratio (default 2): the page is laid out at `viewport` CSS
|
|
21
|
+
* pixels but filmed at 2x, so UI text stays sharp in the 1080p frame and
|
|
22
|
+
* under zooms (and a 4K render has real detail). Defaults to the config's.
|
|
23
|
+
*/
|
|
24
|
+
deviceScaleFactor?: number
|
|
25
|
+
/** Silence before the first action / after the last one (ms). */
|
|
26
|
+
leadInMs?: number
|
|
27
|
+
leadOutMs?: number
|
|
28
|
+
run: (demo: Demo) => Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Marker {
|
|
32
|
+
label: string
|
|
33
|
+
/** Seconds from the start of the recording. */
|
|
34
|
+
at: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where and when the cursor clicked (or clicked into a field to type), in
|
|
39
|
+
* viewport pixels. reelson-compose's check-zooms.ts uses these to keep zooms still
|
|
40
|
+
* and on target while the viewer watches a click.
|
|
41
|
+
*/
|
|
42
|
+
export interface Click {
|
|
43
|
+
at: number
|
|
44
|
+
x: number
|
|
45
|
+
y: number
|
|
46
|
+
kind: 'click' | 'type'
|
|
47
|
+
/** When the cursor started gliding towards this target (zooms ride along with it). */
|
|
48
|
+
move: number
|
|
49
|
+
/** The typing ends here (kind 'type' only). */
|
|
50
|
+
until?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What the demo was working on: the element it moved to (`box`) and the compact block
|
|
55
|
+
* around it (`area`: e.g. a form field with its label), viewport CSS px, from `at` (the
|
|
56
|
+
* glide towards it). A portrait video frames `area` so the element is never cut.
|
|
57
|
+
*/
|
|
58
|
+
export interface Focus {
|
|
59
|
+
at: number
|
|
60
|
+
box: { x: number; y: number; width: number; height: number }
|
|
61
|
+
area: { x: number; y: number; width: number; height: number }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A range of the raw capture removed from recording.mp4 (see Demo.cut). */
|
|
65
|
+
export interface Cut {
|
|
66
|
+
from: number
|
|
67
|
+
to: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A hand-off between two actors (manager → employee). The stretch in between
|
|
72
|
+
* (logout, second login) is cut from recording.mp4; the reelson-compose scaffold
|
|
73
|
+
* splits the footage at `at` and shows a transition card there.
|
|
74
|
+
*/
|
|
75
|
+
export interface Transition {
|
|
76
|
+
/** Seconds in the raw capture (record.ts rewrites it to cut-video time). */
|
|
77
|
+
at: number
|
|
78
|
+
/** Big line on the card, e.g. "The employee receives the document". */
|
|
79
|
+
title: string
|
|
80
|
+
/** Optional smaller line under it. */
|
|
81
|
+
subtitle?: string
|
|
82
|
+
/** Role chips on the card: from → to. */
|
|
83
|
+
from?: string
|
|
84
|
+
to?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A believable customer for forms. Email is unique per call (numeric suffix). */
|
|
88
|
+
export interface Persona {
|
|
89
|
+
firstName: string
|
|
90
|
+
lastName: string
|
|
91
|
+
fullName: string
|
|
92
|
+
email: string
|
|
93
|
+
phone: string
|
|
94
|
+
company: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type PersonaSeed = { firstName: string; lastName: string; company: string }
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Name pools per UI language (demo.config.json `language`). Add a language by
|
|
101
|
+
* adding a key; unknown languages fall back to English.
|
|
102
|
+
*/
|
|
103
|
+
const PERSONAS: Record<string, { phonePrefix: string; people: PersonaSeed[] }> = {
|
|
104
|
+
en: {
|
|
105
|
+
phonePrefix: '+1 555 ',
|
|
106
|
+
people: [
|
|
107
|
+
{ firstName: 'Emma', lastName: 'Carter', company: 'Carter Consulting' },
|
|
108
|
+
{ firstName: 'Liam', lastName: 'Bennett', company: 'Bennett Design Co.' },
|
|
109
|
+
{ firstName: 'Olivia', lastName: 'Hayes', company: 'Hayes Logistics' },
|
|
110
|
+
{ firstName: 'Noah', lastName: 'Foster', company: 'Foster Construction' },
|
|
111
|
+
{ firstName: 'Ava', lastName: 'Mitchell', company: 'Mitchell Health' },
|
|
112
|
+
{ firstName: 'Lucas', lastName: 'Reed', company: 'Reed Tech' },
|
|
113
|
+
{ firstName: 'Mia', lastName: 'Collins', company: 'Collins Events' },
|
|
114
|
+
{ firstName: 'Ethan', lastName: 'Brooks', company: 'Brooks Farms' },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
ro: {
|
|
118
|
+
phonePrefix: '07',
|
|
119
|
+
people: [
|
|
120
|
+
{ firstName: 'Andrei', lastName: 'Popescu', company: 'Popescu Consulting SRL' },
|
|
121
|
+
{ firstName: 'Ioana', lastName: 'Ionescu', company: 'Ionescu Design SRL' },
|
|
122
|
+
{ firstName: 'Mihai', lastName: 'Dumitrescu', company: 'Dumitrescu Construct SRL' },
|
|
123
|
+
{ firstName: 'Elena', lastName: 'Stan', company: 'Stan Logistic SRL' },
|
|
124
|
+
{ firstName: 'Alexandru', lastName: 'Radu', company: 'Radu Tech SRL' },
|
|
125
|
+
{ firstName: 'Maria', lastName: 'Constantin', company: 'Constantin Medical SRL' },
|
|
126
|
+
{ firstName: 'Cristian', lastName: 'Moldovan', company: 'Moldovan Agro SRL' },
|
|
127
|
+
{ firstName: 'Ana', lastName: 'Georgescu', company: 'Georgescu Events SRL' },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function persona(
|
|
133
|
+
seed: number = Date.now(),
|
|
134
|
+
domain = 'example.com',
|
|
135
|
+
language = 'en',
|
|
136
|
+
): Persona {
|
|
137
|
+
const pool = PERSONAS[language.split('-')[0]] ?? PERSONAS.en
|
|
138
|
+
const base = pool.people[seed % pool.people.length]
|
|
139
|
+
const strip = (v: string): string =>
|
|
140
|
+
v
|
|
141
|
+
.normalize('NFD')
|
|
142
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
143
|
+
.toLowerCase()
|
|
144
|
+
const suffix = (seed % 89) + 10
|
|
145
|
+
const digits = String(seed).padStart(8, '0').slice(-8)
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
...base,
|
|
149
|
+
fullName: `${base.firstName} ${base.lastName}`,
|
|
150
|
+
email: `${strip(base.firstName)}.${strip(base.lastName)}${suffix}@${domain}`,
|
|
151
|
+
phone:
|
|
152
|
+
pool.phonePrefix === '07'
|
|
153
|
+
? `07${digits}`
|
|
154
|
+
: `${pool.phonePrefix}${digits.slice(0, 3)} ${digits.slice(3, 7)}`,
|
|
155
|
+
company: base.company,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface Demo {
|
|
160
|
+
page: Page
|
|
161
|
+
markers: Marker[]
|
|
162
|
+
clicks: Click[]
|
|
163
|
+
/** Every element moveTo/click/type went to, with its surroundings (see Focus). */
|
|
164
|
+
focus: Focus[]
|
|
165
|
+
/**
|
|
166
|
+
* Realistic customer data for forms (see Persona), in the project's UI
|
|
167
|
+
* language. Domain defaults to demo.config.json `record.personaDomain`.
|
|
168
|
+
*/
|
|
169
|
+
persona: (seed?: number, domain?: string) => Persona
|
|
170
|
+
cuts: Cut[]
|
|
171
|
+
/**
|
|
172
|
+
* Everything that happens inside `fn` is removed from recording.mp4 (a
|
|
173
|
+
* hard cut), except the first `keepMs` so the viewer glimpses the waiting
|
|
174
|
+
* state. Use it around slow waits: 3-D Secure, long spinners, emails.
|
|
175
|
+
*/
|
|
176
|
+
cut: <T>(fn: () => Promise<T>, opts?: { keepMs?: number }) => Promise<T>
|
|
177
|
+
transitions: Transition[]
|
|
178
|
+
/**
|
|
179
|
+
* Switch actors off camera: `fn` (log out, log in as someone else, open the
|
|
180
|
+
* first page) is cut from the video entirely, and the composition shows a
|
|
181
|
+
* transition card at that point. End `fn` on a settled page.
|
|
182
|
+
*/
|
|
183
|
+
transition: <T>(
|
|
184
|
+
fn: () => Promise<T>,
|
|
185
|
+
card: Omit<Transition, 'at'>,
|
|
186
|
+
) => Promise<T>
|
|
187
|
+
elapsedSeconds: () => number
|
|
188
|
+
/** Date.now() at the start of the recording clock (elapsedSeconds() = 0). */
|
|
189
|
+
startedAt: number
|
|
190
|
+
/** Record the current time under a label (use for "callout here", "zoom here"). */
|
|
191
|
+
marker: (label: string) => void
|
|
192
|
+
pause: (ms: number) => Promise<void>
|
|
193
|
+
/**
|
|
194
|
+
* Navigate and wait for the page to settle. The cursor is parked at a
|
|
195
|
+
* resting point near the middle first (see rest), so a page that opens the
|
|
196
|
+
* video never starts with the cursor in a corner.
|
|
197
|
+
*/
|
|
198
|
+
goto: (path: string) => Promise<void>
|
|
199
|
+
/** Glide the cursor to a random resting point in the middle third of the viewport. */
|
|
200
|
+
rest: () => Promise<void>
|
|
201
|
+
/**
|
|
202
|
+
* Glide the cursor to the element on a curved, eased path. An element out of sight is
|
|
203
|
+
* scrolled into view first, smoothly (its container glides), so the viewer can follow.
|
|
204
|
+
*/
|
|
205
|
+
moveTo: (target: Locator) => Promise<void>
|
|
206
|
+
/** moveTo + click + short settle. */
|
|
207
|
+
click: (target: Locator, opts?: { settleMs?: number }) => Promise<void>
|
|
208
|
+
/** moveTo + click + human-paced typing (uneven delays, beats after spaces). */
|
|
209
|
+
type: (target: Locator, text: string) => Promise<void>
|
|
210
|
+
/** Slow wheel scroll so the viewer can follow. */
|
|
211
|
+
scroll: (deltaY: number, opts?: { stepPx?: number }) => Promise<void>
|
|
212
|
+
/**
|
|
213
|
+
* Runs `action` (e.g. a click on a target="_blank" link), waits for the tab or pop-up it
|
|
214
|
+
* opens, and continues there: `demo.page` and every demo action now use it, and the video
|
|
215
|
+
* films it. When it closes, the demo (and the video) return to the page that opened it.
|
|
216
|
+
*/
|
|
217
|
+
popup: (action: () => Promise<unknown>) => Promise<Page>
|
|
218
|
+
/** Continue on another page of the context (see popup); the video follows. */
|
|
219
|
+
switchTo: (next: Page) => Promise<void>
|
|
220
|
+
/**
|
|
221
|
+
* True when recording the phone version (`reelson record --mobile`): branch where the
|
|
222
|
+
* mobile UI differs, e.g. open the menu behind the hamburger button first.
|
|
223
|
+
*/
|
|
224
|
+
mobile: boolean
|
|
225
|
+
/**
|
|
226
|
+
* True when recording the square version (`reelson record --square`, a square browser):
|
|
227
|
+
* branch where the narrower window changes the UI.
|
|
228
|
+
*/
|
|
229
|
+
square: boolean
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** What the recorder tells the demo, and hears from it. */
|
|
233
|
+
export interface DemoHooks {
|
|
234
|
+
/** The page the demo acts on (and the video shows) changed. */
|
|
235
|
+
onSwitch?: (page: Page) => void
|
|
236
|
+
/** Recording on a phone (`reelson record --mobile`). */
|
|
237
|
+
mobile?: boolean
|
|
238
|
+
/** Recording in a square browser (`reelson record --square`). */
|
|
239
|
+
square?: boolean
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function createDemo(
|
|
243
|
+
first: Page,
|
|
244
|
+
seed: number = 1,
|
|
245
|
+
personaDefaults: { domain?: string; language?: string } = {},
|
|
246
|
+
hooks: DemoHooks = {},
|
|
247
|
+
): Demo {
|
|
248
|
+
// The page every action uses; demo.popup / demo.switchTo move it, a close moves it back.
|
|
249
|
+
let page = first
|
|
250
|
+
const openers: Page[] = []
|
|
251
|
+
const startedAt = Date.now()
|
|
252
|
+
const markers: Marker[] = []
|
|
253
|
+
const clicks: Click[] = []
|
|
254
|
+
const focus: Focus[] = []
|
|
255
|
+
const cuts: Cut[] = []
|
|
256
|
+
const transitions: Transition[] = []
|
|
257
|
+
const elapsedSeconds = (): number => (Date.now() - startedAt) / 1000
|
|
258
|
+
const stamp = (): number => Number(elapsedSeconds().toFixed(2))
|
|
259
|
+
|
|
260
|
+
// Deterministic per scenario, so a re-record moves and types the same way.
|
|
261
|
+
const rng = mulberry32(seed)
|
|
262
|
+
const viewport = page.viewportSize() ?? { width: 1440, height: 900 }
|
|
263
|
+
// A believable place for a hand to rest: somewhere in the middle third, never a corner.
|
|
264
|
+
const restingPoint = (): { x: number; y: number } => ({
|
|
265
|
+
x: Math.round(viewport.width * (0.38 + rng() * 0.24)),
|
|
266
|
+
y: Math.round(viewport.height * (0.38 + rng() * 0.24)),
|
|
267
|
+
})
|
|
268
|
+
let pos = restingPoint()
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Human-like glide: a gently curved path (quadratic bezier with a random
|
|
272
|
+
* perpendicular bow), ease-in-out speed, a little tremor that dies out as
|
|
273
|
+
* the cursor settles, and a landing point slightly off the exact centre.
|
|
274
|
+
*/
|
|
275
|
+
const glide = async (to: { x: number; y: number }): Promise<void> => {
|
|
276
|
+
const from = pos
|
|
277
|
+
const dx = to.x - from.x
|
|
278
|
+
const dy = to.y - from.y
|
|
279
|
+
const dist = Math.hypot(dx, dy)
|
|
280
|
+
if (dist < 2) {
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
const bow = (0.1 + rng() * 0.18) * (rng() < 0.5 ? -1 : 1)
|
|
284
|
+
const cx = from.x + dx / 2 - dy * bow
|
|
285
|
+
const cy = from.y + dy / 2 + dx * bow
|
|
286
|
+
const steps = Math.round(Math.min(42, Math.max(14, dist / 16)))
|
|
287
|
+
const durationMs = Math.min(720, Math.max(260, 200 + dist * 0.55))
|
|
288
|
+
for (let i = 1; i <= steps; i++) {
|
|
289
|
+
const u = i / steps
|
|
290
|
+
const e = u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2
|
|
291
|
+
const tremor = (1 - e) * 1.2
|
|
292
|
+
const x =
|
|
293
|
+
(1 - e) * (1 - e) * from.x +
|
|
294
|
+
2 * (1 - e) * e * cx +
|
|
295
|
+
e * e * to.x +
|
|
296
|
+
(rng() - 0.5) * tremor
|
|
297
|
+
const y =
|
|
298
|
+
(1 - e) * (1 - e) * from.y +
|
|
299
|
+
2 * (1 - e) * e * cy +
|
|
300
|
+
e * e * to.y +
|
|
301
|
+
(rng() - 0.5) * tremor
|
|
302
|
+
await page.mouse.move(x, y)
|
|
303
|
+
await page.waitForTimeout(durationMs / steps)
|
|
304
|
+
}
|
|
305
|
+
pos = to
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const rest: Demo['rest'] = async () => {
|
|
309
|
+
await glide(restingPoint())
|
|
310
|
+
await page.mouse.move(pos.x, pos.y)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const moveTo: Demo['moveTo'] = async (target) => {
|
|
314
|
+
if (await revealSmoothly(target.first())) {
|
|
315
|
+
// Let the viewer see where the scroll landed before the cursor sets off.
|
|
316
|
+
await page.waitForTimeout(250)
|
|
317
|
+
}
|
|
318
|
+
await target.first().scrollIntoViewIfNeeded()
|
|
319
|
+
const box = await target.first().boundingBox()
|
|
320
|
+
if (!box) {
|
|
321
|
+
throw new Error(`moveTo: target has no bounding box: ${target}`)
|
|
322
|
+
}
|
|
323
|
+
focus.push({ at: stamp(), box: rounded(box), area: rounded(await areaAround(target.first(), box)) })
|
|
324
|
+
// Land near, not exactly on, the centre — people don't hit the middle.
|
|
325
|
+
const jx = (rng() - 0.5) * Math.min(24, box.width * 0.3)
|
|
326
|
+
const jy = (rng() - 0.5) * Math.min(10, box.height * 0.3)
|
|
327
|
+
await glide({
|
|
328
|
+
x: box.x + box.width / 2 + jx,
|
|
329
|
+
y: box.y + box.height / 2 + jy,
|
|
330
|
+
})
|
|
331
|
+
await page.waitForTimeout(120 + rng() * 120)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* An element out of sight is scrolled into view the way a person would (its scrolling
|
|
336
|
+
* container glides, eased, over 0.5–1.4 s by distance), not in one jump — so the viewer
|
|
337
|
+
* can follow a menu or a list moving. Returns whether it scrolled.
|
|
338
|
+
*/
|
|
339
|
+
const revealSmoothly = async (el: Locator): Promise<boolean> =>
|
|
340
|
+
el.evaluate(async (node) => {
|
|
341
|
+
const r = node.getBoundingClientRect()
|
|
342
|
+
const x = Math.min(Math.max(r.left + r.width / 2, 0), innerWidth - 1)
|
|
343
|
+
const y = Math.min(Math.max(r.top + r.height / 2, 0), innerHeight - 1)
|
|
344
|
+
const hit = document.elementFromPoint(x, y)
|
|
345
|
+
const inView = r.top >= 0 && r.bottom <= innerHeight && !!hit && (node.contains(hit) || hit.contains(node))
|
|
346
|
+
if (inView) {
|
|
347
|
+
return false
|
|
348
|
+
}
|
|
349
|
+
// The nearest ancestor that scrolls vertically, else the page.
|
|
350
|
+
let box: Element | null = node.parentElement
|
|
351
|
+
while (box && !(/(auto|scroll)/.test(getComputedStyle(box).overflowY) && box.scrollHeight > box.clientHeight)) {
|
|
352
|
+
box = box.parentElement
|
|
353
|
+
}
|
|
354
|
+
const scroller = (box ?? document.scrollingElement ?? document.documentElement) as HTMLElement
|
|
355
|
+
const isPage = !box
|
|
356
|
+
const view = isPage ? { top: 0, height: innerHeight } : { top: scroller.getBoundingClientRect().top, height: scroller.clientHeight }
|
|
357
|
+
const start = scroller.scrollTop
|
|
358
|
+
const max = scroller.scrollHeight - scroller.clientHeight
|
|
359
|
+
const target = Math.min(Math.max(start + (r.top + r.height / 2) - (view.top + view.height / 2), 0), max)
|
|
360
|
+
const distance = target - start
|
|
361
|
+
if (Math.abs(distance) < 2) {
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
364
|
+
const duration = Math.min(1400, Math.max(500, 400 + Math.abs(distance) * 0.8))
|
|
365
|
+
const began = performance.now()
|
|
366
|
+
await new Promise<void>((done) => {
|
|
367
|
+
const step = (now: number) => {
|
|
368
|
+
const t = Math.min(1, (now - began) / duration)
|
|
369
|
+
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2
|
|
370
|
+
scroller.scrollTop = start + distance * eased
|
|
371
|
+
if (t < 1) requestAnimationFrame(step)
|
|
372
|
+
else done()
|
|
373
|
+
}
|
|
374
|
+
requestAnimationFrame(step)
|
|
375
|
+
})
|
|
376
|
+
return true
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Clicks where the cursor landed, not at the element's exact centre (Playwright's
|
|
381
|
+
* default would snap the cursor there). If the element moved since the glide, the
|
|
382
|
+
* point is kept inside it.
|
|
383
|
+
*/
|
|
384
|
+
const clickWhereLanded = async (target: Locator): Promise<void> => {
|
|
385
|
+
const el = target.first()
|
|
386
|
+
const box = await el.boundingBox()
|
|
387
|
+
if (!box) {
|
|
388
|
+
throw new Error(`click: target has no bounding box: ${target}`)
|
|
389
|
+
}
|
|
390
|
+
const inside = (v: number, size: number): number => Math.min(Math.max(v, Math.min(2, size / 2)), size - Math.min(2, size / 2))
|
|
391
|
+
const position = { x: inside(pos.x - box.x, box.width), y: inside(pos.y - box.y, box.height) }
|
|
392
|
+
pos = { x: box.x + position.x, y: box.y + position.y }
|
|
393
|
+
await el.click({ position })
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Human-like typing: uneven per-key delay, a beat after spaces and
|
|
398
|
+
* punctuation, the odd longer hesitation — but never slow overall.
|
|
399
|
+
*/
|
|
400
|
+
const typeNaturally = async (text: string): Promise<void> => {
|
|
401
|
+
for (const ch of text) {
|
|
402
|
+
await page.keyboard.type(ch)
|
|
403
|
+
let delay = 38 + rng() * 60
|
|
404
|
+
if (ch === ' ') {
|
|
405
|
+
delay += 50 + rng() * 80
|
|
406
|
+
} else if ('.,@-_'.includes(ch)) {
|
|
407
|
+
delay += 60 + rng() * 60
|
|
408
|
+
}
|
|
409
|
+
if (rng() < 0.05) {
|
|
410
|
+
delay += 120 + rng() * 160
|
|
411
|
+
}
|
|
412
|
+
await page.waitForTimeout(delay)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const switchTo: Demo['switchTo'] = async (next) => {
|
|
417
|
+
if (next === page) {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
openers.push(page)
|
|
421
|
+
page = next
|
|
422
|
+
await next.waitForLoadState('domcontentloaded').catch(() => {})
|
|
423
|
+
await next.bringToFront().catch(() => {})
|
|
424
|
+
// The new page's mouse starts where the cursor is, so the video shows no jump.
|
|
425
|
+
await next.mouse.move(pos.x, pos.y)
|
|
426
|
+
hooks.onSwitch?.(next)
|
|
427
|
+
next.once('close', () => {
|
|
428
|
+
if (page === next) {
|
|
429
|
+
page = openers.pop() ?? first
|
|
430
|
+
hooks.onSwitch?.(page)
|
|
431
|
+
}
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
get page() {
|
|
437
|
+
return page
|
|
438
|
+
},
|
|
439
|
+
markers,
|
|
440
|
+
clicks,
|
|
441
|
+
focus,
|
|
442
|
+
persona: (personaSeed, domain) =>
|
|
443
|
+
persona(
|
|
444
|
+
personaSeed,
|
|
445
|
+
domain ?? personaDefaults.domain,
|
|
446
|
+
personaDefaults.language,
|
|
447
|
+
),
|
|
448
|
+
cuts,
|
|
449
|
+
cut: async (fn, opts = {}) => {
|
|
450
|
+
const from = elapsedSeconds() + (opts.keepMs ?? 600) / 1000
|
|
451
|
+
const result = await fn()
|
|
452
|
+
const to = elapsedSeconds()
|
|
453
|
+
if (to > from) {
|
|
454
|
+
cuts.push({
|
|
455
|
+
from: Number(from.toFixed(2)),
|
|
456
|
+
to: Number(to.toFixed(2)),
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return result
|
|
461
|
+
},
|
|
462
|
+
transitions,
|
|
463
|
+
transition: async (fn, card) => {
|
|
464
|
+
const from = elapsedSeconds()
|
|
465
|
+
const result = await fn()
|
|
466
|
+
// Re-assert the cursor on the new page so the first kept frame has it.
|
|
467
|
+
await page.mouse.move(pos.x, pos.y)
|
|
468
|
+
await page.waitForTimeout(300)
|
|
469
|
+
const to = elapsedSeconds()
|
|
470
|
+
cuts.push({
|
|
471
|
+
from: Number(from.toFixed(2)),
|
|
472
|
+
to: Number(to.toFixed(2)),
|
|
473
|
+
})
|
|
474
|
+
transitions.push({ ...card, at: Number(from.toFixed(2)) })
|
|
475
|
+
|
|
476
|
+
return result
|
|
477
|
+
},
|
|
478
|
+
elapsedSeconds,
|
|
479
|
+
startedAt,
|
|
480
|
+
marker: (label) => {
|
|
481
|
+
markers.push({ label, at: Number(elapsedSeconds().toFixed(2)) })
|
|
482
|
+
},
|
|
483
|
+
pause: (ms) => page.waitForTimeout(ms),
|
|
484
|
+
goto: async (path) => {
|
|
485
|
+
// Park the cursor mid-screen BEFORE leaving: the overlay carries the position
|
|
486
|
+
// into the next page, and the trimmed video usually starts right after this.
|
|
487
|
+
await rest()
|
|
488
|
+
await page.goto(path, { waitUntil: 'networkidle' })
|
|
489
|
+
// Re-assert on the new document (a new origin has no saved overlay position).
|
|
490
|
+
await page.mouse.move(pos.x, pos.y)
|
|
491
|
+
await page.waitForTimeout(600)
|
|
492
|
+
},
|
|
493
|
+
rest,
|
|
494
|
+
moveTo,
|
|
495
|
+
click: async (target, opts = {}) => {
|
|
496
|
+
const move = stamp()
|
|
497
|
+
await moveTo(target)
|
|
498
|
+
const at = stamp()
|
|
499
|
+
await clickWhereLanded(target)
|
|
500
|
+
clicks.push({ move, at, x: Math.round(pos.x), y: Math.round(pos.y), kind: 'click' })
|
|
501
|
+
await page.waitForTimeout(opts.settleMs ?? 700)
|
|
502
|
+
},
|
|
503
|
+
type: async (target, text) => {
|
|
504
|
+
const move = stamp()
|
|
505
|
+
await moveTo(target)
|
|
506
|
+
const at = stamp()
|
|
507
|
+
await clickWhereLanded(target)
|
|
508
|
+
const click: Click = { move, at, x: Math.round(pos.x), y: Math.round(pos.y), kind: 'type' }
|
|
509
|
+
clicks.push(click)
|
|
510
|
+
await typeNaturally(text)
|
|
511
|
+
click.until = stamp()
|
|
512
|
+
await page.waitForTimeout(300 + rng() * 200)
|
|
513
|
+
},
|
|
514
|
+
switchTo,
|
|
515
|
+
mobile: hooks.mobile ?? false,
|
|
516
|
+
square: hooks.square ?? false,
|
|
517
|
+
popup: async (action) => {
|
|
518
|
+
const [opened] = await Promise.all([page.context().waitForEvent('page'), action()])
|
|
519
|
+
await switchTo(opened)
|
|
520
|
+
return opened
|
|
521
|
+
},
|
|
522
|
+
scroll: async (deltaY, opts = {}) => {
|
|
523
|
+
const step = opts.stepPx ?? 80
|
|
524
|
+
const steps = Math.max(1, Math.round(Math.abs(deltaY) / step))
|
|
525
|
+
const sign = Math.sign(deltaY)
|
|
526
|
+
for (let i = 0; i < steps; i++) {
|
|
527
|
+
await page.mouse.wheel(0, sign * step)
|
|
528
|
+
await page.waitForTimeout(40)
|
|
529
|
+
}
|
|
530
|
+
await page.waitForTimeout(300)
|
|
531
|
+
},
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Small seeded PRNG so recordings are reproducible. */
|
|
536
|
+
function mulberry32(seed: number): () => number {
|
|
537
|
+
let a = seed >>> 0
|
|
538
|
+
return () => {
|
|
539
|
+
a = (a + 0x6d2b79f5) >>> 0
|
|
540
|
+
let t = a
|
|
541
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
542
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
543
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
type Box = { x: number; y: number; width: number; height: number }
|
|
548
|
+
|
|
549
|
+
const rounded = (b: Box): Box => ({ x: Math.round(b.x), y: Math.round(b.y), width: Math.round(b.width), height: Math.round(b.height) })
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The compact block around an element: the largest ancestor that is still about one row
|
|
553
|
+
* (at most 3x the element's height, or 180 px) and narrower than the viewport — e.g. a form
|
|
554
|
+
* field with its label and hint, a table row, a toolbar. Falls back to the element itself.
|
|
555
|
+
*/
|
|
556
|
+
async function areaAround(element: Locator, box: Box): Promise<Box> {
|
|
557
|
+
return element
|
|
558
|
+
.evaluate((node, own) => {
|
|
559
|
+
const maxHeight = Math.max(own.height * 3, 180)
|
|
560
|
+
const maxWidth = window.innerWidth * 0.9
|
|
561
|
+
let best = own
|
|
562
|
+
for (let el = node.parentElement; el && el !== document.body; el = el.parentElement) {
|
|
563
|
+
const r = el.getBoundingClientRect()
|
|
564
|
+
if (r.height > maxHeight || r.width > maxWidth) break
|
|
565
|
+
if (r.width >= best.width && r.height >= best.height) best = { x: r.x, y: r.y, width: r.width, height: r.height }
|
|
566
|
+
}
|
|
567
|
+
return best
|
|
568
|
+
}, box)
|
|
569
|
+
.catch(() => box)
|
|
570
|
+
}
|