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,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Films the page with Chrome's screencast instead of Playwright's video.
|
|
3
|
+
*
|
|
4
|
+
* Playwright's recorder writes a fixed 25 fps WebM, so in the 30 fps recording.mp4
|
|
5
|
+
* scrolling, hovers and UI transitions stutter (one frame in six repeats). The screencast
|
|
6
|
+
* hands over every frame the compositor paints (up to 60 fps, full device pixels), each
|
|
7
|
+
* stamped with the time it was painted on this machine's clock — the same clock as the
|
|
8
|
+
* markers, clicks and cursor log. `frameSchedule` turns those frames into a constant-rate
|
|
9
|
+
* video: at every instant, the latest frame painted so far, with demo.cut() ranges removed.
|
|
10
|
+
*/
|
|
11
|
+
import type { BrowserContext, CDPSession, Page } from '@playwright/test'
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { mkdirSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { writeFile } from 'node:fs/promises'
|
|
15
|
+
import { resolve } from 'node:path'
|
|
16
|
+
|
|
17
|
+
export interface Frame {
|
|
18
|
+
file: string
|
|
19
|
+
/** When Chrome painted it: epoch milliseconds. */
|
|
20
|
+
t: number
|
|
21
|
+
/** Which page painted it (0 = the first); see `frameSchedule` switches. */
|
|
22
|
+
page?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** From `t` (epoch ms) on, the video shows `page`. */
|
|
26
|
+
export interface CameraSwitch {
|
|
27
|
+
t: number
|
|
28
|
+
page: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Screencast {
|
|
32
|
+
/** Stops filming and waits until every frame is on disk. */
|
|
33
|
+
stop: () => Promise<Frame[]>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Starts filming `page`; frames are written into `dir` as they arrive, tagged `pageId`. */
|
|
37
|
+
export async function startScreencast(context: BrowserContext, page: Page, dir: string, pageId = 0, quality = 92): Promise<Screencast> {
|
|
38
|
+
mkdirSync(dir, { recursive: true })
|
|
39
|
+
const cdp: CDPSession = await context.newCDPSession(page)
|
|
40
|
+
const frames: Frame[] = []
|
|
41
|
+
const writes: Promise<void>[] = []
|
|
42
|
+
cdp.on('Page.screencastFrame', (event: { data: string; sessionId: number; metadata: { timestamp?: number } }) => {
|
|
43
|
+
// Ack first: Chrome sends the next frame only after the previous one is acknowledged.
|
|
44
|
+
cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId }).catch(() => {})
|
|
45
|
+
const file = resolve(dir, `${pageId ? `p${pageId}-` : ''}f${String(frames.length).padStart(6, '0')}.jpg`)
|
|
46
|
+
frames.push({ file, t: (event.metadata.timestamp ?? Date.now() / 1000) * 1000, page: pageId })
|
|
47
|
+
writes.push(writeFile(file, Buffer.from(event.data, 'base64')))
|
|
48
|
+
})
|
|
49
|
+
await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 })
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
stop: async () => {
|
|
53
|
+
await cdp.send('Page.stopScreencast').catch(() => {})
|
|
54
|
+
await Promise.all(writes)
|
|
55
|
+
await cdp.detach().catch(() => {})
|
|
56
|
+
return frames
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How long each frame stays on screen in the finished video (seconds), in order. Video
|
|
63
|
+
* time 0 is `startedAt` (the recording clock of createDemo); a frame shows from the moment
|
|
64
|
+
* it was painted until the next one; the first frame also covers anything before it.
|
|
65
|
+
* `cuts` are ranges of that clock (seconds) left out of the video. With `switches`, only
|
|
66
|
+
* the page the camera is on counts at each moment (a pop-up, then back to its opener, which
|
|
67
|
+
* shows its last frame from before); without, every frame counts.
|
|
68
|
+
*/
|
|
69
|
+
export function frameSchedule(
|
|
70
|
+
frames: Frame[],
|
|
71
|
+
startedAt: number,
|
|
72
|
+
endedAt: number,
|
|
73
|
+
cuts: { from: number; to: number }[],
|
|
74
|
+
switches?: CameraSwitch[],
|
|
75
|
+
): { file: string; duration: number }[] {
|
|
76
|
+
const end = (endedAt - startedAt) / 1000
|
|
77
|
+
const seconds = (t: number) => (t - startedAt) / 1000
|
|
78
|
+
const camera = switches?.length
|
|
79
|
+
? [...switches].sort((a, b) => a.t - b.t).map((s) => ({ at: Math.max(0, seconds(s.t)), page: s.page }))
|
|
80
|
+
: [{ at: 0, page: -1 }]
|
|
81
|
+
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 }))
|
|
82
|
+
const kept = keptRanges(cuts, end)
|
|
83
|
+
const out: { file: string; duration: number }[] = []
|
|
84
|
+
const add = (file: string, from: number, to: number) => {
|
|
85
|
+
for (const range of kept) {
|
|
86
|
+
const overlap = Math.min(to, range.to) - Math.max(from, range.from)
|
|
87
|
+
if (overlap <= 0) {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const last = out[out.length - 1]
|
|
91
|
+
if (last?.file === file) {
|
|
92
|
+
last.duration += overlap
|
|
93
|
+
} else {
|
|
94
|
+
out.push({ file, duration: overlap })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const piece of pieces.filter((p) => p.to > p.from)) {
|
|
99
|
+
const own = frames
|
|
100
|
+
.filter((f) => piece.page === -1 || (f.page ?? 0) === piece.page)
|
|
101
|
+
.sort((a, b) => a.t - b.t)
|
|
102
|
+
.map((f) => ({ file: f.file, t: seconds(f.t) }))
|
|
103
|
+
if (!own.length) {
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
// On screen at the start of the piece: the page's latest frame so far, else its first.
|
|
107
|
+
let i = Math.max(0, own.findLastIndex((f) => f.t <= piece.from))
|
|
108
|
+
for (; i < own.length && own[i].t < piece.to; i++) {
|
|
109
|
+
const from = Math.max(piece.from, own[i].t)
|
|
110
|
+
const to = Math.min(piece.to, i + 1 < own.length ? own[i + 1].t : piece.to)
|
|
111
|
+
if (to > from) {
|
|
112
|
+
add(own[i].file, i === 0 && own[i].t > piece.from ? piece.from : from, to)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** [0, end] minus the cuts. */
|
|
121
|
+
function keptRanges(cuts: { from: number; to: number }[], end: number): { from: number; to: number }[] {
|
|
122
|
+
const ranges: { from: number; to: number }[] = []
|
|
123
|
+
let at = 0
|
|
124
|
+
for (const c of [...cuts].sort((a, b) => a.from - b.from)) {
|
|
125
|
+
if (c.from > at) {
|
|
126
|
+
ranges.push({ from: at, to: Math.min(c.from, end) })
|
|
127
|
+
}
|
|
128
|
+
at = Math.max(at, c.to)
|
|
129
|
+
}
|
|
130
|
+
if (at < end) {
|
|
131
|
+
ranges.push({ from: at, to: end })
|
|
132
|
+
}
|
|
133
|
+
return ranges.filter((r) => r.to > r.from)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The finished recording's frame rate. */
|
|
137
|
+
export const FPS = 30
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Encodes the schedule as the H.264 recording.mp4 HyperFrames consumes: output frame k
|
|
141
|
+
* (at k / FPS s) is the latest frame painted by then, piped straight into ffmpeg at a
|
|
142
|
+
* constant rate. (ffmpeg's concat demuxer would snap image timestamps to 25 fps.)
|
|
143
|
+
*/
|
|
144
|
+
export async function encodeFrames(schedule: { file: string; duration: number }[], mp4: string): Promise<boolean> {
|
|
145
|
+
if (!schedule.length) {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
const total = schedule.reduce((sum, e) => sum + e.duration, 0)
|
|
149
|
+
const count = Math.max(1, Math.round(total * FPS))
|
|
150
|
+
const ffmpeg = spawn(
|
|
151
|
+
'ffmpeg',
|
|
152
|
+
[
|
|
153
|
+
'-y', '-loglevel', 'error',
|
|
154
|
+
'-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'mjpeg', '-i', '-',
|
|
155
|
+
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
|
|
156
|
+
'-c:v', 'libx264', '-preset', 'slow', '-crf', '15', '-pix_fmt', 'yuv420p', '-r', String(FPS),
|
|
157
|
+
// A keyframe every second: HyperFrames seeks frame by frame.
|
|
158
|
+
'-g', String(FPS), '-keyint_min', String(FPS),
|
|
159
|
+
'-movflags', '+faststart', '-an', mp4,
|
|
160
|
+
],
|
|
161
|
+
{ stdio: ['pipe', 'inherit', 'inherit'] },
|
|
162
|
+
)
|
|
163
|
+
const done = new Promise<boolean>((resolveDone) => {
|
|
164
|
+
ffmpeg.on('error', () => resolveDone(false))
|
|
165
|
+
ffmpeg.on('close', (code) => resolveDone(code === 0))
|
|
166
|
+
})
|
|
167
|
+
ffmpeg.stdin.on('error', () => {}) // ffmpeg exiting early is reported by `done`
|
|
168
|
+
|
|
169
|
+
let index = 0
|
|
170
|
+
let endOfCurrent = schedule[0].duration
|
|
171
|
+
let loaded = -1
|
|
172
|
+
let bytes = Buffer.alloc(0)
|
|
173
|
+
for (let k = 0; k < count; k++) {
|
|
174
|
+
const t = k / FPS
|
|
175
|
+
while (index < schedule.length - 1 && t >= endOfCurrent - 1e-9) {
|
|
176
|
+
index++
|
|
177
|
+
endOfCurrent += schedule[index].duration
|
|
178
|
+
}
|
|
179
|
+
if (index !== loaded) {
|
|
180
|
+
bytes = readFileSync(schedule[index].file)
|
|
181
|
+
loaded = index
|
|
182
|
+
}
|
|
183
|
+
if (!ffmpeg.stdin.write(bytes)) {
|
|
184
|
+
await new Promise((drained) => ffmpeg.stdin.once('drain', drained))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
ffmpeg.stdin.end()
|
|
188
|
+
|
|
189
|
+
return done
|
|
190
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project settings shared by reelson-record and reelson-compose.
|
|
3
|
+
*
|
|
4
|
+
* Each project that uses the kit keeps a `demo.config.json` at its root (copy
|
|
5
|
+
* demo.config.example.json from the kit). The scripts find it by walking up
|
|
6
|
+
* from the scenario / demo directory, then from the working directory. Every
|
|
7
|
+
* field is optional; DEFAULTS below apply to whatever is missing, so a project
|
|
8
|
+
* without a config still works (neutral brand, English strings, no music).
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { stringsFor } from "./languages.js";
|
|
14
|
+
import { loadSchema, validate } from "./validate.js";
|
|
15
|
+
/** JSON Schema for demo.config.json (editors pick it up through `$schema`). */
|
|
16
|
+
export const CONFIG_SCHEMA_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../schemas/demo.config.schema.json');
|
|
17
|
+
export const DEFAULTS = {
|
|
18
|
+
videosDir: 'docs/videos',
|
|
19
|
+
language: 'en',
|
|
20
|
+
locale: 'en-US',
|
|
21
|
+
brand: {
|
|
22
|
+
name: 'YOUR BRAND',
|
|
23
|
+
tagline: 'PRODUCT',
|
|
24
|
+
eyebrow: 'Your Brand',
|
|
25
|
+
color: '#dc2626',
|
|
26
|
+
colorSoft: '#f87171',
|
|
27
|
+
logo: null,
|
|
28
|
+
},
|
|
29
|
+
template: 'classic',
|
|
30
|
+
sections: {},
|
|
31
|
+
strings: {
|
|
32
|
+
recapTitle: 'In short',
|
|
33
|
+
stepsLabel: { one: 'step', other: 'steps' },
|
|
34
|
+
secondsLabel: { one: 'second', other: 'seconds' },
|
|
35
|
+
},
|
|
36
|
+
music: { file: null, lufs: -28, lufsUnderNarration: -34 },
|
|
37
|
+
voice: {
|
|
38
|
+
provider: 'openai',
|
|
39
|
+
instructions: 'Like a colleague showing a feature on a screen share: natural and conversational, a brisk, easy pace with no gaps between words; light and confident. Native pronunciation of the language. Not an announcer, no exaggerated enthusiasm.',
|
|
40
|
+
lufs: -16,
|
|
41
|
+
},
|
|
42
|
+
record: {
|
|
43
|
+
viewport: { width: 1440, height: 900 },
|
|
44
|
+
deviceScaleFactor: 2,
|
|
45
|
+
extraHTTPHeaders: { 'X-Demo-Recording': '1' },
|
|
46
|
+
hideSelectors: [],
|
|
47
|
+
personaDomain: 'example.com',
|
|
48
|
+
cursor: 'layer',
|
|
49
|
+
capture: 'screencast',
|
|
50
|
+
mobile: { device: 'Pixel 7' },
|
|
51
|
+
square: { viewport: { width: 1080, height: 1080 } },
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Local-only UI that should never be filmed, hidden in every project on top of
|
|
56
|
+
* `record.hideSelectors`.
|
|
57
|
+
*/
|
|
58
|
+
export const COMMON_DEV_CHROME = [
|
|
59
|
+
'.phpdebugbar',
|
|
60
|
+
'.phpdebugbar-openhandler', // Laravel Debugbar
|
|
61
|
+
'form[action*="filament-developer-logins"]', // Filament developer logins (login page buttons)
|
|
62
|
+
'[wire\\:snapshot*="menu-logins"]', // … and its "Switch to" topbar menu
|
|
63
|
+
'.sf-toolbar',
|
|
64
|
+
'.sf-minitoolbar', // Symfony web debug toolbar
|
|
65
|
+
'#djDebug', // Django Debug Toolbar
|
|
66
|
+
'.profiler-results', // rack-mini-profiler
|
|
67
|
+
'vite-error-overlay', // Vite
|
|
68
|
+
'nextjs-portal', // Next.js dev indicator
|
|
69
|
+
'#__next-build-watcher',
|
|
70
|
+
'[data-nextjs-toast]',
|
|
71
|
+
'#nuxt-devtools-container', // Nuxt DevTools
|
|
72
|
+
'astro-dev-toolbar', // Astro dev toolbar
|
|
73
|
+
'#webpack-dev-server-client-overlay',
|
|
74
|
+
];
|
|
75
|
+
export function loadConfig(...startDirs) {
|
|
76
|
+
const path = [...startDirs, process.cwd()]
|
|
77
|
+
.map((dir) => findUp(resolve(dir), 'demo.config.json'))
|
|
78
|
+
.find((p) => p !== null);
|
|
79
|
+
if (!path) {
|
|
80
|
+
return { ...DEFAULTS, root: process.cwd(), path: null };
|
|
81
|
+
}
|
|
82
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
83
|
+
const problems = validate(raw, loadSchema(CONFIG_SCHEMA_PATH));
|
|
84
|
+
if (problems.length) {
|
|
85
|
+
throw new ConfigError(path, problems);
|
|
86
|
+
}
|
|
87
|
+
// The language's built-in strings (plurals, recap title) first; demo.config.json `strings` on top.
|
|
88
|
+
const merged = deepMerge({ ...DEFAULTS, strings: stringsFor(raw.language ?? DEFAULTS.language) }, raw);
|
|
89
|
+
// Plural forms replace the defaults as a whole: merging would leak "step" into { other: "pași" }.
|
|
90
|
+
for (const key of ['stepsLabel', 'secondsLabel']) {
|
|
91
|
+
const own = raw.strings?.[key];
|
|
92
|
+
if (own !== undefined) {
|
|
93
|
+
merged.strings[key] = own;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { ...merged, root: dirname(path), path };
|
|
97
|
+
}
|
|
98
|
+
export class ConfigError extends Error {
|
|
99
|
+
file;
|
|
100
|
+
problems;
|
|
101
|
+
constructor(file, problems) {
|
|
102
|
+
super(`${file} is invalid:\n ${problems.join('\n ')}`);
|
|
103
|
+
this.file = file;
|
|
104
|
+
this.problems = problems;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** "1 step", "4 steps", "20 de pași": the count followed by the right plural form. */
|
|
108
|
+
export function countLabel(count, label, language) {
|
|
109
|
+
if (typeof label === 'string') {
|
|
110
|
+
return `${count} ${label}`;
|
|
111
|
+
}
|
|
112
|
+
const category = new Intl.PluralRules(language).select(count);
|
|
113
|
+
return `${count} ${label[category] ?? label.other}`;
|
|
114
|
+
}
|
|
115
|
+
/** Resolves a path from the config against the project root. */
|
|
116
|
+
export function fromRoot(config, path) {
|
|
117
|
+
return isAbsolute(path) ? path : resolve(config.root, path);
|
|
118
|
+
}
|
|
119
|
+
function findUp(dir, name) {
|
|
120
|
+
let current = dir;
|
|
121
|
+
while (true) {
|
|
122
|
+
const candidate = resolve(current, name);
|
|
123
|
+
if (existsSync(candidate)) {
|
|
124
|
+
return candidate;
|
|
125
|
+
}
|
|
126
|
+
const parent = dirname(current);
|
|
127
|
+
if (parent === current) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
current = parent;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function deepMerge(base, override) {
|
|
134
|
+
if (!isPlainObject(base) || !isPlainObject(override)) {
|
|
135
|
+
return (override === undefined ? base : override);
|
|
136
|
+
}
|
|
137
|
+
const out = { ...base };
|
|
138
|
+
for (const [key, value] of Object.entries(override)) {
|
|
139
|
+
if (key.startsWith('$')) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
out[key] = deepMerge(base[key], value);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function isPlainObject(v) {
|
|
147
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
148
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project settings shared by reelson-record and reelson-compose.
|
|
3
|
+
*
|
|
4
|
+
* Each project that uses the kit keeps a `demo.config.json` at its root (copy
|
|
5
|
+
* demo.config.example.json from the kit). The scripts find it by walking up
|
|
6
|
+
* from the scenario / demo directory, then from the working directory. Every
|
|
7
|
+
* field is optional; DEFAULTS below apply to whatever is missing, so a project
|
|
8
|
+
* without a config still works (neutral brand, English strings, no music).
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, isAbsolute, resolve } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { stringsFor } from './languages.ts'
|
|
14
|
+
import { loadSchema, validate } from './validate.ts'
|
|
15
|
+
|
|
16
|
+
/** JSON Schema for demo.config.json (editors pick it up through `$schema`). */
|
|
17
|
+
export const CONFIG_SCHEMA_PATH = resolve(
|
|
18
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
19
|
+
'../schemas/demo.config.schema.json',
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
/** A noun that follows a number: one string, or forms per Intl.PluralRules category. */
|
|
23
|
+
export type Label = string | Partial<Record<Intl.LDMLPluralRule, string>> & { other: string }
|
|
24
|
+
|
|
25
|
+
export interface DemoConfig {
|
|
26
|
+
/** Where demo folders live, relative to the project root. */
|
|
27
|
+
videosDir: string
|
|
28
|
+
/** BCP 47 language of the product UI; drives <html lang> and persona names. */
|
|
29
|
+
language: string
|
|
30
|
+
/** Browser locale while recording (Accept-Language, Intl formatting). */
|
|
31
|
+
locale: string
|
|
32
|
+
brand: {
|
|
33
|
+
/** Wordmark on the cover and the closing card, e.g. "ACME". */
|
|
34
|
+
name: string
|
|
35
|
+
/** Small spaced line under the wordmark, e.g. "PLATFORM". Empty hides it. */
|
|
36
|
+
tagline: string
|
|
37
|
+
/** Label above the recap title, e.g. "Acme". */
|
|
38
|
+
eyebrow: string
|
|
39
|
+
/** Primary accent (numbers, lines, click ring). Any CSS color. */
|
|
40
|
+
color: string
|
|
41
|
+
/** Lighter accent for gradients. */
|
|
42
|
+
colorSoft: string
|
|
43
|
+
/** Logo image (SVG, PNG or WebP) shown instead of the text wordmark; relative to the project root. */
|
|
44
|
+
logo: string | null
|
|
45
|
+
}
|
|
46
|
+
/** Template folder name under reelson-compose/templates/ (or <videosDir>/_templates/). */
|
|
47
|
+
template: string
|
|
48
|
+
/** Section per slot (intro, recap, outro) over the template's defaults; "recap": "none" drops the recap. */
|
|
49
|
+
sections: { intro?: string; recap?: string; outro?: string }
|
|
50
|
+
/** Every piece of on-card text that is not per-video. */
|
|
51
|
+
strings: {
|
|
52
|
+
recapTitle: string
|
|
53
|
+
/** Chip on the cover: "{steps} {stepsLabel} · {seconds} {secondsLabel}". */
|
|
54
|
+
stepsLabel: Label
|
|
55
|
+
secondsLabel: Label
|
|
56
|
+
}
|
|
57
|
+
music: {
|
|
58
|
+
/** Audio file laid under every video, relative to the project root. null = no music. */
|
|
59
|
+
file: string | null
|
|
60
|
+
/** Integrated loudness of the bed under silent footage / under narration. */
|
|
61
|
+
lufs: number
|
|
62
|
+
lufsUnderNarration: number
|
|
63
|
+
}
|
|
64
|
+
/** Voice-over (video.json "voice": true): each callout spoken by a text-to-speech provider. */
|
|
65
|
+
voice: {
|
|
66
|
+
/** openai (or a local OpenAI-compatible server), elevenlabs, piper (local), command (local). */
|
|
67
|
+
provider: 'openai' | 'elevenlabs' | 'piper' | 'command'
|
|
68
|
+
/** The provider's model (default: its own, e.g. gpt-4o-mini-tts, eleven_multilingual_v2). */
|
|
69
|
+
model?: string
|
|
70
|
+
/** The provider's voice (default: its own; Piper: one for the project's language). */
|
|
71
|
+
voice?: string
|
|
72
|
+
/** How to speak (tone, pace, accent) — for models that take instructions (gpt-4o-mini-tts). */
|
|
73
|
+
instructions: string
|
|
74
|
+
/** Speaking rate, 1 = normal (OpenAI tts-1, ElevenLabs 0.7–1.2, Piper). */
|
|
75
|
+
speed?: number
|
|
76
|
+
/** openai: API root of a local OpenAI-compatible server. */
|
|
77
|
+
baseURL?: string
|
|
78
|
+
/** command: argv with {text} {out} {voice} {model} {speed} {language}. */
|
|
79
|
+
command?: string[]
|
|
80
|
+
/** Extra request fields (openai, elevenlabs) or --flags (piper). */
|
|
81
|
+
options?: Record<string, unknown>
|
|
82
|
+
/** Integrated loudness of the voice track. */
|
|
83
|
+
lufs: number
|
|
84
|
+
}
|
|
85
|
+
record: {
|
|
86
|
+
viewport: { width: number; height: number }
|
|
87
|
+
/** Capture pixel ratio; 2 keeps UI text sharp in the 1080p frame and under zooms. */
|
|
88
|
+
deviceScaleFactor: number
|
|
89
|
+
/** Sent with every request so the app can switch off local-only prefills on camera. */
|
|
90
|
+
extraHTTPHeaders: Record<string, string>
|
|
91
|
+
/** CSS selectors hidden in every recorded page (dev toolbars, env badges). */
|
|
92
|
+
hideSelectors: string[]
|
|
93
|
+
/** Email domain for demo.persona(). Use one that resolves if a gateway validates emails. */
|
|
94
|
+
personaDomain: string
|
|
95
|
+
/**
|
|
96
|
+
* 'layer': the cursor is logged, not filmed, and the video draws it (constant size under
|
|
97
|
+
* zooms, restyled without re-recording). 'recorded': drawn into the page and filmed.
|
|
98
|
+
*/
|
|
99
|
+
cursor: 'layer' | 'recorded'
|
|
100
|
+
/**
|
|
101
|
+
* 'screencast': every frame Chrome paints (up to 60 fps), assembled into a smooth
|
|
102
|
+
* 30 fps recording. 'playwright': Playwright's own 25 fps video (the pre-0.5 way).
|
|
103
|
+
*/
|
|
104
|
+
capture: 'screencast' | 'playwright'
|
|
105
|
+
/** `reelson record --mobile`: the Playwright device the phone take uses. */
|
|
106
|
+
mobile: { device: string }
|
|
107
|
+
/** `reelson record --square`: the browser the square take uses (CSS px). */
|
|
108
|
+
square: { viewport: { width: number; height: number } }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const DEFAULTS: DemoConfig = {
|
|
113
|
+
videosDir: 'docs/videos',
|
|
114
|
+
language: 'en',
|
|
115
|
+
locale: 'en-US',
|
|
116
|
+
brand: {
|
|
117
|
+
name: 'YOUR BRAND',
|
|
118
|
+
tagline: 'PRODUCT',
|
|
119
|
+
eyebrow: 'Your Brand',
|
|
120
|
+
color: '#dc2626',
|
|
121
|
+
colorSoft: '#f87171',
|
|
122
|
+
logo: null,
|
|
123
|
+
},
|
|
124
|
+
template: 'classic',
|
|
125
|
+
sections: {},
|
|
126
|
+
strings: {
|
|
127
|
+
recapTitle: 'In short',
|
|
128
|
+
stepsLabel: { one: 'step', other: 'steps' },
|
|
129
|
+
secondsLabel: { one: 'second', other: 'seconds' },
|
|
130
|
+
},
|
|
131
|
+
music: { file: null, lufs: -28, lufsUnderNarration: -34 },
|
|
132
|
+
voice: {
|
|
133
|
+
provider: 'openai',
|
|
134
|
+
instructions:
|
|
135
|
+
'Like a colleague showing a feature on a screen share: natural and conversational, a brisk, easy pace with no gaps between words; light and confident. Native pronunciation of the language. Not an announcer, no exaggerated enthusiasm.',
|
|
136
|
+
lufs: -16,
|
|
137
|
+
},
|
|
138
|
+
record: {
|
|
139
|
+
viewport: { width: 1440, height: 900 },
|
|
140
|
+
deviceScaleFactor: 2,
|
|
141
|
+
extraHTTPHeaders: { 'X-Demo-Recording': '1' },
|
|
142
|
+
hideSelectors: [],
|
|
143
|
+
personaDomain: 'example.com',
|
|
144
|
+
cursor: 'layer',
|
|
145
|
+
capture: 'screencast',
|
|
146
|
+
mobile: { device: 'Pixel 7' },
|
|
147
|
+
square: { viewport: { width: 1080, height: 1080 } },
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Local-only UI that should never be filmed, hidden in every project on top of
|
|
153
|
+
* `record.hideSelectors`.
|
|
154
|
+
*/
|
|
155
|
+
export const COMMON_DEV_CHROME = [
|
|
156
|
+
'.phpdebugbar',
|
|
157
|
+
'.phpdebugbar-openhandler', // Laravel Debugbar
|
|
158
|
+
'form[action*="filament-developer-logins"]', // Filament developer logins (login page buttons)
|
|
159
|
+
'[wire\\:snapshot*="menu-logins"]', // … and its "Switch to" topbar menu
|
|
160
|
+
'.sf-toolbar',
|
|
161
|
+
'.sf-minitoolbar', // Symfony web debug toolbar
|
|
162
|
+
'#djDebug', // Django Debug Toolbar
|
|
163
|
+
'.profiler-results', // rack-mini-profiler
|
|
164
|
+
'vite-error-overlay', // Vite
|
|
165
|
+
'nextjs-portal', // Next.js dev indicator
|
|
166
|
+
'#__next-build-watcher',
|
|
167
|
+
'[data-nextjs-toast]',
|
|
168
|
+
'#nuxt-devtools-container', // Nuxt DevTools
|
|
169
|
+
'astro-dev-toolbar', // Astro dev toolbar
|
|
170
|
+
'#webpack-dev-server-client-overlay',
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
export interface LoadedConfig extends DemoConfig {
|
|
174
|
+
/** Directory holding demo.config.json (or the working directory without one). */
|
|
175
|
+
root: string
|
|
176
|
+
/** Path of the config file, when one was found. */
|
|
177
|
+
path: string | null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function loadConfig(...startDirs: string[]): LoadedConfig {
|
|
181
|
+
const path = [...startDirs, process.cwd()]
|
|
182
|
+
.map((dir) => findUp(resolve(dir), 'demo.config.json'))
|
|
183
|
+
.find((p): p is string => p !== null)
|
|
184
|
+
if (!path) {
|
|
185
|
+
return { ...DEFAULTS, root: process.cwd(), path: null }
|
|
186
|
+
}
|
|
187
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as Partial<DemoConfig>
|
|
188
|
+
const problems = validate(raw, loadSchema(CONFIG_SCHEMA_PATH))
|
|
189
|
+
if (problems.length) {
|
|
190
|
+
throw new ConfigError(path, problems)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The language's built-in strings (plurals, recap title) first; demo.config.json `strings` on top.
|
|
194
|
+
const merged = deepMerge({ ...DEFAULTS, strings: stringsFor(raw.language ?? DEFAULTS.language) }, raw)
|
|
195
|
+
// Plural forms replace the defaults as a whole: merging would leak "step" into { other: "pași" }.
|
|
196
|
+
for (const key of ['stepsLabel', 'secondsLabel'] as const) {
|
|
197
|
+
const own = raw.strings?.[key]
|
|
198
|
+
if (own !== undefined) {
|
|
199
|
+
merged.strings[key] = own
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { ...merged, root: dirname(path), path }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export class ConfigError extends Error {
|
|
207
|
+
file: string
|
|
208
|
+
problems: string[]
|
|
209
|
+
|
|
210
|
+
constructor(file: string, problems: string[]) {
|
|
211
|
+
super(`${file} is invalid:\n ${problems.join('\n ')}`)
|
|
212
|
+
this.file = file
|
|
213
|
+
this.problems = problems
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** "1 step", "4 steps", "20 de pași": the count followed by the right plural form. */
|
|
218
|
+
export function countLabel(count: number, label: Label, language: string): string {
|
|
219
|
+
if (typeof label === 'string') {
|
|
220
|
+
return `${count} ${label}`
|
|
221
|
+
}
|
|
222
|
+
const category = new Intl.PluralRules(language).select(count)
|
|
223
|
+
|
|
224
|
+
return `${count} ${label[category] ?? label.other}`
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Resolves a path from the config against the project root. */
|
|
228
|
+
export function fromRoot(config: LoadedConfig, path: string): string {
|
|
229
|
+
return isAbsolute(path) ? path : resolve(config.root, path)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function findUp(dir: string, name: string): string | null {
|
|
233
|
+
let current = dir
|
|
234
|
+
while (true) {
|
|
235
|
+
const candidate = resolve(current, name)
|
|
236
|
+
if (existsSync(candidate)) {
|
|
237
|
+
return candidate
|
|
238
|
+
}
|
|
239
|
+
const parent = dirname(current)
|
|
240
|
+
if (parent === current) {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
current = parent
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function deepMerge<T>(base: T, override: unknown): T {
|
|
248
|
+
if (!isPlainObject(base) || !isPlainObject(override)) {
|
|
249
|
+
return (override === undefined ? base : override) as T
|
|
250
|
+
}
|
|
251
|
+
const out: Record<string, unknown> = { ...base }
|
|
252
|
+
for (const [key, value] of Object.entries(override)) {
|
|
253
|
+
if (key.startsWith('$')) {
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
out[key] = deepMerge((base as Record<string, unknown>)[key], value)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return out as T
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
263
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
264
|
+
}
|