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,532 @@
1
+ /**
2
+ * Builds a demo's HyperFrames project from its video.json.
3
+ *
4
+ * reelson build <slug|dir> [--title ".."] [--subtitle ".."] [--trim-start s] [--trim-end s]
5
+ * [--template name] [--intro name] [--recap name|none] [--outro name]
6
+ * [--music file | --no-music]
7
+ *
8
+ * video.json is the source of truth; video/ is generated and can be deleted at
9
+ * any time. The first build creates video.json from markers.json (one callout
10
+ * per marker, a suggested trim); the flags above edit video.json in place.
11
+ *
12
+ * Writes <demo>/video/{index.html, hyperframes.json, package.json, assets/*}:
13
+ * the template's stage with the chosen intro/recap/outro sections, its own assets
14
+ * (vendored GSAP + fonts), each section's assets, the brand logo, the recording, narration
15
+ * (when the recording has audio) and the music bed (trimmed, loudness-normalised,
16
+ * faded; cached between builds).
17
+ */
18
+ import { spawnSync } from 'node:child_process'
19
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
20
+ import { basename, extname, resolve } from 'node:path'
21
+ import { countLabel, fromRoot, type LoadedConfig } from '../../reelson-record/scripts/config.ts'
22
+ import { renderComposition, type CompositionInput } from './composition.ts'
23
+ import { phoneLayout, portraitLayout, squareLayout } from './portrait.ts'
24
+ import { renderVoiceTrack, spokenLength, voiceLines, voiceSettings } from './voice.ts'
25
+ import { HYPERFRAMES_VERSION, RENDER_FLAGS } from './hyperframes.ts'
26
+ import {
27
+ readMarkers,
28
+ readSection,
29
+ readVideoSpec,
30
+ ReelsonError,
31
+ resolveDesign,
32
+ videoSchemaRef,
33
+ type Design,
34
+ type Section,
35
+ } from './project.ts'
36
+ import {
37
+ computeTimeline,
38
+ defaultCallouts,
39
+ round,
40
+ type Markers,
41
+ type SectionChoice,
42
+ type Timeline,
43
+ type TrimPoint,
44
+ type VideoSpec,
45
+ } from './timeline.ts'
46
+ import { compositionClicks, followPath, planZoom, ZoomError, type CompClick, type Zoom } from './zooms.ts'
47
+
48
+ export interface BuildOptions {
49
+ title?: string
50
+ subtitle?: string
51
+ trimStart?: TrimPoint
52
+ trimEnd?: TrimPoint
53
+ template?: string
54
+ /** Section per slot; stored in video.json `sections`. */
55
+ sections?: SectionChoice
56
+ /** A file for this video, or false for none; undefined keeps video.json / config. */
57
+ music?: string | false
58
+ log?: (line: string) => void
59
+ }
60
+
61
+ export interface Plan {
62
+ spec: VideoSpec
63
+ markers: Markers
64
+ design: Design
65
+ timeline: Timeline
66
+ clicks: CompClick[]
67
+ zooms: Zoom[]
68
+ warnings: string[]
69
+ /** video.json did not exist and was derived from markers.json. */
70
+ created: boolean
71
+ }
72
+
73
+ /** Everything a build needs, computed without touching video/ (check uses it too). */
74
+ export function plan(demoDir: string, config: LoadedConfig, options: BuildOptions = {}): Plan {
75
+ const markers = readMarkers(demoDir)
76
+ const existing = readVideoSpec(demoDir)
77
+ const spec: VideoSpec = existing ?? {
78
+ title: options.title ?? humanize(basename(demoDir)),
79
+ // Follows the recording: a re-record never leaves a stale trim behind.
80
+ trim: { start: 'auto' },
81
+ callouts: defaultCallouts(markers),
82
+ zooms: [],
83
+ }
84
+ applyOptions(spec, options)
85
+
86
+ return { ...planSpec(demoDir, spec, markers, config), created: existing === null }
87
+ }
88
+
89
+ /** The plan for a given video.json (validated elsewhere), without reading it from disk. */
90
+ export function planSpec(demoDir: string, spec: VideoSpec, markers: Markers, config: LoadedConfig): Omit<Plan, 'created'> {
91
+ const design = resolveDesign(spec.template ?? config.template, [config.sections, spec.sections], config)
92
+ let computed
93
+ try {
94
+ // With a voice-over, the callouts wait for their (cached) lines to be said.
95
+ const voice = voiceSettings(spec, config)
96
+ computed = computeTimeline(markers, spec, design.timing, voice ? spokenLength(voice, resolve(demoDir, 'voice')) : undefined)
97
+ } catch (error) {
98
+ throw new ReelsonError(`${resolve(demoDir, 'video.json')}: ${(error as Error).message}`)
99
+ }
100
+ const { timeline, warnings } = computed
101
+ const clicks = compositionClicks(markers, timeline)
102
+ const zooms = (spec.zooms ?? []).map((z, i) => {
103
+ try {
104
+ const zoom = planZoom(z, clicks, timeline)
105
+ if (z.follow && timeline.cursor) {
106
+ zoom.path = followPath(zoom, timeline.cursor, timeline.viewport)
107
+ } else if (z.follow) {
108
+ warnings.push(`zoom ${i + 1}: "follow" needs the cursor log of a record.cursor "layer" recording — it stays on its focus`)
109
+ }
110
+ return zoom
111
+ } catch (error) {
112
+ if (error instanceof ZoomError) {
113
+ throw new ReelsonError(`video.json zooms[${i}]: ${error.message}`)
114
+ }
115
+ throw error
116
+ }
117
+ })
118
+
119
+ return { spec, markers, design, timeline, clicks, zooms, warnings }
120
+ }
121
+
122
+ /** What `build` made: the plan, plus the timelines of the portrait and square versions (their takes' own timing). */
123
+ export interface Built extends Plan {
124
+ versions: { portrait: Timeline; square: Timeline | null }
125
+ }
126
+
127
+ export function build(demoDir: string, config: LoadedConfig, options: BuildOptions = {}): Built {
128
+ const log = options.log ?? console.log
129
+ const recording = resolve(demoDir, 'recording.mp4')
130
+ if (!existsSync(recording)) {
131
+ throw new ReelsonError(`missing ${recording} — run \`reelson record\` first (or export one from OpenScreen)`)
132
+ }
133
+ const result = plan(demoDir, config, options)
134
+ const { spec, timeline, design } = result
135
+ const { template } = design
136
+
137
+ // video.json first: it is the source of truth even if a later step fails.
138
+ const specPath = resolve(demoDir, 'video.json')
139
+ const serialized = serializeVideoSpec(spec, demoDir, config)
140
+ if (!existsSync(specPath) || readFileSync(specPath, 'utf8') !== serialized) {
141
+ writeFileSync(specPath, serialized)
142
+ log(result.created ? `created ${specPath} — word the callouts and add zooms there` : `updated ${specPath}`)
143
+ }
144
+
145
+ const videoDir = resolve(demoDir, 'video')
146
+ const assets = resolve(videoDir, 'assets')
147
+ mkdirSync(assets, { recursive: true })
148
+ if (existsSync(resolve(template.dir, 'assets'))) {
149
+ cpSync(resolve(template.dir, 'assets'), assets, { recursive: true, preserveTimestamps: true })
150
+ }
151
+ const sections = Object.values(design.sections)
152
+ .filter((section): section is Section => section !== null)
153
+ .map((section) => {
154
+ const source = readSection(section)
155
+ if (source.assets) {
156
+ cpSync(resolve(section.dir, 'assets'), resolve(videoDir, source.assets), { recursive: true, preserveTimestamps: true })
157
+ }
158
+ return source
159
+ })
160
+ const logo = copyLogo(spec, config, assets, log)
161
+ copyIfChanged(recording, resolve(assets, 'recording.mp4'))
162
+ const narration = extractNarration(recording, resolve(assets, 'narration.m4a'), timeline, log)
163
+ const voice = voiceTrack(demoDir, config, spec, timeline, resolve(assets, 'voice.m4a'), log)
164
+ const music = renderMusicBed(spec, config, timeline, resolve(assets, 'music.m4a'), narration || voice, log)
165
+
166
+ const brand = { ...config.brand, ...spec.brand }
167
+ const composition = {
168
+ stage: readFileSync(resolve(template.dir, 'stage.html'), 'utf8'),
169
+ sections,
170
+ timeline,
171
+ zooms: result.zooms,
172
+ narration,
173
+ music,
174
+ voice,
175
+ text: {
176
+ language: config.language,
177
+ brand,
178
+ logo,
179
+ title: spec.title,
180
+ subtitle: spec.subtitle ?? '',
181
+ recapTitle: spec.recapTitle ?? config.strings.recapTitle,
182
+ stepsChip: countLabel(timeline.callouts.length, config.strings.stepsLabel, config.language),
183
+ secondsChip: countLabel(Math.round(timeline.total), config.strings.secondsLabel, config.language),
184
+ },
185
+ }
186
+ const html = renderComposition(composition)
187
+ writeFileSync(resolve(videoDir, 'index.html'), html)
188
+ // The same video for phones (`reelson render --portrait`): from the phone take when there
189
+ // is one (video.json "portrait"), else a camera framing each element on this recording.
190
+ const portrait = renderPortrait(demoDir, config, spec, composition, result, assets, log)
191
+ writeFileSync(resolve(videoDir, 'portrait.html'), portrait.html)
192
+ // The square video (`reelson render --square`): only from a square take, which fills it.
193
+ const square = renderSquare(demoDir, config, spec, composition, result, assets, log)
194
+ if (square) {
195
+ writeFileSync(resolve(videoDir, 'square.html'), square.html)
196
+ } else {
197
+ rmSync(resolve(videoDir, 'square.html'), { force: true })
198
+ }
199
+ writeFileSync(
200
+ resolve(videoDir, 'hyperframes.json'),
201
+ JSON.stringify(
202
+ {
203
+ $schema: 'https://hyperframes.heygen.com/schema/hyperframes.json',
204
+ paths: { blocks: 'compositions', components: 'compositions/components', assets: 'assets' },
205
+ // autoProxy swaps big media for a downscaled proxy, which throws away the 2x capture.
206
+ media: { autoProxy: false },
207
+ },
208
+ null,
209
+ 2,
210
+ ) + '\n',
211
+ )
212
+ const hf = `npx --yes hyperframes@${HYPERFRAMES_VERSION}`
213
+ writeFileSync(
214
+ resolve(videoDir, 'package.json'),
215
+ JSON.stringify(
216
+ {
217
+ name: `reelson-video-${basename(demoDir)}`,
218
+ private: true,
219
+ type: 'module',
220
+ scripts: { dev: `${hf} preview`, check: `${hf} check`, render: `${hf} render ${RENDER_FLAGS.join(' ')}` },
221
+ },
222
+ null,
223
+ 2,
224
+ ) + '\n',
225
+ )
226
+
227
+ const t = timeline
228
+ const { intro, recap, outro } = design.sections
229
+ log(`built ${resolve(videoDir, 'index.html')} (template '${template.name}')`)
230
+ log(
231
+ ` timeline: intro/${intro.name} 0–${t.intro.duration}s | recording ${t.clipStart}–${t.clipEnd}s (media ${t.mediaStart}–${t.mediaEnd}s) | ` +
232
+ (recap && t.recap ? `recap/${recap.name} ${t.recap.start}–${round(t.recap.start + t.recap.duration)}s | ` : 'no recap | ') +
233
+ `outro/${outro.name} ${t.outro.start}–${t.total}s`,
234
+ )
235
+ if (t.transitions.length) {
236
+ log(` hand-off card(s) at ${t.transitions.map((tr) => `${tr.at}s`).join(', ')}`)
237
+ }
238
+ log(` frame ${t.frame.width}x${t.frame.height}, ${t.callouts.length} callout(s), ${result.zooms.length} zoom(s)`)
239
+ for (const w of result.warnings) {
240
+ log(` warning: ${w}`)
241
+ }
242
+
243
+ return { ...result, versions: { portrait: portrait.timeline, square: square?.timeline ?? null } }
244
+ }
245
+
246
+ /** The portrait composition (see `build`). */
247
+ function renderPortrait(
248
+ demoDir: string,
249
+ config: LoadedConfig,
250
+ spec: VideoSpec,
251
+ composition: CompositionInput,
252
+ result: Omit<Plan, 'created'>,
253
+ assets: string,
254
+ log: (line: string) => void,
255
+ ): { html: string; timeline: Timeline } {
256
+ const source = spec.portrait ?? 'auto'
257
+ const phoneTake = resolve(demoDir, 'recording.mobile.mp4')
258
+ const phoneMarkers = resolve(demoDir, 'markers.mobile.json')
259
+ const hasPhone = existsSync(phoneTake) && existsSync(phoneMarkers)
260
+ if (source === 'mobile' && !hasPhone) {
261
+ log(' warning: video.json "portrait": "mobile", but there is no phone take — run `reelson record <slug> --mobile`; using the desktop camera')
262
+ }
263
+ if (source !== 'desktop' && hasPhone) {
264
+ try {
265
+ // The same video.json on the phone take: callouts follow their markers; zooms are
266
+ // numbered by the desktop clicks, so they don't apply.
267
+ const markers = JSON.parse(readFileSync(phoneMarkers, 'utf8')) as Markers
268
+ const phone = planSpec(demoDir, { ...spec, zooms: [] }, markers, config)
269
+ copyIfChanged(phoneTake, resolve(assets, 'recording.mobile.mp4'))
270
+ const narration = extractNarration(phoneTake, resolve(assets, 'narration.mobile.m4a'), phone.timeline, () => {})
271
+ const voice = voiceTrack(demoDir, config, spec, phone.timeline, resolve(assets, 'voice.mobile.m4a'), () => {})
272
+ const music = renderMusicBed(spec, config, phone.timeline, resolve(assets, 'music.mobile.m4a'), narration || voice, () => {})
273
+ const layout = phoneLayout(phone.timeline)
274
+ log(` portrait: the phone take (${markers.viewport.width}x${markers.viewport.height}), ${phone.timeline.total}s`)
275
+ const html = renderComposition({
276
+ ...composition,
277
+ timeline: phone.timeline,
278
+ layout: layout.layout,
279
+ cursor: layout.cursor,
280
+ zooms: [],
281
+ narration,
282
+ music,
283
+ voice,
284
+ media: {
285
+ recording: 'assets/recording.mobile.mp4',
286
+ narration: 'assets/narration.mobile.m4a',
287
+ music: 'assets/music.mobile.m4a',
288
+ voice: 'assets/voice.mobile.m4a',
289
+ },
290
+ text: {
291
+ ...composition.text,
292
+ stepsChip: countLabel(phone.timeline.callouts.length, config.strings.stepsLabel, config.language),
293
+ secondsChip: countLabel(Math.round(phone.timeline.total), config.strings.secondsLabel, config.language),
294
+ },
295
+ })
296
+ return { html, timeline: phone.timeline }
297
+ } catch (error) {
298
+ log(` warning: the phone take does not fit video.json (${(error as Error).message}) — portrait uses the desktop camera`)
299
+ }
300
+ }
301
+ const camera = portraitLayout(result.timeline)
302
+ return { html: renderComposition({ ...composition, layout: camera.layout, cursor: camera.cursor, zooms: camera.zooms }), timeline: result.timeline }
303
+ }
304
+
305
+ /**
306
+ * The square composition (see `build`), or null without a square take (or one that no longer
307
+ * fits video.json).
308
+ */
309
+ function renderSquare(
310
+ demoDir: string,
311
+ config: LoadedConfig,
312
+ spec: VideoSpec,
313
+ composition: CompositionInput,
314
+ result: Omit<Plan, 'created'>,
315
+ assets: string,
316
+ log: (line: string) => void,
317
+ ): { html: string; timeline: Timeline } | null {
318
+ const take = resolve(demoDir, 'recording.square.mp4')
319
+ const takeMarkers = resolve(demoDir, 'markers.square.json')
320
+ if (!existsSync(take) || !existsSync(takeMarkers)) {
321
+ return null
322
+ }
323
+ try {
324
+ // The same video.json on the square take: callouts follow their markers. Zooms are
325
+ // anchored by click number, so they carry over only while the clicks match.
326
+ const markers = JSON.parse(readFileSync(takeMarkers, 'utf8')) as Markers
327
+ const clicks = markers.clicks?.length ?? 0
328
+ const desktopClicks = result.markers.clicks?.length ?? 0
329
+ const sameClicks = clicks === desktopClicks
330
+ if (!sameClicks && spec.zooms?.length) {
331
+ log(` warning: the square take has ${clicks} click(s), the desktop take ${desktopClicks} — the square video leaves out the zooms`)
332
+ }
333
+ const plan = planSpec(demoDir, sameClicks ? spec : { ...spec, zooms: [] }, markers, config)
334
+ copyIfChanged(take, resolve(assets, 'recording.square.mp4'))
335
+ const narration = extractNarration(take, resolve(assets, 'narration.square.m4a'), plan.timeline, () => {})
336
+ const voice = voiceTrack(demoDir, config, spec, plan.timeline, resolve(assets, 'voice.square.m4a'), () => {})
337
+ const music = renderMusicBed(spec, config, plan.timeline, resolve(assets, 'music.square.m4a'), narration || voice, () => {})
338
+ const layout = squareLayout(plan.timeline)
339
+ log(` square: the square take (${markers.viewport.width}x${markers.viewport.height}), ${plan.timeline.total}s`)
340
+ const html = renderComposition({
341
+ ...composition,
342
+ timeline: plan.timeline,
343
+ layout: layout.layout,
344
+ cursor: layout.cursor,
345
+ zooms: plan.zooms,
346
+ narration,
347
+ music,
348
+ voice,
349
+ media: {
350
+ recording: 'assets/recording.square.mp4',
351
+ narration: 'assets/narration.square.m4a',
352
+ music: 'assets/music.square.m4a',
353
+ voice: 'assets/voice.square.m4a',
354
+ },
355
+ text: {
356
+ ...composition.text,
357
+ stepsChip: countLabel(plan.timeline.callouts.length, config.strings.stepsLabel, config.language),
358
+ secondsChip: countLabel(Math.round(plan.timeline.total), config.strings.secondsLabel, config.language),
359
+ },
360
+ })
361
+ return { html, timeline: plan.timeline }
362
+ } catch (error) {
363
+ log(` warning: the square take does not fit video.json (${(error as Error).message}) — re-record it: \`reelson record ${basename(demoDir)} --square\``)
364
+ return null
365
+ }
366
+ }
367
+
368
+ /** The voice-over track for one version (see voice.ts); false when the video has none. */
369
+ function voiceTrack(demoDir: string, config: LoadedConfig, spec: VideoSpec, t: Timeline, target: string, log: (l: string) => void): boolean {
370
+ const settings = voiceSettings(spec, config)
371
+ if (!settings) {
372
+ return false
373
+ }
374
+ return renderVoiceTrack(voiceLines(t, spec, settings), settings, resolve(demoDir, 'voice'), t.total, target, log)
375
+ }
376
+
377
+ function applyOptions(spec: VideoSpec, o: BuildOptions): void {
378
+ if (o.title !== undefined) spec.title = o.title
379
+ if (o.subtitle !== undefined) spec.subtitle = o.subtitle
380
+ if (o.template !== undefined) spec.template = o.template
381
+ if (o.sections && Object.keys(o.sections).length) spec.sections = { ...spec.sections, ...o.sections }
382
+ if (o.trimStart !== undefined) spec.trim = { ...spec.trim, start: o.trimStart }
383
+ if (o.trimEnd !== undefined) spec.trim = { ...spec.trim, end: o.trimEnd }
384
+ if (o.music !== undefined) spec.music = o.music
385
+ }
386
+
387
+ function humanize(slug: string): string {
388
+ const words = slug.replace(/[-_]+/g, ' ').trim()
389
+ return words.charAt(0).toUpperCase() + words.slice(1)
390
+ }
391
+
392
+ /** video.json as the build writes it: `$schema` first, then a stable, readable key order. */
393
+ export function serializeVideoSpec(spec: VideoSpec, demoDir: string, config: LoadedConfig): string {
394
+ return JSON.stringify({ $schema: videoSchemaRef(demoDir, config), ...withoutSchema(spec) }, null, 4) + '\n'
395
+ }
396
+
397
+ /** video.json in a stable, readable key order, without `$schema` (re-added on write). */
398
+ function withoutSchema(spec: VideoSpec): VideoSpec {
399
+ const { $schema: _ignored, ...rest } = spec as VideoSpec & { $schema?: string }
400
+ const order: (keyof VideoSpec)[] = ['title', 'subtitle', 'template', 'sections', 'recapTitle', 'brand', 'trim', 'music', 'callouts', 'zooms', 'cursor', 'voice', 'portrait', 'formats']
401
+ const known = order.filter((k) => rest[k] !== undefined).map((k) => [k, rest[k]])
402
+ const others = Object.entries(rest).filter(([k]) => !order.includes(k as keyof VideoSpec))
403
+
404
+ return Object.fromEntries([...known, ...others]) as VideoSpec
405
+ }
406
+
407
+ const LOGO_TYPES = ['.svg', '.png', '.webp']
408
+
409
+ /**
410
+ * brand.logo (video.json over demo.config.json) → assets/brand-logo.<ext>. Returns its
411
+ * path for the composition, or '' when the brand is drawn as a text wordmark.
412
+ */
413
+ function copyLogo(spec: VideoSpec, config: LoadedConfig, assets: string, log: (l: string) => void): string {
414
+ const file = spec.brand?.logo !== undefined ? spec.brand.logo : config.brand.logo
415
+ if (!file) {
416
+ return ''
417
+ }
418
+ const source = fromRoot(config, file)
419
+ const ext = extname(source).toLowerCase()
420
+ if (!LOGO_TYPES.includes(ext)) {
421
+ throw new ReelsonError(`brand.logo must be ${LOGO_TYPES.join(', ')} (SVG stays sharpest), got ${source}`)
422
+ }
423
+ if (!existsSync(source)) {
424
+ throw new ReelsonError(`brand logo not found: ${source} — fix brand.logo, or set it to null for the text wordmark`)
425
+ }
426
+ const height = ext === '.png' ? pngHeight(source) : null
427
+ if (height !== null && height < MIN_LOGO_HEIGHT) {
428
+ log(` warning: ${basename(source)} is ${height}px tall — under ${MIN_LOGO_HEIGHT}px it looks soft on the cards; use an SVG or a taller PNG`)
429
+ }
430
+ const name = `brand-logo${ext}`
431
+ copyIfChanged(source, resolve(assets, name))
432
+
433
+ return `assets/${name}`
434
+ }
435
+
436
+ /** The biggest wordmark (168px tall on the outro) at 2x, so the logo stays crisp. */
437
+ const MIN_LOGO_HEIGHT = 340
438
+
439
+ function pngHeight(path: string): number | null {
440
+ const header = readFileSync(path).subarray(0, 24)
441
+ return header.toString('ascii', 12, 16) === 'IHDR' ? header.readUInt32BE(20) : null
442
+ }
443
+
444
+ function copyIfChanged(from: string, to: string): void {
445
+ if (existsSync(to)) {
446
+ const a = statSync(from)
447
+ const b = statSync(to)
448
+ if (a.size === b.size && b.mtimeMs >= a.mtimeMs) {
449
+ return
450
+ }
451
+ }
452
+ copyFileSync(from, to)
453
+ }
454
+
455
+ /** Extracts the recording's audio track (OpenScreen voiceover), if any. */
456
+ function extractNarration(source: string, target: string, t: Timeline, log: (l: string) => void): boolean {
457
+ const probe = spawnSync(
458
+ 'ffprobe',
459
+ ['-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=codec_name', '-of', 'csv=p=0', source],
460
+ { encoding: 'utf8' },
461
+ )
462
+ if (probe.status !== 0 || probe.stdout.trim() === '') {
463
+ return false
464
+ }
465
+ if (t.transitions.length) {
466
+ log(' warning: narration is not split at hand-offs; it drifts after the first transition card')
467
+ }
468
+ if (existsSync(target) && statSync(target).mtimeMs >= statSync(source).mtimeMs) {
469
+ return true
470
+ }
471
+ const extract = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', source, '-vn', '-c:a', 'aac', '-b:a', '160k', target], {
472
+ stdio: 'inherit',
473
+ })
474
+ if (extract.status !== 0) {
475
+ log(' warning: ffmpeg could not extract the audio track; continuing without narration')
476
+ return false
477
+ }
478
+ log(' audio track found — narration extracted to assets/narration.m4a')
479
+ return true
480
+ }
481
+
482
+ /**
483
+ * Pre-renders the music bed with ffmpeg (trim, loudnorm, fades) so the
484
+ * composition plays it at unity. Cached: re-rendered only when the source,
485
+ * length or loudness changes.
486
+ */
487
+ function renderMusicBed(
488
+ spec: VideoSpec,
489
+ config: LoadedConfig,
490
+ t: Timeline,
491
+ target: string,
492
+ underNarration: boolean,
493
+ log: (l: string) => void,
494
+ ): boolean {
495
+ const choice = spec.music ?? null
496
+ if (choice === false) {
497
+ return false
498
+ }
499
+ const file = typeof choice === 'string' ? choice : config.music.file
500
+ if (!file) {
501
+ log(' no music: set music.file in demo.config.json (or "music" in video.json)')
502
+ return false
503
+ }
504
+ const source = fromRoot(config, file)
505
+ if (!existsSync(source)) {
506
+ throw new ReelsonError(`music not found: ${source} — fix music.file / video.json "music", or set "music": false`)
507
+ }
508
+ const lufs = underNarration ? config.music.lufsUnderNarration : config.music.lufs
509
+ const fadeOut = 3
510
+ const key = JSON.stringify({ source, mtime: statSync(source).mtimeMs, total: t.total, lufs })
511
+ const stamp = `${target}.key`
512
+ if (existsSync(target) && existsSync(stamp) && readFileSync(stamp, 'utf8') === key) {
513
+ return true
514
+ }
515
+ const filters = [
516
+ `atrim=0:${t.total}`,
517
+ `loudnorm=I=${lufs}:TP=-3:LRA=9`,
518
+ 'afade=t=in:st=0:d=0.8',
519
+ `afade=t=out:st=${round(t.total - fadeOut)}:d=${fadeOut}`,
520
+ ].join(',')
521
+ const render = spawnSync(
522
+ 'ffmpeg',
523
+ ['-y', '-loglevel', 'error', '-i', source, '-vn', '-af', filters, '-c:a', 'aac', '-b:a', '160k', target],
524
+ { stdio: 'inherit' },
525
+ )
526
+ if (render.status !== 0) {
527
+ throw new ReelsonError('ffmpeg could not render the music bed')
528
+ }
529
+ writeFileSync(stamp, key)
530
+ log(` music bed: ${basename(source)} → assets/music.m4a (${lufs} LUFS, fades 0.8s/${fadeOut}s)`)
531
+ return true
532
+ }
@@ -0,0 +1,24 @@
1
+ export function captionCues(timeline, title, subtitle) {
2
+ const intro = subtitle ? `${title}\n${subtitle}` : title;
3
+ return [
4
+ { start: 0, end: timeline.intro.exit, text: intro },
5
+ ...timeline.callouts.map((c) => ({ start: c.at, end: c.at + c.duration, text: c.text })),
6
+ ].filter((c) => c.end > c.start && c.text.trim());
7
+ }
8
+ export function toSrt(cues) {
9
+ const time = (s) => clock(s, ',');
10
+ return cues.map((c, i) => `${i + 1}\n${time(c.start)} --> ${time(c.end)}\n${c.text}\n`).join('\n');
11
+ }
12
+ export function toVtt(cues) {
13
+ const time = (s) => clock(s, '.');
14
+ return `WEBVTT\n\n${cues.map((c) => `${time(c.start)} --> ${time(c.end)}\n${c.text}\n`).join('\n')}`;
15
+ }
16
+ /** 00:01:02,345 (SRT) / 00:01:02.345 (WebVTT). */
17
+ function clock(seconds, separator) {
18
+ const ms = Math.max(0, Math.round(seconds * 1000));
19
+ const pad = (n, width = 2) => String(n).padStart(width, '0');
20
+ const h = Math.floor(ms / 3_600_000);
21
+ const m = Math.floor((ms % 3_600_000) / 60_000);
22
+ const s = Math.floor((ms % 60_000) / 1000);
23
+ return `${pad(h)}:${pad(m)}:${pad(s)}${separator}${pad(ms % 1000, 3)}`;
24
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Captions for a rendered video: the title while the intro is on screen, then one cue per
3
+ * callout, in composition time — the same words the viewer reads on the cards, as a
4
+ * subtitle track for players, social uploads and screen readers. Pure.
5
+ */
6
+ import type { Timeline } from './timeline.ts'
7
+
8
+ export interface Cue {
9
+ start: number
10
+ end: number
11
+ text: string
12
+ }
13
+
14
+ export function captionCues(timeline: Timeline, title: string, subtitle?: string): Cue[] {
15
+ const intro = subtitle ? `${title}\n${subtitle}` : title
16
+ return [
17
+ { start: 0, end: timeline.intro.exit, text: intro },
18
+ ...timeline.callouts.map((c) => ({ start: c.at, end: c.at + c.duration, text: c.text })),
19
+ ].filter((c) => c.end > c.start && c.text.trim())
20
+ }
21
+
22
+ export function toSrt(cues: Cue[]): string {
23
+ const time = (s: number) => clock(s, ',')
24
+ return cues.map((c, i) => `${i + 1}\n${time(c.start)} --> ${time(c.end)}\n${c.text}\n`).join('\n')
25
+ }
26
+
27
+ export function toVtt(cues: Cue[]): string {
28
+ const time = (s: number) => clock(s, '.')
29
+ return `WEBVTT\n\n${cues.map((c) => `${time(c.start)} --> ${time(c.end)}\n${c.text}\n`).join('\n')}`
30
+ }
31
+
32
+ /** 00:01:02,345 (SRT) / 00:01:02.345 (WebVTT). */
33
+ function clock(seconds: number, separator: ',' | '.'): string {
34
+ const ms = Math.max(0, Math.round(seconds * 1000))
35
+ const pad = (n: number, width = 2) => String(n).padStart(width, '0')
36
+ const h = Math.floor(ms / 3_600_000)
37
+ const m = Math.floor((ms % 3_600_000) / 60_000)
38
+ const s = Math.floor((ms % 60_000) / 1000)
39
+ return `${pad(h)}:${pad(m)}:${pad(s)}${separator}${pad(ms % 1000, 3)}`
40
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Checks a demo before rendering:
3
+ * 1. demo.config.json and video.json match their schemas (typos are errors);
4
+ * 2. the timeline resolves (callouts point at real markers, trims make sense);
5
+ * 3. every zoom rides along with the cursor (style guide #13) — see zooms.ts;
6
+ * 4. `hyperframes check` on each built composition — landscape, portrait, square (lint,
7
+ * runtime, layout, contrast).
8
+ *
9
+ * reelson check <slug|dir> [--no-hyperframes]
10
+ */
11
+ import { existsSync, statSync } from 'node:fs';
12
+ import { resolve } from 'node:path';
13
+ import { plan } from "./build.js";
14
+ import { hyperframesOn } from "./hyperframes.js";
15
+ import { round } from "./timeline.js";
16
+ import { checkZoom, zoomOverlaps } from "./zooms.js";
17
+ /** Returns the number of problems found (0 = ready to render). */
18
+ export function check(demoDir, config, options = {}) {
19
+ const log = options.log ?? console.log;
20
+ const result = plan(demoDir, config);
21
+ let problems = 0;
22
+ if (result.created) {
23
+ log('✗ no video.json yet — run `reelson build` first');
24
+ return 1;
25
+ }
26
+ for (const w of result.warnings) {
27
+ log(`! ${w}`);
28
+ }
29
+ for (const edge of ['start', 'end']) {
30
+ if (typeof result.spec.trim?.[edge] === 'number') {
31
+ log(`! trim.${edge} is a fixed time — after a re-record it cuts in the wrong place; ` +
32
+ (edge === 'start' ? 'use "auto" or ' : 'use ') +
33
+ '{ "marker": "…", "offset": … } / { "click": n, "offset": … } instead');
34
+ }
35
+ }
36
+ if ((result.spec.zooms ?? []).length && !result.clicks.length) {
37
+ log('✗ zooms need logged clicks — re-record with the current reelson-record scripts');
38
+ problems++;
39
+ }
40
+ const overlaps = zoomOverlaps(result.zooms);
41
+ result.zooms.forEach((z, i) => {
42
+ const { problems: checked, framed } = checkZoom(z, result.clicks, result.timeline);
43
+ const found = [...checked, ...overlaps.filter((o) => o.index === i).map((o) => o.message)];
44
+ const label = `zoom ${i + 1} (${z.at}–${round(z.at + z.duration)}s, in ${z.in}s / out ${z.out}s, ${z.scale}x at ${z.x},${z.y})`;
45
+ if (found.length) {
46
+ problems += found.length;
47
+ log(`✗ ${label}`);
48
+ found.forEach((p) => log(` ${p}`));
49
+ }
50
+ else {
51
+ log(`✓ ${label}: ${framed.map((c) => `${c.kind} #${c.index} ${c.comp}s`).join(', ')}`);
52
+ }
53
+ });
54
+ if (!result.zooms.length) {
55
+ log('✓ no zooms');
56
+ }
57
+ const videoDir = resolve(demoDir, 'video');
58
+ const index = resolve(videoDir, 'index.html');
59
+ if (!existsSync(index)) {
60
+ log('✗ video/ is not built — run `reelson build`');
61
+ return problems + 1;
62
+ }
63
+ if (statSync(resolve(demoDir, 'video.json')).mtimeMs > statSync(index).mtimeMs) {
64
+ log('! video.json changed since the last build — run `reelson build` (render does it for you)');
65
+ }
66
+ if (options.hyperframes !== false) {
67
+ for (const composition of ['index.html', 'portrait.html', 'square.html']) {
68
+ if (!existsSync(resolve(videoDir, composition))) {
69
+ continue;
70
+ }
71
+ log(`\n${composition}:`);
72
+ if (hyperframesOn(videoDir, composition, ['check', '.']) !== 0) {
73
+ log(`✗ hyperframes check failed (${composition})`);
74
+ problems++;
75
+ }
76
+ }
77
+ }
78
+ log(problems ? `\n${problems} problem(s)` : '\nready to render');
79
+ return problems;
80
+ }