@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/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@matte97p/demowright",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Record polished product demo videos from a script. Playwright drives the browser, the overlay (captions, smooth cursor, auto-zoom, end card) is baked into the recording. A demo that lives in your repo and re-renders in CI — so it never goes stale.",
8
+ "type": "module",
9
+ "license": "MIT",
10
+ "author": "Matteo Perino <matte97.p@gmail.com> (https://github.com/matte97p)",
11
+ "contributors": [
12
+ "Matteo Perino <matte97.p@gmail.com> (https://github.com/matte97p)",
13
+ "GeoSuite (https://trygeosuite.it)"
14
+ ],
15
+ "homepage": "https://github.com/matte97p/demowright#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/matte97p/demowright.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/matte97p/demowright/issues"
22
+ },
23
+ "keywords": [
24
+ "demo",
25
+ "demo-video",
26
+ "screencast",
27
+ "screen-recording",
28
+ "product-demo",
29
+ "playwright",
30
+ "video",
31
+ "ffmpeg",
32
+ "marketing",
33
+ "devtools",
34
+ "ci"
35
+ ],
36
+ "main": "src/index.js",
37
+ "exports": {
38
+ ".": "./src/index.js"
39
+ },
40
+ "bin": {
41
+ "demowright": "bin/cli.js"
42
+ },
43
+ "files": [
44
+ "bin",
45
+ "src",
46
+ "examples/local-demo.config.js",
47
+ "examples/geosuite-howitworks.config.js",
48
+ "examples/geosuite-workspace-audit.config.js",
49
+ "examples/site",
50
+ "assets",
51
+ "README.md",
52
+ "LICENSE",
53
+ "CHANGELOG.md"
54
+ ],
55
+ "scripts": {
56
+ "lint": "node --check src/index.js && node --check src/runner.js && node --check src/overlay.js && node --check src/steps.js && node --check src/render.js && node --check src/scaffold.js && node --check bin/cli.js",
57
+ "test": "node --check examples/local-demo.config.js && echo \"ok: example config parses (no unit tests yet)\"",
58
+ "demo": "node bin/cli.js run examples/local-demo.config.js -o output/local-demo.mp4"
59
+ },
60
+ "dependencies": {
61
+ "ffmpeg-static": "^5.2.0",
62
+ "playwright": "^1.45.0"
63
+ },
64
+ "engines": {
65
+ "node": ">=20"
66
+ }
67
+ }
package/src/index.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * demowright — record polished product demo videos from a script.
3
+ *
4
+ * Public API:
5
+ * recordDemo(demo, opts) capture + render in one call → { outputs, demo }
6
+ * defineDemo(demo) identity helper for editor autocompletion
7
+ * runDemo / renderVideo the two stages, if you want them separately
8
+ */
9
+ import { rm } from 'node:fs/promises'
10
+ import path from 'node:path'
11
+ import { normalizeDemo, defineDemo, estimateDurationMs, STEP_TYPES } from './steps.js'
12
+ import { runDemo } from './runner.js'
13
+ import { renderVideo } from './render.js'
14
+
15
+ export { defineDemo, normalizeDemo, runDemo, renderVideo, estimateDurationMs, STEP_TYPES }
16
+
17
+ /**
18
+ * Capture a demo and render it to MP4(s).
19
+ * @param {object} rawDemo the demo definition ({ url, steps, ... })
20
+ * @param {object} [opts] { out, formats, music, workDir, keepRaw, onStep }
21
+ * @returns {Promise<{ outputs: Array<{format,path}>, demo: object }>}
22
+ */
23
+ export async function recordDemo(rawDemo, opts = {}) {
24
+ const demo = normalizeDemo(rawDemo)
25
+ const out = opts.out || path.join(process.cwd(), 'output', demo.name + '.mp4')
26
+ const workDir = opts.workDir || path.join(path.dirname(out), '.demowright-tmp')
27
+
28
+ const { rawVideoPath, timelapses } = await runDemo(demo, { workDir, onStep: opts.onStep, onAuth: opts.onAuth })
29
+ const outputs = await renderVideo(rawVideoPath, {
30
+ out,
31
+ formats: opts.formats && opts.formats.length ? opts.formats : demo.formats,
32
+ music: opts.music || demo.music,
33
+ musicVolume: demo.musicVolume,
34
+ fps: demo.fps,
35
+ timelapses,
36
+ workDir,
37
+ })
38
+
39
+ if (!opts.keepRaw) await rm(workDir, { recursive: true, force: true })
40
+ return { outputs, demo }
41
+ }
package/src/overlay.js ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * In-page overlay runtime.
3
+ *
4
+ * This function is serialized with `.toString()` and injected into the page via
5
+ * Playwright's `addInitScript`, so it re-installs itself on every navigation.
6
+ * It exposes `window.__dw`, a small API the runner drives over `page.evaluate`.
7
+ *
8
+ * The whole point: the polish (captions, a smooth synthetic cursor, zoom, an end
9
+ * card) is part of the DOM, so it ends up *inside* the recorded video — no
10
+ * post-production compositing needed. The browser never shows the real OS cursor
11
+ * in a headless recording, which is exactly why we draw our own.
12
+ *
13
+ * Coordinate model:
14
+ * - The overlay root is attached to <html> (documentElement), so it is NOT a
15
+ * descendant of <body> and is therefore unaffected by the zoom transform we
16
+ * apply to <body>. Captions and cursor stay crisp while the page zooms.
17
+ * - `getBoundingClientRect()` already reflects ancestor CSS transforms, so the
18
+ * cursor/ring always land on the element as actually rendered, zoom or not.
19
+ */
20
+ export function overlayRuntime(theme) {
21
+ if (window.__dw) return
22
+ const ACCENT = (theme && theme.accent) || '#e91e63'
23
+ const FONT =
24
+ (theme && theme.font) ||
25
+ 'system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
26
+ const EASE = 'cubic-bezier(0.22, 0.61, 0.36, 1)'
27
+
28
+ let root = null
29
+ let cursorEl = null
30
+ let clickRingEl = null
31
+ let captionEl = null
32
+ let ringEl = null
33
+ let cardEl = null
34
+ let captionTimer = null
35
+
36
+ function ensureRoot() {
37
+ if (root) return
38
+ root = document.createElement('div')
39
+ root.id = '__dw-overlay'
40
+ Object.assign(root.style, {
41
+ position: 'fixed',
42
+ left: '0',
43
+ top: '0',
44
+ width: '100%',
45
+ height: '100%',
46
+ pointerEvents: 'none',
47
+ zIndex: '2147483647',
48
+ fontFamily: FONT,
49
+ overflow: 'hidden',
50
+ })
51
+
52
+ // Synthetic cursor (SVG arrow) — moves via a transform transition.
53
+ cursorEl = document.createElement('div')
54
+ Object.assign(cursorEl.style, {
55
+ position: 'absolute',
56
+ left: '0',
57
+ top: '0',
58
+ width: '28px',
59
+ height: '28px',
60
+ transform: 'translate(-40px, -40px)',
61
+ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.45))',
62
+ willChange: 'transform',
63
+ })
64
+ cursorEl.innerHTML =
65
+ '<svg viewBox="0 0 24 24" width="28" height="28">' +
66
+ '<path d="M5 2.5 19 12.2l-6.1.7 3.5 7.1-2.7 1.3-3.5-7.2L5 19.5z" ' +
67
+ 'fill="#fff" stroke="#111" stroke-width="1.2" stroke-linejoin="round"/></svg>'
68
+
69
+ // Click pulse ring.
70
+ clickRingEl = document.createElement('div')
71
+ Object.assign(clickRingEl.style, {
72
+ position: 'absolute',
73
+ left: '0',
74
+ top: '0',
75
+ width: '14px',
76
+ height: '14px',
77
+ marginLeft: '0px',
78
+ marginTop: '0px',
79
+ borderRadius: '50%',
80
+ border: '2px solid ' + ACCENT,
81
+ transform: 'translate(-40px, -40px) scale(0.2)',
82
+ opacity: '0',
83
+ })
84
+
85
+ // Caption bar (bottom-center).
86
+ captionEl = document.createElement('div')
87
+ Object.assign(captionEl.style, {
88
+ position: 'absolute',
89
+ left: '50%',
90
+ bottom: '7%',
91
+ transform: 'translateX(-50%) translateY(12px)',
92
+ maxWidth: '78%',
93
+ padding: '14px 22px',
94
+ borderRadius: '14px',
95
+ background: 'rgba(12, 12, 14, 0.82)',
96
+ backdropFilter: 'blur(6px)',
97
+ color: '#fff',
98
+ fontSize: '26px',
99
+ lineHeight: '1.3',
100
+ fontWeight: '600',
101
+ letterSpacing: '0.2px',
102
+ textAlign: 'center',
103
+ boxShadow: '0 10px 40px rgba(0,0,0,0.35)',
104
+ opacity: '0',
105
+ transition: 'opacity 320ms ease, transform 320ms ' + EASE,
106
+ whiteSpace: 'pre-wrap',
107
+ })
108
+
109
+ // Highlight ring around an element.
110
+ ringEl = document.createElement('div')
111
+ Object.assign(ringEl.style, {
112
+ position: 'absolute',
113
+ left: '0',
114
+ top: '0',
115
+ borderRadius: '12px',
116
+ border: '3px solid ' + ACCENT,
117
+ boxShadow: '0 0 0 9999px rgba(8,8,10,0.0)',
118
+ opacity: '0',
119
+ transition: 'opacity 260ms ease, left 420ms ' + EASE + ', top 420ms ' + EASE + ', width 420ms ' + EASE + ', height 420ms ' + EASE,
120
+ })
121
+
122
+ root.append(ringEl, captionEl, clickRingEl, cursorEl)
123
+ ;(document.documentElement || document.body).appendChild(root)
124
+ }
125
+
126
+ function center(selector) {
127
+ const el = document.querySelector(selector)
128
+ if (!el) return null
129
+ const r = el.getBoundingClientRect()
130
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, rect: r }
131
+ }
132
+
133
+ const api = {}
134
+
135
+ api.ready = function () {
136
+ ensureRoot()
137
+ return true
138
+ }
139
+
140
+ api.cursorTo = function (x, y, ms) {
141
+ ensureRoot()
142
+ cursorEl.style.transition = 'transform ' + (ms || 600) + 'ms ' + EASE
143
+ cursorEl.style.transform = 'translate(' + x + 'px, ' + y + 'px)'
144
+ }
145
+
146
+ api.cursorToSelector = function (selector, ms) {
147
+ ensureRoot()
148
+ const c = center(selector)
149
+ if (!c) return false
150
+ api.cursorTo(c.x, c.y, ms)
151
+ return true
152
+ }
153
+
154
+ api.click = function () {
155
+ ensureRoot()
156
+ const t = cursorEl.style.transform
157
+ clickRingEl.style.transition = 'none'
158
+ clickRingEl.style.transform = t + ' scale(0.2)'
159
+ clickRingEl.style.opacity = '0.9'
160
+ // Force reflow so the pulse animates from the reset state.
161
+ void clickRingEl.offsetWidth
162
+ clickRingEl.style.transition = 'transform 480ms ease-out, opacity 480ms ease-out'
163
+ clickRingEl.style.transform = t + ' scale(2.6)'
164
+ clickRingEl.style.opacity = '0'
165
+ }
166
+
167
+ api.caption = function (text, ms) {
168
+ ensureRoot()
169
+ if (captionTimer) clearTimeout(captionTimer)
170
+ captionEl.textContent = text
171
+ captionEl.style.opacity = '1'
172
+ captionEl.style.transform = 'translateX(-50%) translateY(0)'
173
+ if (ms && ms > 0) {
174
+ captionTimer = setTimeout(api.captionHide, ms)
175
+ }
176
+ }
177
+
178
+ api.captionHide = function () {
179
+ if (!captionEl) return
180
+ captionEl.style.opacity = '0'
181
+ captionEl.style.transform = 'translateX(-50%) translateY(12px)'
182
+ }
183
+
184
+ api.highlight = function (selector, pad) {
185
+ ensureRoot()
186
+ const c = center(selector)
187
+ if (!c) return false
188
+ const p = pad == null ? 8 : pad
189
+ ringEl.style.left = c.rect.left - p + 'px'
190
+ ringEl.style.top = c.rect.top - p + 'px'
191
+ ringEl.style.width = c.rect.width + p * 2 + 'px'
192
+ ringEl.style.height = c.rect.height + p * 2 + 'px'
193
+ ringEl.style.opacity = '1'
194
+ return true
195
+ }
196
+
197
+ api.highlightHide = function () {
198
+ if (ringEl) ringEl.style.opacity = '0'
199
+ }
200
+
201
+ api.zoom = function (selector, scale, ms) {
202
+ ensureRoot()
203
+ const c = center(selector)
204
+ if (!c) return false
205
+ const ox = c.x + window.scrollX
206
+ const oy = c.y + window.scrollY
207
+ const b = document.body
208
+ b.style.transition = 'transform ' + (ms || 700) + 'ms ' + EASE
209
+ b.style.transformOrigin = ox + 'px ' + oy + 'px'
210
+ b.style.transform = 'scale(' + scale + ')'
211
+ return true
212
+ }
213
+
214
+ api.zoomReset = function (ms) {
215
+ const b = document.body
216
+ b.style.transition = 'transform ' + (ms || 600) + 'ms ' + EASE
217
+ b.style.transform = 'none'
218
+ }
219
+
220
+ api.endcard = function (title, subtitle, ms) {
221
+ ensureRoot()
222
+ if (!cardEl) {
223
+ cardEl = document.createElement('div')
224
+ Object.assign(cardEl.style, {
225
+ position: 'absolute',
226
+ left: '0',
227
+ top: '0',
228
+ width: '100%',
229
+ height: '100%',
230
+ display: 'flex',
231
+ flexDirection: 'column',
232
+ alignItems: 'center',
233
+ justifyContent: 'center',
234
+ gap: '14px',
235
+ background: 'radial-gradient(120% 120% at 50% 30%, #1b1b22 0%, #0b0b0e 70%)',
236
+ color: '#fff',
237
+ opacity: '0',
238
+ transition: 'opacity 520ms ease',
239
+ })
240
+ const t = document.createElement('div')
241
+ t.className = '__dw-card-title'
242
+ Object.assign(t.style, { fontSize: '56px', fontWeight: '800', letterSpacing: '-0.5px' })
243
+ const s = document.createElement('div')
244
+ s.className = '__dw-card-sub'
245
+ Object.assign(s.style, { fontSize: '24px', fontWeight: '500', color: ACCENT })
246
+ cardEl.append(t, s)
247
+ root.appendChild(cardEl)
248
+ }
249
+ cardEl.querySelector('.__dw-card-title').textContent = title || ''
250
+ cardEl.querySelector('.__dw-card-sub').textContent = subtitle || ''
251
+ void cardEl.offsetWidth
252
+ cardEl.style.opacity = '1'
253
+ return true
254
+ }
255
+
256
+ window.__dw = api
257
+ }
258
+
259
+ /** Build the init-script source string that installs the overlay in-page. */
260
+ export function buildInitScript(theme) {
261
+ return '(' + overlayRuntime.toString() + ')(' + JSON.stringify(theme || {}) + ')'
262
+ }
package/src/render.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The render stage: turn the raw .webm Playwright produced into shareable MP4s.
3
+ *
4
+ * Playwright records silent video, so audio (if any) is the supplied music track.
5
+ * Formats are derived with ffmpeg filters so one capture yields landscape (16:9),
6
+ * square (1:1), and vertical (9:16 with a blurred fill) without re-recording.
7
+ */
8
+ import { spawn } from 'node:child_process'
9
+ import { mkdir } from 'node:fs/promises'
10
+ import path from 'node:path'
11
+ import ffmpegPath from 'ffmpeg-static'
12
+
13
+ /** Video-filter graph per format. `[v]` is the labelled final video pad. */
14
+ const FILTERS = {
15
+ landscape: 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1[v]',
16
+ square: 'crop=ih:ih:(iw-ih)/2:0,scale=1080:1080,setsar=1[v]',
17
+ vertical:
18
+ 'split=2[bg][fg];' +
19
+ '[bg]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,boxblur=24:4[bgb];' +
20
+ '[fg]scale=1080:-2[fgs];' +
21
+ '[bgb][fgs]overlay=(W-w)/2:(H-h)/2,setsar=1[v]',
22
+ }
23
+
24
+ function runFfmpeg(args) {
25
+ return new Promise((resolve, reject) => {
26
+ const proc = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] })
27
+ let stderr = ''
28
+ proc.stderr.on('data', (d) => {
29
+ stderr += d.toString()
30
+ })
31
+ proc.on('error', reject)
32
+ proc.on('close', (code) => {
33
+ if (code === 0) resolve()
34
+ else reject(new Error('ffmpeg exited ' + code + '\n' + stderr.split('\n').slice(-12).join('\n')))
35
+ })
36
+ })
37
+ }
38
+
39
+ /**
40
+ * Speed up one or more time ranges of the capture (the "dead waits" — a scrape,
41
+ * an audit running) while leaving the rest at real time. `timelapses` is a list
42
+ * of { start, end, factor } in seconds, relative to the video start. Returns the
43
+ * path to a new intermediate video; the format crops then run on top of it. With
44
+ * no ranges, the original path is returned untouched.
45
+ */
46
+ async function applyTimelapse(rawVideoPath, timelapses, workDir, fps) {
47
+ const ranges = (timelapses || [])
48
+ .filter((t) => t && t.factor > 1 && t.end > t.start)
49
+ .sort((a, b) => a.start - b.start)
50
+ if (!ranges.length) return rawVideoPath
51
+
52
+ const parts = []
53
+ const labels = []
54
+ let cursor = 0
55
+ let i = 0
56
+ const seg = (expr, label) => {
57
+ parts.push('[0:v]' + expr + '[' + label + ']')
58
+ labels.push('[' + label + ']')
59
+ }
60
+ for (const r of ranges) {
61
+ if (r.start > cursor) {
62
+ seg('trim=start=' + cursor.toFixed(3) + ':end=' + r.start.toFixed(3) + ',setpts=PTS-STARTPTS', 'n' + i)
63
+ i++
64
+ }
65
+ seg(
66
+ 'trim=start=' + r.start.toFixed(3) + ':end=' + r.end.toFixed(3) + ',setpts=(PTS-STARTPTS)/' + r.factor,
67
+ 'f' + i
68
+ )
69
+ i++
70
+ cursor = r.end
71
+ }
72
+ // tail: from the last range to the end (open-ended trim)
73
+ seg('trim=start=' + cursor.toFixed(3) + ',setpts=PTS-STARTPTS', 'n' + i)
74
+
75
+ const filter = parts.join(';') + ';' + labels.join('') + 'concat=n=' + labels.length + ':v=1:a=0[v]'
76
+ await mkdir(workDir, { recursive: true })
77
+ const out = path.join(workDir, 'timelapsed.mp4')
78
+ await runFfmpeg([
79
+ '-y', '-loglevel', 'error', '-i', rawVideoPath,
80
+ '-filter_complex', filter, '-map', '[v]',
81
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'medium', '-crf', '18',
82
+ '-r', String(fps), out,
83
+ ])
84
+ return out
85
+ }
86
+
87
+ function outPathFor(base, format, formats) {
88
+ if (formats.length === 1) return base
89
+ const ext = path.extname(base) || '.mp4'
90
+ const stem = base.slice(0, base.length - ext.length)
91
+ return stem + '.' + format + ext
92
+ }
93
+
94
+ /**
95
+ * Render `rawVideoPath` into one MP4 per requested format.
96
+ * Returns an array of { format, path } for the files written.
97
+ */
98
+ export async function renderVideo(rawVideoPath, opts = {}) {
99
+ const formats = opts.formats && opts.formats.length ? opts.formats : ['landscape']
100
+ const base = opts.out || path.join(process.cwd(), 'output', 'demo.mp4')
101
+ const fps = opts.fps || 30
102
+ await mkdir(path.dirname(base), { recursive: true })
103
+
104
+ // Speed up the marked dead-wait ranges once, up front; the format crops then
105
+ // run on top of the time-lapsed intermediate.
106
+ const source = await applyTimelapse(rawVideoPath, opts.timelapses, opts.workDir || path.dirname(base), fps)
107
+
108
+ const results = []
109
+ for (const format of formats) {
110
+ const filter = FILTERS[format]
111
+ if (!filter) throw new Error('[demowright] unknown format "' + format + '" (use landscape|square|vertical)')
112
+ const out = outPathFor(base, format, formats)
113
+
114
+ const args = ['-y', '-loglevel', 'error', '-i', source]
115
+ if (opts.music) args.push('-stream_loop', '-1', '-i', opts.music)
116
+
117
+ args.push('-filter_complex', filter, '-map', '[v]')
118
+ if (opts.music) {
119
+ const vol = opts.musicVolume == null ? 0.18 : opts.musicVolume
120
+ args.push('-map', '1:a', '-af', 'volume=' + vol, '-c:a', 'aac', '-b:a', '128k')
121
+ }
122
+ args.push(
123
+ '-c:v', 'libx264',
124
+ '-pix_fmt', 'yuv420p',
125
+ '-preset', 'medium',
126
+ '-crf', '20',
127
+ '-r', String(fps),
128
+ '-movflags', '+faststart',
129
+ '-shortest',
130
+ out
131
+ )
132
+
133
+ await runFfmpeg(args)
134
+ results.push({ format, path: out })
135
+ }
136
+ return results
137
+ }