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.
Files changed (105) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +15 -0
  4. package/README.md +180 -0
  5. package/bin/reelson.js +649 -0
  6. package/bin/reelson.ts +676 -0
  7. package/bin/run.js +5 -0
  8. package/demo.config.example.json +51 -0
  9. package/docs/demo.webp +0 -0
  10. package/docs/prompting.md +72 -0
  11. package/docs/style-guide.md +117 -0
  12. package/examples/demo.config.json +18 -0
  13. package/examples/todo-add-item/scenario.ts +45 -0
  14. package/examples/todo-add-item/video.json +35 -0
  15. package/music/README.md +7 -0
  16. package/package.json +68 -0
  17. package/skills/reelson-compose/SKILL.md +226 -0
  18. package/skills/reelson-compose/schemas/video.schema.json +172 -0
  19. package/skills/reelson-compose/scripts/build.js +424 -0
  20. package/skills/reelson-compose/scripts/build.ts +532 -0
  21. package/skills/reelson-compose/scripts/captions.js +24 -0
  22. package/skills/reelson-compose/scripts/captions.ts +40 -0
  23. package/skills/reelson-compose/scripts/check.js +80 -0
  24. package/skills/reelson-compose/scripts/check.ts +93 -0
  25. package/skills/reelson-compose/scripts/composition.js +163 -0
  26. package/skills/reelson-compose/scripts/composition.ts +241 -0
  27. package/skills/reelson-compose/scripts/hyperframes.js +138 -0
  28. package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
  29. package/skills/reelson-compose/scripts/portrait.js +150 -0
  30. package/skills/reelson-compose/scripts/portrait.ts +218 -0
  31. package/skills/reelson-compose/scripts/project.js +238 -0
  32. package/skills/reelson-compose/scripts/project.ts +324 -0
  33. package/skills/reelson-compose/scripts/studio.js +330 -0
  34. package/skills/reelson-compose/scripts/studio.ts +394 -0
  35. package/skills/reelson-compose/scripts/timeline.js +271 -0
  36. package/skills/reelson-compose/scripts/timeline.ts +514 -0
  37. package/skills/reelson-compose/scripts/tts.js +303 -0
  38. package/skills/reelson-compose/scripts/tts.ts +371 -0
  39. package/skills/reelson-compose/scripts/verify.js +190 -0
  40. package/skills/reelson-compose/scripts/verify.ts +209 -0
  41. package/skills/reelson-compose/scripts/voice.js +178 -0
  42. package/skills/reelson-compose/scripts/voice.ts +224 -0
  43. package/skills/reelson-compose/scripts/zooms.js +259 -0
  44. package/skills/reelson-compose/scripts/zooms.ts +333 -0
  45. package/skills/reelson-compose/sections/README.md +5 -0
  46. package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
  47. package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
  48. package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
  49. package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
  50. package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
  51. package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
  52. package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
  53. package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
  54. package/skills/reelson-compose/sections/intro/split/section.css +30 -0
  55. package/skills/reelson-compose/sections/intro/split/section.html +12 -0
  56. package/skills/reelson-compose/sections/intro/split/section.js +19 -0
  57. package/skills/reelson-compose/sections/intro/split/section.json +9 -0
  58. package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
  59. package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
  60. package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
  61. package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
  62. package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
  63. package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
  64. package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
  65. package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
  66. package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
  67. package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
  68. package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
  69. package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
  70. package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
  71. package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
  72. package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
  73. package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
  74. package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
  75. package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
  76. package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
  77. package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
  78. package/skills/reelson-compose/studio/index.html +81 -0
  79. package/skills/reelson-compose/studio/studio.css +142 -0
  80. package/skills/reelson-compose/studio/studio.js +1031 -0
  81. package/skills/reelson-compose/templates/README.md +193 -0
  82. package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
  83. package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
  84. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
  85. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
  86. package/skills/reelson-compose/templates/classic/stage.html +447 -0
  87. package/skills/reelson-compose/templates/classic/template.json +5 -0
  88. package/skills/reelson-record/SKILL.md +220 -0
  89. package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
  90. package/skills/reelson-record/scripts/capture.js +142 -0
  91. package/skills/reelson-record/scripts/capture.ts +190 -0
  92. package/skills/reelson-record/scripts/config.js +148 -0
  93. package/skills/reelson-record/scripts/config.ts +264 -0
  94. package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
  95. package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
  96. package/skills/reelson-record/scripts/doctor.js +128 -0
  97. package/skills/reelson-record/scripts/doctor.ts +147 -0
  98. package/skills/reelson-record/scripts/languages.js +29 -0
  99. package/skills/reelson-record/scripts/languages.ts +41 -0
  100. package/skills/reelson-record/scripts/record.js +365 -0
  101. package/skills/reelson-record/scripts/record.ts +451 -0
  102. package/skills/reelson-record/scripts/scenario.js +356 -0
  103. package/skills/reelson-record/scripts/scenario.ts +570 -0
  104. package/skills/reelson-record/scripts/validate.js +143 -0
  105. 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
+ }