@matte97p/demowright 0.1.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/src/runner.js ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The capture stage: drive the browser with Playwright while the overlay paints
3
+ * the polish into the same frames Playwright records. Output is a raw .webm.
4
+ */
5
+ import { chromium } from 'playwright'
6
+ import { mkdir } from 'node:fs/promises'
7
+ import path from 'node:path'
8
+ import { buildInitScript } from './overlay.js'
9
+
10
+ const sleep = (page, ms) => page.waitForTimeout(Math.max(0, ms | 0))
11
+
12
+ /** Real on-screen center of a selector (viewport coords), for genuine hover. */
13
+ async function centerOf(page, selector) {
14
+ const box = await page.locator(selector).first().boundingBox()
15
+ if (!box) return null
16
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
17
+ }
18
+
19
+ async function dw(page, fn, ...args) {
20
+ return page.evaluate(
21
+ ([f, a]) => (window.__dw && window.__dw[f] ? window.__dw[f](...a) : false),
22
+ [fn, args]
23
+ )
24
+ }
25
+
26
+ /** type → async executor. Each receives (page, step, demo). */
27
+ const EXECUTORS = {
28
+ async caption(page, step) {
29
+ await dw(page, 'caption', step.text, step.hold ? 0 : step.duration)
30
+ if (!step.hold) await sleep(page, step.duration)
31
+ },
32
+
33
+ async captionHide(page) {
34
+ await dw(page, 'captionHide')
35
+ await sleep(page, 340)
36
+ },
37
+
38
+ async goto(page, step) {
39
+ await page.goto(step.url, { waitUntil: 'load' })
40
+ await page.waitForFunction(() => !!window.__dw).catch(() => {})
41
+ await dw(page, 'ready')
42
+ await sleep(page, 400)
43
+ },
44
+
45
+ async move(page, step) {
46
+ if (step.selector) {
47
+ await dw(page, 'cursorToSelector', step.selector, step.duration)
48
+ const c = await centerOf(page, step.selector)
49
+ if (c) await page.mouse.move(c.x, c.y, { steps: 12 })
50
+ } else {
51
+ await dw(page, 'cursorTo', step.x, step.y, step.duration)
52
+ await page.mouse.move(step.x, step.y, { steps: 12 })
53
+ }
54
+ await sleep(page, step.duration + 120)
55
+ },
56
+
57
+ async click(page, step) {
58
+ await dw(page, 'cursorToSelector', step.selector, step.duration)
59
+ const c = await centerOf(page, step.selector)
60
+ if (c) await page.mouse.move(c.x, c.y, { steps: 12 })
61
+ await sleep(page, step.duration)
62
+ await dw(page, 'click')
63
+ // .first(): demos often have a selector that legitimately matches more than
64
+ // one node (e.g. a desktop + mobile copy of the same nav). Click the first
65
+ // rather than failing Playwright's strict-mode check.
66
+ await page.locator(step.selector).first().click()
67
+ await sleep(page, step.settle)
68
+ },
69
+
70
+ async type(page, step) {
71
+ const loc = page.locator(step.selector).first()
72
+ if (step.clear) await loc.fill('')
73
+ await loc.focus()
74
+ await loc.pressSequentially(step.text, { delay: step.perChar })
75
+ await sleep(page, 250)
76
+ },
77
+
78
+ async key(page, step) {
79
+ await page.keyboard.press(step.key)
80
+ await sleep(page, 200)
81
+ },
82
+
83
+ // Pick an option in a native <select>. Target by `value`, `label`, or `index`.
84
+ async select(page, step) {
85
+ await dw(page, 'cursorToSelector', step.selector, step.duration)
86
+ const c = await centerOf(page, step.selector)
87
+ if (c) await page.mouse.move(c.x, c.y, { steps: 10 })
88
+ await sleep(page, step.duration)
89
+ const loc = page.locator(step.selector).first()
90
+ if (step.contains != null) {
91
+ // Match the option whose visible text contains a substring, then select
92
+ // by its value — robust to decorated labels (e.g. "Linear · linear.app").
93
+ const value = await loc.evaluate((el, sub) => {
94
+ const o = [...el.options].find((opt) => opt.text.toLowerCase().includes(String(sub).toLowerCase()))
95
+ return o ? o.value : null
96
+ }, step.contains)
97
+ if (value == null) throw new Error('no <option> containing "' + step.contains + '" in ' + step.selector)
98
+ await loc.selectOption(value)
99
+ } else if (step.index != null) await loc.selectOption({ index: step.index })
100
+ else if (step.label != null) await loc.selectOption({ label: step.label })
101
+ else await loc.selectOption(step.value)
102
+ await sleep(page, step.settle)
103
+ },
104
+
105
+ async highlight(page, step) {
106
+ await dw(page, 'highlight', step.selector, step.pad)
107
+ if (step.duration) {
108
+ await sleep(page, step.duration)
109
+ await dw(page, 'highlightHide')
110
+ await sleep(page, 260)
111
+ }
112
+ },
113
+
114
+ async highlightHide(page) {
115
+ await dw(page, 'highlightHide')
116
+ await sleep(page, 260)
117
+ },
118
+
119
+ async zoom(page, step) {
120
+ await dw(page, 'zoom', step.selector, step.scale, step.duration)
121
+ await sleep(page, step.duration + 100)
122
+ },
123
+
124
+ async zoomReset(page, step) {
125
+ await dw(page, 'zoomReset', step.duration)
126
+ await sleep(page, step.duration + 100)
127
+ },
128
+
129
+ async scroll(page, step) {
130
+ if (step.selector) {
131
+ await page.evaluate(
132
+ (sel) => document.querySelector(sel)?.scrollIntoView({ behavior: 'smooth', block: 'center' }),
133
+ step.selector
134
+ )
135
+ } else {
136
+ await page.evaluate((y) => window.scrollTo({ top: y, behavior: 'smooth' }), step.y || 0)
137
+ }
138
+ await sleep(page, step.duration)
139
+ },
140
+
141
+ async wait(page, step) {
142
+ if (step.selector) {
143
+ await page.waitForSelector(step.selector, { state: 'visible', timeout: step.timeout })
144
+ } else {
145
+ await sleep(page, step.duration)
146
+ }
147
+ },
148
+
149
+ async endcard(page, step) {
150
+ await dw(page, 'captionHide')
151
+ await dw(page, 'endcard', step.title, step.subtitle || '', step.duration)
152
+ await sleep(page, step.duration)
153
+ },
154
+ }
155
+
156
+ /**
157
+ * Log in once in a throwaway, non-recorded context and return its storageState
158
+ * (cookies + localStorage). Field values come from env vars referenced by name,
159
+ * so credentials never live in the config or appear in the recording.
160
+ */
161
+ async function authenticate(browser, auth, viewport, { locale = null, init = null } = {}) {
162
+ const ctx = await browser.newContext({ viewport, ...(locale ? { locale } : {}) })
163
+ const page = await ctx.newPage()
164
+ if (init) await page.addInitScript(init)
165
+ await page.goto(auth.url, { waitUntil: 'load' })
166
+ for (const f of auth.fields) {
167
+ const value = f.env != null ? process.env[f.env] : f.value
168
+ if (value == null || value === '') {
169
+ throw new Error('auth field "' + f.selector + '" resolved empty (set env ' + (f.env || '') + ')')
170
+ }
171
+ const loc = page.locator(f.selector).first()
172
+ await loc.fill('')
173
+ await loc.pressSequentially(String(value), { delay: auth.perChar })
174
+ }
175
+ await page.click(auth.submit)
176
+ if (auth.waitUrl) await page.waitForURL(auth.waitUrl, { timeout: 30000 })
177
+ else if (auth.waitFor) await page.waitForSelector(auth.waitFor, { state: 'visible', timeout: 30000 })
178
+ else await page.waitForLoadState('networkidle')
179
+ // Best-effort post-login dismissals (e.g. first-run tour) so the recording
180
+ // starts on a clean page. Each is optional — a missing element is ignored.
181
+ for (const sel of auth.after || []) {
182
+ await page.locator(sel).first().click({ timeout: 10000, force: true }).catch(() => {})
183
+ }
184
+ await page.waitForTimeout(500)
185
+ const state = await ctx.storageState()
186
+ await ctx.close()
187
+ return state
188
+ }
189
+
190
+ /**
191
+ * Run a normalized demo and return { rawVideoPath }. The caller is responsible
192
+ * for the render stage (render.js) and any cleanup of the work directory.
193
+ */
194
+ export async function runDemo(demo, opts = {}) {
195
+ const workDir = opts.workDir || path.join(process.cwd(), '.demowright-tmp')
196
+ await mkdir(workDir, { recursive: true })
197
+
198
+ const browser = await chromium.launch({ headless: true })
199
+
200
+ let storageState
201
+ if (demo.auth) {
202
+ if (opts.onAuth) opts.onAuth()
203
+ storageState = await authenticate(browser, demo.auth, demo.viewport, {
204
+ locale: demo.locale,
205
+ init: demo.init,
206
+ })
207
+ }
208
+
209
+ const context = await browser.newContext({
210
+ viewport: demo.viewport,
211
+ deviceScaleFactor: 1,
212
+ recordVideo: { dir: workDir, size: demo.viewport },
213
+ reducedMotion: 'no-preference',
214
+ ...(demo.locale ? { locale: demo.locale } : {}),
215
+ ...(storageState ? { storageState } : {}),
216
+ })
217
+ // Video recording starts when the context is created; measure timelapse
218
+ // ranges as second-offsets from this moment so they line up with the capture.
219
+ const recordStart = Date.now()
220
+ const page = await context.newPage()
221
+ await page.addInitScript(buildInitScript(demo.theme))
222
+ if (demo.init) await page.addInitScript(demo.init)
223
+
224
+ const log = opts.onStep || (() => {})
225
+ const timelapses = [] // { start, end, factor } in seconds — dead waits to speed up
226
+ let video
227
+
228
+ try {
229
+ await page.goto(demo.url, { waitUntil: 'load' })
230
+ await page.waitForFunction(() => !!window.__dw).catch(() => {})
231
+ await dw(page, 'ready')
232
+ await sleep(page, 700)
233
+
234
+ for (let i = 0; i < demo.steps.length; i++) {
235
+ const step = demo.steps[i]
236
+ log(i, step)
237
+ const exec = EXECUTORS[step.type]
238
+ // A `wait` may be marked `timelapse: N` to speed that recorded span up N×
239
+ // in the final video (e.g. waiting out a multi-minute audit).
240
+ const tlStart = step.type === 'wait' && step.timelapse > 1 ? (Date.now() - recordStart) / 1000 : null
241
+ try {
242
+ await exec(page, step, demo)
243
+ } catch (err) {
244
+ throw new Error('step ' + i + ' (' + step.type + ') failed: ' + err.message)
245
+ }
246
+ if (tlStart != null) {
247
+ timelapses.push({ start: tlStart, end: (Date.now() - recordStart) / 1000, factor: step.timelapse })
248
+ }
249
+ }
250
+ } finally {
251
+ // Close the context/browser even on failure (flushes the video). Do NOT
252
+ // `return` here — a return inside finally swallows a thrown step error and
253
+ // makes a failed run look successful. Capture the handle and resolve after.
254
+ video = page.video()
255
+ await context.close() // flushes the video file
256
+ await browser.close()
257
+ }
258
+ // Only reached when the step loop completed without throwing.
259
+ if (!video) throw new Error('[demowright] no video was recorded')
260
+ const rawVideoPath = await video.path()
261
+ return { rawVideoPath, workDir, timelapses }
262
+ }
@@ -0,0 +1,46 @@
1
+ /** `demowright init` — drop a runnable starter config in the target directory. */
2
+ import { writeFile, access } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+
5
+ const STARTER = `import { defineDemo } from 'demowright'
6
+
7
+ // Edit the selectors/text to match your app, then:
8
+ // npx demowright run demowright.config.js -o output/demo.mp4
9
+ export default defineDemo({
10
+ name: 'my-demo',
11
+ url: 'http://localhost:3000',
12
+ viewport: { width: 1280, height: 720 },
13
+ theme: { accent: '#e91e63' },
14
+ // music: './assets/track.mp3',
15
+ formats: ['landscape'], // add 'square' and/or 'vertical' for social crops
16
+ steps: [
17
+ { type: 'caption', text: 'This is my app.', duration: 2400 },
18
+ { type: 'highlight', selector: 'nav', duration: 1600 },
19
+ { type: 'zoom', selector: 'nav', scale: 1.3 },
20
+ { type: 'wait', duration: 800 },
21
+ { type: 'zoomReset' },
22
+ { type: 'click', selector: 'a[href="/pricing"]' },
23
+ { type: 'wait', selector: 'h1' },
24
+ { type: 'caption', text: 'And here is the thing it does.', duration: 2600 },
25
+ { type: 'endcard', title: 'My Product', subtitle: 'myproduct.com', duration: 2600 },
26
+ ],
27
+ })
28
+ `
29
+
30
+ async function exists(p) {
31
+ try {
32
+ await access(p)
33
+ return true
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ export async function scaffold(dir = process.cwd()) {
40
+ const target = path.join(dir, 'demowright.config.js')
41
+ if (await exists(target)) {
42
+ return { created: false, path: target }
43
+ }
44
+ await writeFile(target, STARTER, 'utf8')
45
+ return { created: true, path: target }
46
+ }
package/src/steps.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Demo schema: validation + normalization.
3
+ *
4
+ * A demo is a plain object: { url, viewport?, theme?, music?, steps: [...] }.
5
+ * Each step is { type, ...fields }. This module is intentionally Playwright-free
6
+ * so it can be unit-tested without a browser — the executors live in runner.js.
7
+ */
8
+
9
+ /** Default timing (ms) per step kind. Tuned to read well at normal playback. */
10
+ const DEFAULTS = {
11
+ caption: { duration: 2600 },
12
+ move: { duration: 650 },
13
+ click: { duration: 650, settle: 280 },
14
+ type: { perChar: 42 },
15
+ select: { duration: 450, settle: 350 },
16
+ highlight: { pad: 8 },
17
+ zoom: { scale: 1.35, duration: 750 },
18
+ zoomReset: { duration: 600 },
19
+ scroll: { duration: 600 },
20
+ endcard: { duration: 2800 },
21
+ wait: { timeout: 30000 },
22
+ }
23
+
24
+ /** Every supported step type and the fields it requires. */
25
+ export const STEP_TYPES = {
26
+ caption: { required: ['text'] },
27
+ captionHide: { required: [] },
28
+ goto: { required: ['url'] },
29
+ move: { required: [] }, // selector OR (x,y)
30
+ click: { required: ['selector'] },
31
+ type: { required: ['selector', 'text'] },
32
+ select: { required: ['selector'] }, // + one of: value | label | index
33
+ key: { required: ['key'] },
34
+ highlight: { required: ['selector'] },
35
+ highlightHide: { required: [] },
36
+ zoom: { required: ['selector'] },
37
+ zoomReset: { required: [] },
38
+ scroll: { required: [] }, // selector OR y
39
+ wait: { required: [] }, // duration OR selector
40
+ endcard: { required: ['title'] },
41
+ }
42
+
43
+ function fail(msg) {
44
+ throw new Error('[demowright] invalid demo: ' + msg)
45
+ }
46
+
47
+ /**
48
+ * Validate and return a normalized copy of the demo with defaults applied.
49
+ * Throws an Error with an actionable message on the first problem found.
50
+ */
51
+ export function normalizeDemo(demo) {
52
+ if (!demo || typeof demo !== 'object') fail('expected a demo object')
53
+ if (!demo.url || typeof demo.url !== 'string') {
54
+ fail('"url" is required (the page the demo starts on)')
55
+ }
56
+ if (!Array.isArray(demo.steps) || demo.steps.length === 0) {
57
+ fail('"steps" must be a non-empty array')
58
+ }
59
+
60
+ const viewport = {
61
+ width: (demo.viewport && demo.viewport.width) || 1280,
62
+ height: (demo.viewport && demo.viewport.height) || 720,
63
+ }
64
+
65
+ const steps = demo.steps.map((raw, i) => {
66
+ if (!raw || typeof raw !== 'object') fail('step ' + i + ' is not an object')
67
+ const spec = STEP_TYPES[raw.type]
68
+ if (!spec) {
69
+ fail('step ' + i + ' has unknown type "' + raw.type + '". Valid: ' + Object.keys(STEP_TYPES).join(', '))
70
+ }
71
+ for (const field of spec.required) {
72
+ if (raw[field] == null) fail('step ' + i + ' (' + raw.type + ') is missing "' + field + '"')
73
+ }
74
+ if (raw.type === 'move' && raw.selector == null && (raw.x == null || raw.y == null)) {
75
+ fail('step ' + i + ' (move) needs either "selector" or both "x" and "y"')
76
+ }
77
+ if (raw.type === 'wait' && raw.duration == null && raw.selector == null) {
78
+ fail('step ' + i + ' (wait) needs either "duration" (ms) or "selector"')
79
+ }
80
+ return { ...DEFAULTS[raw.type], ...raw }
81
+ })
82
+
83
+ return {
84
+ name: demo.name || 'demo',
85
+ url: demo.url,
86
+ viewport,
87
+ theme: demo.theme || {},
88
+ music: demo.music || null,
89
+ musicVolume: demo.musicVolume == null ? 0.18 : demo.musicVolume,
90
+ formats: demo.formats || ['landscape'],
91
+ fps: demo.fps || 30,
92
+ // Browser UI locale (e.g. 'it-IT') for the recording context.
93
+ locale: demo.locale || null,
94
+ // JS run before the app's own scripts on every page (addInitScript) — for
95
+ // seeding state that must exist at boot, e.g. dismissing a first-run tour.
96
+ init: typeof demo.init === 'string' ? demo.init : null,
97
+ auth: normalizeAuth(demo.auth),
98
+ steps,
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Optional login block. Performed in a throwaway, NON-recorded context to
104
+ * capture an authenticated storageState — so the recording starts already
105
+ * logged in and the password never appears on screen. Field values are read
106
+ * from env at run time (referenced by name) so secrets stay out of the config.
107
+ */
108
+ function normalizeAuth(auth) {
109
+ if (!auth) return null
110
+ if (!auth.url) fail('auth.url is required when auth is set')
111
+ if (!Array.isArray(auth.fields) || !auth.fields.length) {
112
+ fail('auth.fields must list the login inputs ({ selector, env|value })')
113
+ }
114
+ for (const f of auth.fields) {
115
+ if (!f.selector) fail('each auth.field needs a "selector"')
116
+ if (f.env == null && f.value == null) fail('auth.field "' + f.selector + '" needs "env" or "value"')
117
+ }
118
+ if (!auth.submit) fail('auth.submit (selector of the login button) is required')
119
+ return {
120
+ url: auth.url,
121
+ fields: auth.fields,
122
+ submit: auth.submit,
123
+ waitFor: auth.waitFor || null,
124
+ waitUrl: auth.waitUrl || null,
125
+ perChar: auth.perChar || 20,
126
+ // Best-effort selectors to click after login, before capturing the session
127
+ // (e.g. dismiss a first-run tour) — so the recording starts on a clean page.
128
+ after: Array.isArray(auth.after) ? auth.after : [],
129
+ }
130
+ }
131
+
132
+ /** Identity helper — gives editors a hook for autocompletion on demo configs. */
133
+ export function defineDemo(demo) {
134
+ return demo
135
+ }
136
+
137
+ /** Sum of caption/zoom/wait/etc. durations — a rough estimate of clip length (ms). */
138
+ export function estimateDurationMs(demo) {
139
+ let total = 700 // intro settle
140
+ for (const s of demo.steps) {
141
+ if (s.type === 'caption') total += s.duration || 0
142
+ else if (s.type === 'type') total += (s.text ? s.text.length : 0) * (s.perChar || 42) + 300
143
+ else if (s.type === 'wait') total += s.duration || 600
144
+ else if (s.type === 'click' || s.type === 'move') total += (s.duration || 0) + 200
145
+ else if (s.type === 'zoom' || s.type === 'zoomReset' || s.type === 'scroll') total += s.duration || 0
146
+ else if (s.type === 'endcard') total += s.duration || 0
147
+ else total += 250
148
+ }
149
+ return total
150
+ }