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
package/bin/reelson.ts ADDED
@@ -0,0 +1,676 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * reelson — scripted Playwright walkthroughs → branded HyperFrames demo videos.
4
+ * Run `reelson help` for the commands. Project settings come from the nearest
5
+ * demo.config.json (walking up from the working directory).
6
+ */
7
+ import { spawnSync } from 'node:child_process'
8
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
9
+ import { createRequire } from 'node:module'
10
+ import { homedir } from 'node:os'
11
+ import { basename, relative, resolve } from 'node:path'
12
+ import { parseArgs, parseEnv } from 'node:util'
13
+ import { CONFIG_SCHEMA_PATH, ConfigError, fromRoot, loadConfig, type LoadedConfig } from '../skills/reelson-record/scripts/config.ts'
14
+ import { doctor } from '../skills/reelson-record/scripts/doctor.ts'
15
+ import { build, plan, type BuildOptions } from '../skills/reelson-compose/scripts/build.ts'
16
+ import { captionCues, toSrt, toVtt } from '../skills/reelson-compose/scripts/captions.ts'
17
+ import { check } from '../skills/reelson-compose/scripts/check.ts'
18
+ import { studio } from '../skills/reelson-compose/scripts/studio.ts'
19
+ import { fetchLines, spokenTexts, voiceSettings, type VoiceSettings } from '../skills/reelson-compose/scripts/voice.ts'
20
+ import { listVoices, missingSetup, PROVIDERS, type Provider } from '../skills/reelson-compose/scripts/tts.ts'
21
+ import { TAKES, verify } from '../skills/reelson-compose/scripts/verify.ts'
22
+ import { DRAFT_FLAGS, FPS, gifIfChanged, hyperframes, hyperframesOn, RENDER_FLAGS, renderIfChanged } from '../skills/reelson-compose/scripts/hyperframes.ts'
23
+ import { catalog, KIT_ROOT, listDemos, RECORD_SCRIPT, ReelsonError, resolveDemoDir } from '../skills/reelson-compose/scripts/project.ts'
24
+ import { SLOTS, type SectionChoice, type Timeline } from '../skills/reelson-compose/scripts/timeline.ts'
25
+
26
+ const HELP = `reelson — scripted walkthroughs → branded demo videos
27
+
28
+ Usage: reelson <command> [options]
29
+
30
+ install [<project-dir>] [--global] [--no-browser]
31
+ link the reelson-record + reelson-compose skills into
32
+ <project>/.claude/skills/ (default: this directory; --global:
33
+ ~/.claude/skills/), create demo.config.json if missing and
34
+ download Playwright's Chromium (--no-browser: skip it)
35
+ init create demo.config.json in this directory
36
+ doctor check the tools and that the cursor layer lines up here
37
+ new <slug> [--url <origin>] start <videosDir>/<slug>/scenario.ts
38
+ record <slug> [--headed] [--mobile | --square | --all-takes]
39
+ run the scenario → recording.mp4 + markers.json; --mobile: on a
40
+ phone (record.mobile.device) → recording.mobile.mp4, for --portrait;
41
+ --square: in a square browser (record.square.viewport) →
42
+ recording.square.mp4, for --square; --all-takes: all three
43
+ build <slug> [options] video.json → video/ (HyperFrames project)
44
+ --title, --subtitle, --template <name>
45
+ --intro <name>, --recap <name|none>, --outro <name>
46
+ --trim-start <s|auto>, --trim-end <s>, --music <file> | --no-music
47
+ voice <slug> speak the voice-over lines not spoken yet (video.json "voice": true;
48
+ demo.config.json voice.provider: openai, elevenlabs, piper (local)
49
+ or command (local); cached in <demo>/voice/)
50
+ voices [--provider <name>] [--all] [--library]
51
+ the voices to pick for voice.voice (openai; elevenlabs: your
52
+ account's, --library: the Voice Library's in the project's
53
+ language, paid plans; piper: the project's language, --all: every one)
54
+ check <slug> [--no-hyperframes] schemas, zoom timing, hyperframes lint
55
+ verify <slug...> | --all [--update]
56
+ re-record every take (desktop, phone, square) headless into a
57
+ scratch folder and check video.json still fits (markers, click
58
+ numbers, zooms); --update keeps them
59
+ snapshot <slug> --at 1,3.5,8 [--portrait | --square]
60
+ PNG frames into video/snapshots/ (…/portrait/, …/square/)
61
+ studio <slug> [--port 4800] [--no-open]
62
+ preview + edit on a timeline of every layer (saves video.json)
63
+ preview <slug> open the HyperFrames studio (raw composition)
64
+ templates list templates and intro/recap/outro sections
65
+ render <slug...> | --all [--gif] [--square] [--portrait] [--all-formats] [--only <format>]
66
+ [--draft] [--force] [--no-build]
67
+ build + render video/renders/<slug>.mp4 + .srt/.vtt captions;
68
+ skips a video unchanged since its last render (--force);
69
+ --gif, --square (1080², the square take filling the frame),
70
+ --portrait (1080x1920 phone layout) add versions, --all-formats
71
+ both (also video.json "formats": ["portrait", "square"]);
72
+ --only landscape|portrait|square renders just that version;
73
+ --draft: a 2x faster 15 fps look → renders/<slug>.draft.mp4
74
+
75
+ <slug> is a folder under videosDir, or a path to a demo folder or its scenario.ts.
76
+ Docs: ${KIT_ROOT}/README.md`
77
+
78
+ const [command, ...rest] = process.argv.slice(2)
79
+ const SECRETS = ['OPENAI_API_KEY', 'ELEVENLABS_API_KEY']
80
+
81
+ try {
82
+ loadSecrets()
83
+ process.exitCode = await run(command, rest)
84
+ } catch (error) {
85
+ if (error instanceof ReelsonError || error instanceof ConfigError) {
86
+ console.error(`reelson: ${error.message}`)
87
+ process.exitCode = 1
88
+ } else {
89
+ throw error
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Picks the secrets reelson reads (OPENAI_API_KEY, ELEVENLABS_API_KEY) up from a .env next to demo.config.json, else from one in the reelson checkout;
95
+ * a variable already in the environment wins. The rest of those files is left alone.
96
+ */
97
+ function loadSecrets(): void {
98
+ for (const dir of [loadConfig(process.cwd()).root, KIT_ROOT]) {
99
+ const file = resolve(dir, '.env')
100
+ if (!existsSync(file)) {
101
+ continue
102
+ }
103
+ const values = parseEnv(readFileSync(file, 'utf8'))
104
+ for (const name of SECRETS) {
105
+ if (!process.env[name] && values[name]) {
106
+ process.env[name] = values[name]
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ async function run(cmd: string | undefined, argv: string[]): Promise<number> {
113
+ switch (cmd) {
114
+ case undefined:
115
+ case 'help':
116
+ case '--help':
117
+ case '-h':
118
+ console.log(HELP)
119
+ return 0
120
+ case '--version':
121
+ case '-v':
122
+ console.log(JSON.parse(readFileSync(resolve(KIT_ROOT, 'package.json'), 'utf8')).version)
123
+ return 0
124
+ case 'install':
125
+ return install(argv)
126
+ case 'init':
127
+ return init()
128
+ case 'doctor':
129
+ const problems = await doctor()
130
+ voiceDoctor()
131
+ return problems ? 1 : 0
132
+ case 'new':
133
+ return create(argv)
134
+ case 'record':
135
+ return record(argv)
136
+ case 'build':
137
+ return buildCommand(argv)
138
+ case 'voice':
139
+ return voiceCommand(argv)
140
+ case 'voices':
141
+ return voicesCommand(argv)
142
+ case 'check':
143
+ return checkCommand(argv)
144
+ case 'verify':
145
+ return verifyCommand(argv)
146
+ case 'snapshot':
147
+ return snapshot(argv)
148
+ case 'studio':
149
+ return studioCommand(argv)
150
+ case 'preview':
151
+ return preview(argv)
152
+ case 'render':
153
+ return await render(argv)
154
+ case 'templates':
155
+ return templates()
156
+ default:
157
+ console.error(`reelson: unknown command "${cmd}"\n\n${HELP}`)
158
+ return 2
159
+ }
160
+ }
161
+
162
+ /** `reelson doctor`'s voice-over line: can the configured provider speak here? (A note: not every project uses it.) */
163
+ function voiceDoctor(): void {
164
+ const settings = voiceSettings({ title: '', voice: true }, loadConfig(process.cwd())) as VoiceSettings
165
+ const problem = missingSetup(settings)
166
+ const what = `voice-over: ${settings.provider}${settings.voice ? ` (${settings.voice})` : ''}`
167
+ console.log(problem ? `· ${what} — for "voice": true videos: ${problem}` : `✓ ${what}`)
168
+ }
169
+
170
+ function config(): LoadedConfig {
171
+ const loaded = loadConfig(process.cwd())
172
+ if (!loaded.path) {
173
+ console.warn('reelson: no demo.config.json found — using defaults (run `reelson init`)')
174
+ }
175
+ return loaded
176
+ }
177
+
178
+ function one(positionals: string[], usage: string): string {
179
+ if (positionals.length !== 1) {
180
+ throw new ReelsonError(`usage: reelson ${usage}`)
181
+ }
182
+ return positionals[0]
183
+ }
184
+
185
+ /**
186
+ * `reelson install`: links the skills into a project (or ~/.claude) and prepares the machine. The links
187
+ * point at this installation, so `npm update -g reelson` (or `git pull` in a checkout) updates every project.
188
+ */
189
+ function install(argv: string[]): number {
190
+ const skillNames = ['reelson-record', 'reelson-compose']
191
+ // Names used before 0.7 (demo-* before that): their links are dropped when they point at a reelson install.
192
+ const oldNames = ['demo-record', 'demo-video', 'reelkit-record', 'reelkit-compose']
193
+ const { values, positionals } = parseArgs({
194
+ args: argv,
195
+ allowPositionals: true,
196
+ options: { global: { type: 'boolean' }, 'no-browser': { type: 'boolean' } },
197
+ })
198
+ if (positionals.length > 1 || (values.global && positionals.length)) {
199
+ throw new ReelsonError('usage: reelson install [<project-dir> | --global] [--no-browser]')
200
+ }
201
+ const target = values.global ? homedir() : resolve(positionals[0] ?? '.')
202
+ if (!existsSync(target)) {
203
+ throw new ReelsonError(`${target} does not exist`)
204
+ }
205
+ if (spawnSync('ffmpeg', ['-version']).status !== 0) {
206
+ console.warn('warning: ffmpeg not found — brew install ffmpeg')
207
+ }
208
+ if (!values['no-browser']) {
209
+ console.log('Installing Playwright\'s Chromium (skipped when it is already there)…')
210
+ const cli = createRequire(import.meta.url).resolve('@playwright/test/cli')
211
+ const status = spawnSync(process.execPath, [cli, 'install', 'chromium'], { stdio: 'inherit' }).status
212
+ if (status !== 0) {
213
+ throw new ReelsonError('could not install Chromium — run `npx playwright install chromium`')
214
+ }
215
+ }
216
+
217
+ const skills = resolve(target, '.claude/skills')
218
+ mkdirSync(skills, { recursive: true })
219
+ const isLink = (path: string): boolean => lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink() ?? false
220
+ for (const old of oldNames) {
221
+ const link = resolve(skills, old)
222
+ if (isLink(link) && (resolve(skills, readlinkSync(link)).startsWith(`${KIT_ROOT}/`) || /[\\/]skills[\\/](reelkit|reelson)-/.test(readlinkSync(link)))) {
223
+ rmSync(link)
224
+ console.log(` removed old link ${link}`)
225
+ }
226
+ }
227
+ for (const skill of skillNames) {
228
+ const link = resolve(skills, skill)
229
+ if (existsSync(link) && !isLink(link)) {
230
+ throw new ReelsonError(`${link} is a folder, not a link to reelson — move it away and run install again`)
231
+ }
232
+ if (isLink(link)) {
233
+ rmSync(link)
234
+ }
235
+ symlinkSync(resolve(KIT_ROOT, 'skills', skill), link, 'dir')
236
+ console.log(` linked ${link}`)
237
+ }
238
+
239
+ if (!values.global) {
240
+ if (!existsSync(resolve(target, 'demo.config.json'))) {
241
+ init(target)
242
+ }
243
+ const videos = loadConfig(target).videosDir.replace(/^\.?\/+|\/+$/g, '')
244
+ console.log(`
245
+ Add to ${resolve(target, '.gitignore')}:
246
+
247
+ /${videos}/**/recording*.mp4
248
+ /${videos}/**/.raw*/
249
+ /${videos}/**/video/
250
+ /${videos}/**/*.openscreen`)
251
+ }
252
+ console.log('\nDone. Try: reelson doctor')
253
+ return 0
254
+ }
255
+
256
+ function init(dir = process.cwd()): number {
257
+ const target = resolve(dir, 'demo.config.json')
258
+ if (existsSync(target)) {
259
+ throw new ReelsonError(`${target} already exists`)
260
+ }
261
+ const example = JSON.parse(readFileSync(resolve(KIT_ROOT, 'demo.config.example.json'), 'utf8'))
262
+ delete example.$comment
263
+ delete example.$schema
264
+ const viaProject = resolve(dir, '.claude/skills/reelson-record/schemas/demo.config.schema.json')
265
+ const schema = existsSync(viaProject) ? viaProject : CONFIG_SCHEMA_PATH
266
+ let schemaRef = relative(dir, schema)
267
+ if (!/^\.{1,2}\//.test(schemaRef) && !schemaRef.startsWith('/')) {
268
+ schemaRef = `./${schemaRef}`
269
+ }
270
+ writeFileSync(target, JSON.stringify({ $schema: schemaRef, ...example }, null, 4) + '\n')
271
+ console.log(`created ${target} — set brand, language and music`)
272
+ return 0
273
+ }
274
+
275
+ function create(argv: string[]): number {
276
+ const { values, positionals } = parseArgs({
277
+ args: argv,
278
+ allowPositionals: true,
279
+ options: { url: { type: 'string' } },
280
+ })
281
+ const slug = one(positionals, 'new <slug> [--url https://app.test]')
282
+ const cfg = config()
283
+ const dir = resolve(fromRoot(cfg, cfg.videosDir), slug)
284
+ const scenario = resolve(dir, 'scenario.ts')
285
+ if (existsSync(scenario)) {
286
+ throw new ReelsonError(`${scenario} already exists`)
287
+ }
288
+ mkdirSync(dir, { recursive: true })
289
+ const viaProject = resolve(cfg.root, '.claude/skills/reelson-record/scripts/scenario.ts')
290
+ const typesFile = existsSync(viaProject) ? viaProject : resolve(KIT_ROOT, 'skills/reelson-record/scripts/scenario.ts')
291
+ let importPath = relative(dir, typesFile)
292
+ if (!/^\.{1,2}\//.test(importPath)) {
293
+ importPath = `./${importPath}`
294
+ }
295
+ writeFileSync(
296
+ scenario,
297
+ `/**
298
+ * Demo: ${slug}
299
+ *
300
+ * reelson record ${slug} (--headed to watch)
301
+ * reelson build ${slug} --title "..."
302
+ *
303
+ * Call demo.marker('Step text') right after the UI reaches each state worth a
304
+ * callout (≤ 10); each becomes a callout in video.json, shown from the start of
305
+ * that step (the first glide/click after the previous marker).
306
+ */
307
+ import type { Scenario } from '${importPath}'
308
+
309
+ export default {
310
+ name: '${slug}',
311
+ baseURL: '${values.url ?? 'https://app.test'}',
312
+ async run(demo) {
313
+ // Log in with the project's helpers here (it is trimmed away later), then:
314
+ await demo.goto('/')
315
+ demo.marker('First step')
316
+
317
+ // Hold so the last callout can be read before the recording ends.
318
+ await demo.pause(2000)
319
+ },
320
+ } satisfies Scenario
321
+ `,
322
+ )
323
+ console.log(`created ${scenario}`)
324
+ return 0
325
+ }
326
+
327
+ function record(argv: string[]): number {
328
+ const { values, positionals } = parseArgs({
329
+ args: argv,
330
+ allowPositionals: true,
331
+ options: {
332
+ headed: { type: 'boolean' },
333
+ mobile: { type: 'boolean' },
334
+ square: { type: 'boolean' },
335
+ 'all-takes': { type: 'boolean' },
336
+ out: { type: 'string' },
337
+ },
338
+ })
339
+ if ([values.mobile, values.square, values['all-takes']].filter(Boolean).length > 1) {
340
+ throw new ReelsonError('record: --mobile, --square or --all-takes')
341
+ }
342
+ const target = one(positionals, 'record <slug|scenario.ts> [--headed] [--mobile | --square | --all-takes]')
343
+ const scenario = target.endsWith('.ts') ? resolve(target) : resolve(resolveDemoDir(target, config()), 'scenario.ts')
344
+ if (!existsSync(scenario)) {
345
+ throw new ReelsonError(`no scenario at ${scenario} — create one with \`reelson new\``)
346
+ }
347
+ const takes = values['all-takes']
348
+ ? TAKES.map((take) => take.flags)
349
+ : [[...(values.mobile ? ['--mobile'] : []), ...(values.square ? ['--square'] : [])]]
350
+ for (const flags of takes) {
351
+ const args = [RECORD_SCRIPT, scenario, ...(values.headed ? ['--headed'] : []), ...flags, ...(values.out ? ['--out', values.out] : [])]
352
+ const status = spawnSync(process.execPath, args, { stdio: 'inherit' }).status ?? 1
353
+ if (status !== 0) {
354
+ return status
355
+ }
356
+ }
357
+ return 0
358
+ }
359
+
360
+ function buildOptions(argv: string[]): { options: BuildOptions; positionals: string[] } {
361
+ const { values, positionals } = parseArgs({
362
+ args: argv,
363
+ allowPositionals: true,
364
+ options: {
365
+ title: { type: 'string' },
366
+ subtitle: { type: 'string' },
367
+ template: { type: 'string' },
368
+ intro: { type: 'string' },
369
+ recap: { type: 'string' },
370
+ outro: { type: 'string' },
371
+ 'trim-start': { type: 'string' },
372
+ 'trim-end': { type: 'string' },
373
+ music: { type: 'string' },
374
+ 'no-music': { type: 'boolean' },
375
+ },
376
+ })
377
+ const seconds = (v: string | undefined, name: string): number | undefined => {
378
+ if (v === undefined) {
379
+ return undefined
380
+ }
381
+ const n = Number.parseFloat(v)
382
+ if (!Number.isFinite(n) || n < 0) {
383
+ throw new ReelsonError(`--${name} expects seconds, got "${v}"`)
384
+ }
385
+ return n
386
+ }
387
+ const trimPoint = (v: string | undefined, name: string) => (v === 'auto' ? 'auto' : seconds(v, name))
388
+ const cfg = config()
389
+ const options: BuildOptions = {
390
+ title: values.title,
391
+ subtitle: values.subtitle,
392
+ template: values.template,
393
+ sections: Object.fromEntries(SLOTS.filter((slot) => values[slot] !== undefined).map((slot) => [slot, values[slot]])) as SectionChoice,
394
+ trimStart: trimPoint(values['trim-start'], 'trim-start'),
395
+ trimEnd: seconds(values['trim-end'], 'trim-end'),
396
+ // video.json stores the path relative to the project root.
397
+ music: values['no-music'] ? false : values.music ? relative(cfg.root, resolve(values.music)) : undefined,
398
+ }
399
+
400
+ return { options, positionals }
401
+ }
402
+
403
+ async function buildCommand(argv: string[]): Promise<number> {
404
+ const { options, positionals } = buildOptions(argv)
405
+ const cfg = config()
406
+ const dir = resolveDemoDir(one(positionals, 'build <slug> [options]'), cfg)
407
+ build(dir, cfg, options)
408
+ // Voice-over lines not spoken yet: fetch them, then build again to mix them in.
409
+ if (await speak(dir, cfg)) {
410
+ build(dir, cfg, { log: () => {} })
411
+ }
412
+ return 0
413
+ }
414
+
415
+ /**
416
+ * Fetches the voice-over lines this demo is missing (see voice.ts); how many it fetched.
417
+ * Unless `strict`, a failure (no OPENAI_API_KEY, offline) is a warning: the video is built
418
+ * with the lines it has.
419
+ */
420
+ async function speak(dir: string, cfg: LoadedConfig, strict = false): Promise<number> {
421
+ const { spec, timeline } = plan(dir, cfg)
422
+ const settings = voiceSettings(spec, cfg)
423
+ if (!settings) {
424
+ return 0
425
+ }
426
+ try {
427
+ return await fetchLines(spokenTexts(spec, timeline.callouts), settings, resolve(dir, 'voice'), console.log)
428
+ } catch (error) {
429
+ if (strict || !(error instanceof ReelsonError || error instanceof TypeError)) {
430
+ throw error
431
+ }
432
+ console.warn(`reelson: ${error.message} — building without those lines`)
433
+ return 0
434
+ }
435
+ }
436
+
437
+ async function voiceCommand(argv: string[]): Promise<number> {
438
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true })
439
+ const cfg = config()
440
+ const dir = resolveDemoDir(one(positionals, 'voice <slug>'), cfg)
441
+ if (!voiceSettings(plan(dir, cfg).spec, cfg)) {
442
+ throw new ReelsonError(`${basename(dir)} has no voice-over — set "voice": true in its video.json`)
443
+ }
444
+ const fetched = await speak(dir, cfg, true)
445
+ console.log(fetched ? `spoke ${fetched} line(s) into ${resolve(dir, 'voice')}` : 'every line is already spoken')
446
+ return 0
447
+ }
448
+
449
+ async function voicesCommand(argv: string[]): Promise<number> {
450
+ const { values } = parseArgs({ args: argv, options: { provider: { type: 'string' }, all: { type: 'boolean' }, library: { type: 'boolean' } } })
451
+ const cfg = loadConfig(process.cwd())
452
+ const provider = (values.provider ?? cfg.voice.provider) as Provider
453
+ if (!PROVIDERS.includes(provider)) {
454
+ throw new ReelsonError(`--provider expects ${PROVIDERS.join(', ')}, got "${provider}"`)
455
+ }
456
+ const current = voiceSettings({ title: '', voice: { provider } }, cfg) as VoiceSettings
457
+ const voices = await listVoices(provider, cfg.language, values.all, values.library)
458
+ const width = Math.min(40, Math.max(...voices.map((v) => (v.name === v.id ? v.id : `${v.name} ${v.id}`).length)))
459
+ const scope = values.library ? ` in the Voice Library for "${cfg.language}" (paid plans)` : provider === 'piper' && !values.all ? ` for "${cfg.language}"` : ''
460
+ console.log(`${provider} voices${scope} (voice.voice; * = the one used now):`)
461
+ for (const v of voices) {
462
+ const label = v.name === v.id ? v.id : `${v.name} ${v.id}`
463
+ const used = v.id === current.voice || v.name.toLowerCase() === current.voice.toLowerCase()
464
+ console.log(`${used ? '*' : ' '} ${label.padEnd(width)} ${v.about}`)
465
+ }
466
+ return 0
467
+ }
468
+
469
+ function verifyCommand(argv: string[]): number {
470
+ const { values, positionals } = parseArgs({
471
+ args: argv,
472
+ allowPositionals: true,
473
+ options: { all: { type: 'boolean' }, update: { type: 'boolean' } },
474
+ })
475
+ const cfg = config()
476
+ const dirs = values.all ? listDemos(cfg) : positionals.map((p) => resolveDemoDir(p, cfg))
477
+ if (!dirs.length) {
478
+ throw new ReelsonError(values.all ? 'no demos with a video.json under videosDir' : 'usage: reelson verify <slug...> | --all [--update]')
479
+ }
480
+ let failing = 0
481
+ for (const dir of dirs) {
482
+ if (verify(dir, cfg, { update: values.update })) {
483
+ failing++
484
+ }
485
+ }
486
+ console.log(failing ? `\n${failing} of ${dirs.length} demo(s) need attention` : `\nall ${dirs.length} demo(s) still record and fit their video.json`)
487
+ return failing ? 1 : 0
488
+ }
489
+
490
+ function checkCommand(argv: string[]): number {
491
+ const { values, positionals } = parseArgs({
492
+ args: argv,
493
+ allowPositionals: true,
494
+ options: { 'no-hyperframes': { type: 'boolean' } },
495
+ })
496
+ const cfg = config()
497
+ const problems = check(resolveDemoDir(one(positionals, 'check <slug> [--no-hyperframes]'), cfg), cfg, {
498
+ hyperframes: !values['no-hyperframes'],
499
+ })
500
+ return problems ? 1 : 0
501
+ }
502
+
503
+ function templates(): number {
504
+ const cfg = config()
505
+ const { templates: found, sections } = catalog(cfg)
506
+ const row = (name: string, description: string, local: boolean) =>
507
+ ` ${(name + (local ? ' (project)' : '')).padEnd(22)} ${description}`
508
+ console.log('Templates (the stage: background, frame, callouts) — "template":')
509
+ found.forEach((t) => console.log(row(t.name, t.description, t.local)))
510
+ for (const slot of SLOTS) {
511
+ console.log(`\nSections — "sections": { "${slot}": … }`)
512
+ sections[slot].forEach((s) => console.log(row(s.name, s.description, s.local)))
513
+ }
514
+ console.log(`\nProject: template "${cfg.template}", sections ${JSON.stringify(cfg.sections)} (video.json overrides both)`)
515
+ return 0
516
+ }
517
+
518
+ function builtVideoDir(slug: string): string {
519
+ const dir = resolve(resolveDemoDir(slug, config()), 'video')
520
+ if (!existsSync(resolve(dir, 'index.html'))) {
521
+ throw new ReelsonError(`${dir} is not built — run \`reelson build ${slug}\``)
522
+ }
523
+ return dir
524
+ }
525
+
526
+ function snapshot(argv: string[]): number {
527
+ const { values, positionals } = parseArgs({
528
+ args: argv,
529
+ allowPositionals: true,
530
+ options: { at: { type: 'string' }, portrait: { type: 'boolean' }, square: { type: 'boolean' } },
531
+ })
532
+ const slug = one(positionals, 'snapshot <slug> --at 1,3.5,8 [--portrait | --square]')
533
+ const videoDir = builtVideoDir(slug)
534
+ const format = values.portrait ? 'portrait' : values.square ? 'square' : null
535
+ const composition = format ? `${format}.html` : 'index.html'
536
+ if (!existsSync(resolve(videoDir, composition))) {
537
+ throw new ReelsonError(`no ${composition} — ${format === 'square' ? `record the square take (\`reelson record ${slug} --square\`) and ` : ''}run \`reelson build ${slug}\``)
538
+ }
539
+ let at = values.at
540
+ if (at) {
541
+ // There is no frame at the very end (it would come out blank): clamp to the last one.
542
+ const html = readFileSync(resolve(videoDir, composition), 'utf8')
543
+ const total = Number.parseFloat(/data-composition-id="main"[^>]*data-duration="([\d.]+)"/.exec(html)?.[1] ?? 'NaN')
544
+ const last = Math.floor(total * FPS - 1e-6) / FPS
545
+ at = at
546
+ .split(',')
547
+ .map((v) => {
548
+ const t = Number.parseFloat(v)
549
+ if (!Number.isFinite(t) || t < 0) {
550
+ throw new ReelsonError(`--at expects seconds, got "${v}"`)
551
+ }
552
+ if (t <= last) {
553
+ return v.trim()
554
+ }
555
+ console.log(`snapshot: ${t}s is past the last frame — using ${last.toFixed(3)}s`)
556
+ return last.toFixed(3)
557
+ })
558
+ .join(',')
559
+ }
560
+ const out = resolve(videoDir, 'snapshots', ...(format ? [format] : []))
561
+ return hyperframesOn(videoDir, composition, ['snapshot', '.', '-o', out, ...(at ? ['--at', at] : [])])
562
+ }
563
+
564
+ async function studioCommand(argv: string[]): Promise<number> {
565
+ const { values, positionals } = parseArgs({
566
+ args: argv,
567
+ allowPositionals: true,
568
+ options: { port: { type: 'string' }, 'no-open': { type: 'boolean' } },
569
+ })
570
+ const cfg = config()
571
+ const dir = resolveDemoDir(one(positionals, 'studio <slug> [--port 4800] [--no-open]'), cfg)
572
+ const port = values.port === undefined ? undefined : Number.parseInt(values.port, 10)
573
+ if (port !== undefined && !(port > 0 && port < 65536)) {
574
+ throw new ReelsonError(`--port expects a port number, got "${values.port}"`)
575
+ }
576
+ const url = await studio(dir, cfg, { port })
577
+ console.log(`studio for ${basename(dir)}: ${url} (watching video.json, markers.json, templates, sections — Ctrl+C to stop)`)
578
+ if (!values['no-open']) {
579
+ spawnSync(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], { stdio: 'ignore' })
580
+ }
581
+ // The server keeps the process alive; the exit code is set when it is stopped.
582
+ return 0
583
+ }
584
+
585
+ function preview(argv: string[]): number {
586
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true })
587
+ return hyperframes(['preview', '.'], builtVideoDir(one(positionals, 'preview <slug>')))
588
+ }
589
+
590
+ async function render(argv: string[]): Promise<number> {
591
+ const { values, positionals } = parseArgs({
592
+ args: argv,
593
+ allowPositionals: true,
594
+ options: {
595
+ all: { type: 'boolean' },
596
+ gif: { type: 'boolean' },
597
+ square: { type: 'boolean' },
598
+ portrait: { type: 'boolean' },
599
+ 'all-formats': { type: 'boolean' },
600
+ only: { type: 'string' },
601
+ draft: { type: 'boolean' },
602
+ force: { type: 'boolean' },
603
+ 'no-build': { type: 'boolean' },
604
+ },
605
+ })
606
+ if (values.only && !['landscape', 'portrait', 'square'].includes(values.only)) {
607
+ throw new ReelsonError(`--only expects landscape, portrait or square, got "${values.only}"`)
608
+ }
609
+ const cfg = config()
610
+ const dirs = values.all ? listDemos(cfg) : positionals.map((p) => resolveDemoDir(p, cfg))
611
+ if (!dirs.length) {
612
+ throw new ReelsonError(values.all ? 'no demos with a video.json under videosDir' : 'usage: reelson render <slug...> | --all')
613
+ }
614
+
615
+ let failures = 0
616
+ for (const dir of dirs) {
617
+ const slug = basename(dir)
618
+ if (!values['no-build']) {
619
+ await speak(dir, cfg)
620
+ }
621
+ const result = values['no-build'] ? { ...plan(dir, cfg), versions: null } : build(dir, cfg)
622
+ const videoDir = resolve(dir, 'video')
623
+ mkdirSync(resolve(videoDir, 'renders'), { recursive: true })
624
+ // Captions: the same words as the cards, timed to each video (the phone and square
625
+ // takes have their own timing).
626
+ const captions = (timeline: Timeline, name: string) => {
627
+ const cues = captionCues(timeline, result.spec.title, result.spec.subtitle)
628
+ writeFileSync(resolve(videoDir, `renders/${name}.srt`), toSrt(cues))
629
+ writeFileSync(resolve(videoDir, `renders/${name}.vtt`), toVtt(cues))
630
+ }
631
+ captions(result.timeline, slug)
632
+ const [video, flags] = values.draft ? [`renders/${slug}.draft.mp4`, DRAFT_FLAGS] : [`renders/${slug}.mp4`, RENDER_FLAGS]
633
+ const report = (outcome: 'rendered' | 'unchanged' | 'failed', output: string): void => {
634
+ if (outcome === 'failed') {
635
+ failures++
636
+ console.error(`reelson: render failed for ${slug} (${output})`)
637
+ return
638
+ }
639
+ console.log(`${outcome === 'unchanged' ? 'up to date' : 'rendered'} ${resolve(videoDir, output)}`)
640
+ }
641
+ if (!values.only || values.only === 'landscape') {
642
+ const outcome = renderIfChanged(videoDir, video, flags, values.force)
643
+ report(outcome, video)
644
+ // The GIF is cut from the MP4 just rendered (ffmpeg), not rendered a second time.
645
+ if (values.gif && outcome !== 'failed') {
646
+ const gif = video.replace(/\.mp4$/, '.gif')
647
+ report(gifIfChanged(videoDir, video, gif, values.force), gif)
648
+ }
649
+ }
650
+ // Asked for here (--portrait, --square, --all-formats, --only) or in video.json "formats".
651
+ const wanted = new Set(result.spec.formats ?? [])
652
+ const asked = (format: 'portrait' | 'square') =>
653
+ values.only ? values.only === format : values[format] || values['all-formats'] || wanted.has(format)
654
+ // Portrait: its own composition (tall frame, footage panning with the cursor).
655
+ if (asked('portrait')) {
656
+ if (result.versions) captions(result.versions.portrait, `${slug}.portrait`)
657
+ const output = video.replace(/\.mp4$/, '.portrait.mp4')
658
+ report(renderIfChanged(videoDir, output, flags, values.force, 'portrait.html'), output)
659
+ }
660
+ // Square: its own composition, from the square take (`reelson record --square`).
661
+ if (asked('square')) {
662
+ const output = video.replace(/\.mp4$/, '.square.mp4')
663
+ const hint = `run \`reelson record ${slug} --square\` (a square browser), then render again`
664
+ if (existsSync(resolve(videoDir, 'square.html'))) {
665
+ if (result.versions?.square) captions(result.versions.square, `${slug}.square`)
666
+ report(renderIfChanged(videoDir, output, flags, values.force, 'square.html'), output)
667
+ } else if (values.square || values.only === 'square') {
668
+ failures++
669
+ console.error(`reelson: no square take for ${slug} — ${hint}`)
670
+ } else {
671
+ console.log(`skipped the square version of ${slug}: no square take — ${hint}`)
672
+ }
673
+ }
674
+ }
675
+ return failures ? 1 : 0
676
+ }
package/bin/run.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // The `reelson` command. Node will not run TypeScript from under node_modules, so an npm install
3
+ // runs reelson.js (compiled beside reelson.ts when the package is packed); a checkout runs the source.
4
+ const installed = /[\\/]node_modules[\\/]/.test(import.meta.filename)
5
+ await import(installed ? './reelson.js' : './reelson.ts')