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,93 @@
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 type { LoadedConfig } from '../../reelson-record/scripts/config.ts'
14
+ import { plan } from './build.ts'
15
+ import { hyperframesOn } from './hyperframes.ts'
16
+ import { round } from './timeline.ts'
17
+ import { checkZoom, zoomOverlaps } from './zooms.ts'
18
+
19
+ export interface CheckOptions {
20
+ hyperframes?: boolean
21
+ log?: (line: string) => void
22
+ }
23
+
24
+ /** Returns the number of problems found (0 = ready to render). */
25
+ export function check(demoDir: string, config: LoadedConfig, options: CheckOptions = {}): number {
26
+ const log = options.log ?? console.log
27
+ const result = plan(demoDir, config)
28
+ let problems = 0
29
+
30
+ if (result.created) {
31
+ log('✗ no video.json yet — run `reelson build` first')
32
+ return 1
33
+ }
34
+ for (const w of result.warnings) {
35
+ log(`! ${w}`)
36
+ }
37
+ for (const edge of ['start', 'end'] as const) {
38
+ if (typeof result.spec.trim?.[edge] === 'number') {
39
+ log(
40
+ `! trim.${edge} is a fixed time — after a re-record it cuts in the wrong place; ` +
41
+ (edge === 'start' ? 'use "auto" or ' : 'use ') +
42
+ '{ "marker": "…", "offset": … } / { "click": n, "offset": … } instead',
43
+ )
44
+ }
45
+ }
46
+ if ((result.spec.zooms ?? []).length && !result.clicks.length) {
47
+ log('✗ zooms need logged clicks — re-record with the current reelson-record scripts')
48
+ problems++
49
+ }
50
+
51
+ const overlaps = zoomOverlaps(result.zooms)
52
+ result.zooms.forEach((z, i) => {
53
+ const { problems: checked, framed } = checkZoom(z, result.clicks, result.timeline)
54
+ const found = [...checked, ...overlaps.filter((o) => o.index === i).map((o) => o.message)]
55
+ 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})`
56
+ if (found.length) {
57
+ problems += found.length
58
+ log(`✗ ${label}`)
59
+ found.forEach((p) => log(` ${p}`))
60
+ } else {
61
+ log(`✓ ${label}: ${framed.map((c) => `${c.kind} #${c.index} ${c.comp}s`).join(', ')}`)
62
+ }
63
+ })
64
+ if (!result.zooms.length) {
65
+ log('✓ no zooms')
66
+ }
67
+
68
+ const videoDir = resolve(demoDir, 'video')
69
+ const index = resolve(videoDir, 'index.html')
70
+ if (!existsSync(index)) {
71
+ log('✗ video/ is not built — run `reelson build`')
72
+ return problems + 1
73
+ }
74
+ if (statSync(resolve(demoDir, 'video.json')).mtimeMs > statSync(index).mtimeMs) {
75
+ log('! video.json changed since the last build — run `reelson build` (render does it for you)')
76
+ }
77
+ if (options.hyperframes !== false) {
78
+ for (const composition of ['index.html', 'portrait.html', 'square.html']) {
79
+ if (!existsSync(resolve(videoDir, composition))) {
80
+ continue
81
+ }
82
+ log(`\n${composition}:`)
83
+ if (hyperframesOn(videoDir, composition, ['check', '.']) !== 0) {
84
+ log(`✗ hyperframes check failed (${composition})`)
85
+ problems++
86
+ }
87
+ }
88
+ }
89
+
90
+ log(problems ? `\n${problems} problem(s)` : '\nready to render')
91
+ return problems
92
+ }
93
+
@@ -0,0 +1,163 @@
1
+ import { round } from "./timeline.js";
2
+ import { calloutsAtTop, landscapeLayout } from "./portrait.js";
3
+ const MEDIA = { recording: 'assets/recording.mp4', narration: 'assets/narration.m4a', music: 'assets/music.m4a', voice: 'assets/voice.m4a' };
4
+ /** HyperFrames track per slot: the recap cross-fades with the outro, so they sit on different tracks. */
5
+ const TRACKS = { intro: 3, recap: 2, outro: 3 };
6
+ export function renderComposition(input) {
7
+ const { timeline: t, text } = input;
8
+ const layout = input.layout ?? landscapeLayout(t);
9
+ const atTop = calloutsAtTop(t, layout);
10
+ // Everything the stage and section scripts animate from. Times are composition seconds.
11
+ const demo = {
12
+ total: t.total,
13
+ clipStart: t.clipStart,
14
+ clipDuration: t.clipDuration,
15
+ mediaStart: t.mediaStart,
16
+ sections: { intro: t.intro, recap: t.recap, outro: t.outro },
17
+ callouts: t.callouts.map(({ source: _source, say: _say, ...c }, i) => (atTop.has(i) ? { ...c, top: true } : c)),
18
+ zooms: input.zooms,
19
+ cursor: input.cursor !== undefined ? input.cursor : t.cursor,
20
+ layout: { format: layout.format, stage: layout.stage, bandZoom: layout.bandZoom, camera: layout.camera },
21
+ transitions: t.transitions.map(({ at, gap }) => ({ at, gap })),
22
+ chip: { steps: text.stepsChip, seconds: text.secondsChip },
23
+ };
24
+ const shared = {
25
+ LANG: escapeHtml(text.language),
26
+ BRAND_COLOR: escapeHtml(text.brand.color),
27
+ BRAND_COLOR_SOFT: escapeHtml(text.brand.colorSoft),
28
+ BRAND: escapeHtml(text.brand.name),
29
+ BRAND_SUB: escapeHtml(text.brand.tagline),
30
+ BRAND_LOGO: escapeHtml(text.logo),
31
+ EYEBROW: escapeHtml(text.brand.eyebrow),
32
+ TITLE: escapeHtml(text.title),
33
+ SUBTITLE: escapeHtml(text.subtitle),
34
+ OUTRO_TITLE: escapeHtml(text.recapTitle),
35
+ TOTAL: String(t.total),
36
+ FRAME_W: String(layout.frame.width),
37
+ FRAME_H: String(layout.frame.height),
38
+ FOOTAGE_W: String(layout.footage.width),
39
+ FOOTAGE_H: String(layout.footage.height),
40
+ STAGE_W: String(layout.stage.width),
41
+ STAGE_H: String(layout.stage.height),
42
+ BAND_ZOOM: String(layout.bandZoom),
43
+ FORMAT: layout.phone ? `${layout.format} phone` : layout.format,
44
+ };
45
+ const unfilled = new Set();
46
+ const fill = (source, values) => source.replace(/{{([A-Z_]+)}}/g, (match, key) => {
47
+ if (key in values) {
48
+ return values[key];
49
+ }
50
+ unfilled.add(match);
51
+ return match;
52
+ });
53
+ const parts = input.sections.map((s) => {
54
+ const slot = t[s.slot];
55
+ if (!slot) {
56
+ throw new Error(`the timeline has no ${s.slot} (recap "none") but a ${s.slot} section was given`);
57
+ }
58
+ const values = {
59
+ ...shared,
60
+ START: String(slot.start),
61
+ DURATION: String(slot.duration),
62
+ TRACK: String(TRACKS[s.slot]),
63
+ ASSETS: s.assets,
64
+ };
65
+ const label = `${s.slot}: ${s.name}`;
66
+ return {
67
+ slot: s.slot,
68
+ css: ` /* ── ${label} ── */\n${indent(fill(s.css, values).trim(), 6)}`,
69
+ html: ` <!-- ── ${label} ── -->\n${indent(band(fill(s.html, values).trim(), layout.format === 'landscape' || (layout.format === 'square' ? s.square : s.portrait)), 6)}`,
70
+ js: ` // ── ${label} ──\n ;((section) => {\n${indent(fill(s.js, values).trim(), 8)}\n` +
71
+ ` })(DEMO.sections.${s.slot});`,
72
+ };
73
+ });
74
+ const part = (slot) => parts.find((p) => p.slot === slot) ?? { html: '', css: '', js: '' };
75
+ const html = fill(input.stage, {
76
+ ...shared,
77
+ SECTION_STYLES: parts.map((p) => p.css).join('\n\n'),
78
+ // Each slot has its own place in the stage: the intro sits under #screen, recap and outro above it.
79
+ INTRO: part('intro').html,
80
+ RECAP: part('recap').html,
81
+ OUTRO: part('outro').html,
82
+ SECTION_SCRIPTS: parts.map((p) => p.js).join('\n\n'),
83
+ VIDEOS: renderVideoSegments(t, (input.media ?? MEDIA).recording),
84
+ TRANSITIONS: t.transitions.map((tr, i) => renderTransitionCard(tr, i, t.belt)).join('\n'),
85
+ AUDIO: input.narration ? renderNarration(t, (input.media ?? MEDIA).narration) : '',
86
+ MUSIC: (input.music ? renderMusic(t, (input.media ?? MEDIA).music) : '') +
87
+ (input.voice ? `\n${renderVoice(t, input.media?.voice ?? MEDIA.voice)}` : ''),
88
+ // JSON is valid JS; escaping "<" keeps "</script>" in a callout from closing the tag.
89
+ DEMO: JSON.stringify(demo, null, 2)
90
+ // Number tuples (cursor [t, x, y], camera [t, k, x, y, h]) one per line, not one number per line.
91
+ .replace(/\[\s+((?:-?[\d.]+,\s+)+-?[\d.]+)\s+\]/g, (_m, inner) => `[${inner.split(/,\s+/).join(', ')}]`)
92
+ .replace(/</g, '\\u003c')
93
+ .replace(/\n/g, '\n '),
94
+ });
95
+ if (unfilled.size) {
96
+ throw new Error(`template placeholders left unfilled: ${[...unfilled].join(', ')}`);
97
+ }
98
+ return html;
99
+ }
100
+ /**
101
+ * A section without its own layout for this format (portrait, square): mark its root so the
102
+ * stage zooms its 16:9 card to the width.
103
+ */
104
+ function band(html, laidOut = false) {
105
+ return laidOut ? html : html.replace(/^(<section\b[^>]*\bclass=")([^"]*)"/, '$1$2 band"');
106
+ }
107
+ function indent(block, spaces) {
108
+ const pad = ' '.repeat(spaces);
109
+ return block
110
+ .split('\n')
111
+ .map((line) => (line.trim() ? pad + line : ''))
112
+ .join('\n');
113
+ }
114
+ /**
115
+ * One <video> clip per stretch of footage between hand-offs (same file,
116
+ * different data-media-start), so the footage pauses while a card is on screen.
117
+ */
118
+ function renderVideoSegments(t, src) {
119
+ return t.segments
120
+ .map((s, i) => {
121
+ const id = i === 0 ? 'recording' : `recording-${i + 1}`;
122
+ return ` <video id="${id}" class="clip" src="${src}" muted playsinline
123
+ data-start="${s.start}" data-duration="${s.duration}" data-media-start="${s.mediaStart}" data-track-index="1"></video>`;
124
+ })
125
+ .join('\n');
126
+ }
127
+ /** Markup for a hand-off card; the template's builder animates it from DEMO.transitions. */
128
+ function renderTransitionCard(tr, i, belt) {
129
+ const { card } = tr;
130
+ const roles = card.from && card.to
131
+ ? `
132
+ <div class="roles">
133
+ <div class="role from">${escapeHtml(card.from)}</div>
134
+ <div class="arrow"></div>
135
+ <div class="role to"><div class="fill"></div><span>${escapeHtml(card.to)}</span></div>
136
+ </div>`
137
+ : '';
138
+ const subtitle = card.subtitle
139
+ ? `
140
+ <p class="subtitle">${escapeHtml(card.subtitle)}</p>`
141
+ : '';
142
+ return ` <!-- Hand-off ${i + 1}: demo.transition() in the scenario -->
143
+ <section id="transition-${i}" class="clip transition-card" data-start="${round(tr.at - belt)}" data-duration="${round(tr.gap + 2 * belt)}" data-track-index="2">
144
+ <div class="stack">${roles}
145
+ <h1 class="title">${escapeHtml(card.title)}</h1>${subtitle}
146
+ </div>
147
+ </section>`;
148
+ }
149
+ function renderNarration(t, src) {
150
+ return ` <!-- Narration from the recording (kept in sync via the same start/media-start) -->
151
+ <audio id="narration" class="clip" src="${src}" data-start="${t.clipStart}" data-duration="${round(t.mediaEnd - t.mediaStart)}" data-media-start="${t.mediaStart}" data-track-index="3"></audio>`;
152
+ }
153
+ function renderMusic(t, src) {
154
+ return ` <!-- Music bed, pre-rendered by reelson build (trim + loudnorm + fades) -->
155
+ <audio id="music" class="clip" src="${src}" data-start="0" data-duration="${t.total}" data-track-index="4"></audio>`;
156
+ }
157
+ function renderVoice(t, src) {
158
+ return ` <!-- Voice-over, pre-mixed by reelson build (each callout spoken as it appears) -->
159
+ <audio id="voice" class="clip" src="${src}" data-start="0" data-duration="${t.total}" data-track-index="5"></audio>`;
160
+ }
161
+ export function escapeHtml(s) {
162
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
163
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Assembles a template's stage.html and the chosen sections into one
3
+ * composition, filled from a computed timeline. Pure (strings in, string out),
4
+ * so the output is golden-tested. See templates/README.md for the contract.
5
+ */
6
+ import type { Slot, Timeline } from './timeline.ts'
7
+ import { round } from './timeline.ts'
8
+ import { calloutsAtTop, landscapeLayout, type Layout } from './portrait.ts'
9
+ import type { Zoom } from './zooms.ts'
10
+
11
+ export interface CompositionText {
12
+ language: string
13
+ brand: { name: string; tagline: string; eyebrow: string; color: string; colorSoft: string }
14
+ /** assets/ path of the brand logo, or '' to draw the text wordmark. */
15
+ logo: string
16
+ title: string
17
+ subtitle: string
18
+ recapTitle: string
19
+ /** Intro chip halves, already pluralised: "4 steps", "27 seconds". */
20
+ stepsChip: string
21
+ secondsChip: string
22
+ }
23
+
24
+ /** One section's files, read by the build. */
25
+ export interface SectionSource {
26
+ slot: Slot
27
+ name: string
28
+ html: string
29
+ css: string
30
+ js: string
31
+ /** Where the section's own assets/ were copied, relative to video/ ('' if it has none). */
32
+ assets: string
33
+ /** Has its own portrait layout; otherwise its root gets class "band" (portrait zooms it). */
34
+ portrait?: boolean
35
+ /** Has its own square layout; otherwise its root gets class "band" (square zooms it). */
36
+ square?: boolean
37
+ }
38
+
39
+ export interface CompositionInput {
40
+ stage: string
41
+ /** In slot order; the recap is left out when the video has none. */
42
+ sections: SectionSource[]
43
+ timeline: Timeline
44
+ zooms: Zoom[]
45
+ text: CompositionText
46
+ /** assets/narration.m4a exists (the recording had an audio track). */
47
+ narration: boolean
48
+ /** assets/music.m4a exists. */
49
+ music: boolean
50
+ /** The voice-over track exists (media.voice). */
51
+ voice?: boolean
52
+ /** Stage/frame/footage sizes and the portrait camera; default: the landscape stage. */
53
+ layout?: Layout
54
+ /** Overrides the timeline's cursor layer (portrait and square move it into their footage scale). */
55
+ cursor?: Timeline['cursor']
56
+ /** The media files (default assets/recording.mp4, narration.m4a, music.m4a). */
57
+ media?: { recording: string; narration: string; music: string; voice?: string }
58
+ }
59
+
60
+ const MEDIA = { recording: 'assets/recording.mp4', narration: 'assets/narration.m4a', music: 'assets/music.m4a', voice: 'assets/voice.m4a' }
61
+
62
+ /** HyperFrames track per slot: the recap cross-fades with the outro, so they sit on different tracks. */
63
+ const TRACKS: Record<Slot, number> = { intro: 3, recap: 2, outro: 3 }
64
+
65
+ export function renderComposition(input: CompositionInput): string {
66
+ const { timeline: t, text } = input
67
+ const layout = input.layout ?? landscapeLayout(t)
68
+ const atTop = calloutsAtTop(t, layout)
69
+
70
+ // Everything the stage and section scripts animate from. Times are composition seconds.
71
+ const demo = {
72
+ total: t.total,
73
+ clipStart: t.clipStart,
74
+ clipDuration: t.clipDuration,
75
+ mediaStart: t.mediaStart,
76
+ sections: { intro: t.intro, recap: t.recap, outro: t.outro },
77
+ callouts: t.callouts.map(({ source: _source, say: _say, ...c }, i) => (atTop.has(i) ? { ...c, top: true } : c)),
78
+ zooms: input.zooms,
79
+ cursor: input.cursor !== undefined ? input.cursor : t.cursor,
80
+ layout: { format: layout.format, stage: layout.stage, bandZoom: layout.bandZoom, camera: layout.camera },
81
+ transitions: t.transitions.map(({ at, gap }) => ({ at, gap })),
82
+ chip: { steps: text.stepsChip, seconds: text.secondsChip },
83
+ }
84
+
85
+ const shared: Record<string, string> = {
86
+ LANG: escapeHtml(text.language),
87
+ BRAND_COLOR: escapeHtml(text.brand.color),
88
+ BRAND_COLOR_SOFT: escapeHtml(text.brand.colorSoft),
89
+ BRAND: escapeHtml(text.brand.name),
90
+ BRAND_SUB: escapeHtml(text.brand.tagline),
91
+ BRAND_LOGO: escapeHtml(text.logo),
92
+ EYEBROW: escapeHtml(text.brand.eyebrow),
93
+ TITLE: escapeHtml(text.title),
94
+ SUBTITLE: escapeHtml(text.subtitle),
95
+ OUTRO_TITLE: escapeHtml(text.recapTitle),
96
+ TOTAL: String(t.total),
97
+ FRAME_W: String(layout.frame.width),
98
+ FRAME_H: String(layout.frame.height),
99
+ FOOTAGE_W: String(layout.footage.width),
100
+ FOOTAGE_H: String(layout.footage.height),
101
+ STAGE_W: String(layout.stage.width),
102
+ STAGE_H: String(layout.stage.height),
103
+ BAND_ZOOM: String(layout.bandZoom),
104
+ FORMAT: layout.phone ? `${layout.format} phone` : layout.format,
105
+ }
106
+ const unfilled = new Set<string>()
107
+ const fill = (source: string, values: Record<string, string>): string =>
108
+ source.replace(/{{([A-Z_]+)}}/g, (match, key: string) => {
109
+ if (key in values) {
110
+ return values[key]
111
+ }
112
+ unfilled.add(match)
113
+ return match
114
+ })
115
+
116
+ const parts = input.sections.map((s) => {
117
+ const slot = t[s.slot]
118
+ if (!slot) {
119
+ throw new Error(`the timeline has no ${s.slot} (recap "none") but a ${s.slot} section was given`)
120
+ }
121
+ const values = {
122
+ ...shared,
123
+ START: String(slot.start),
124
+ DURATION: String(slot.duration),
125
+ TRACK: String(TRACKS[s.slot]),
126
+ ASSETS: s.assets,
127
+ }
128
+ const label = `${s.slot}: ${s.name}`
129
+ return {
130
+ slot: s.slot,
131
+ css: ` /* ── ${label} ── */\n${indent(fill(s.css, values).trim(), 6)}`,
132
+ html: ` <!-- ── ${label} ── -->\n${indent(band(fill(s.html, values).trim(), layout.format === 'landscape' || (layout.format === 'square' ? s.square : s.portrait)), 6)}`,
133
+ js:
134
+ ` // ── ${label} ──\n ;((section) => {\n${indent(fill(s.js, values).trim(), 8)}\n` +
135
+ ` })(DEMO.sections.${s.slot});`,
136
+ }
137
+ })
138
+
139
+ const part = (slot: Slot) => parts.find((p) => p.slot === slot) ?? { html: '', css: '', js: '' }
140
+ const html = fill(input.stage, {
141
+ ...shared,
142
+ SECTION_STYLES: parts.map((p) => p.css).join('\n\n'),
143
+ // Each slot has its own place in the stage: the intro sits under #screen, recap and outro above it.
144
+ INTRO: part('intro').html,
145
+ RECAP: part('recap').html,
146
+ OUTRO: part('outro').html,
147
+ SECTION_SCRIPTS: parts.map((p) => p.js).join('\n\n'),
148
+ VIDEOS: renderVideoSegments(t, (input.media ?? MEDIA).recording),
149
+ TRANSITIONS: t.transitions.map((tr, i) => renderTransitionCard(tr, i, t.belt)).join('\n'),
150
+ AUDIO: input.narration ? renderNarration(t, (input.media ?? MEDIA).narration) : '',
151
+ MUSIC:
152
+ (input.music ? renderMusic(t, (input.media ?? MEDIA).music) : '') +
153
+ (input.voice ? `\n${renderVoice(t, input.media?.voice ?? MEDIA.voice)}` : ''),
154
+ // JSON is valid JS; escaping "<" keeps "</script>" in a callout from closing the tag.
155
+ DEMO: JSON.stringify(demo, null, 2)
156
+ // Number tuples (cursor [t, x, y], camera [t, k, x, y, h]) one per line, not one number per line.
157
+ .replace(/\[\s+((?:-?[\d.]+,\s+)+-?[\d.]+)\s+\]/g, (_m, inner: string) => `[${inner.split(/,\s+/).join(', ')}]`)
158
+ .replace(/</g, '\\u003c')
159
+ .replace(/\n/g, '\n '),
160
+ })
161
+ if (unfilled.size) {
162
+ throw new Error(`template placeholders left unfilled: ${[...unfilled].join(', ')}`)
163
+ }
164
+
165
+ return html
166
+ }
167
+
168
+ /**
169
+ * A section without its own layout for this format (portrait, square): mark its root so the
170
+ * stage zooms its 16:9 card to the width.
171
+ */
172
+ function band(html: string, laidOut = false): string {
173
+ return laidOut ? html : html.replace(/^(<section\b[^>]*\bclass=")([^"]*)"/, '$1$2 band"')
174
+ }
175
+
176
+ function indent(block: string, spaces: number): string {
177
+ const pad = ' '.repeat(spaces)
178
+ return block
179
+ .split('\n')
180
+ .map((line) => (line.trim() ? pad + line : ''))
181
+ .join('\n')
182
+ }
183
+
184
+ /**
185
+ * One <video> clip per stretch of footage between hand-offs (same file,
186
+ * different data-media-start), so the footage pauses while a card is on screen.
187
+ */
188
+ function renderVideoSegments(t: Timeline, src: string): string {
189
+ return t.segments
190
+ .map((s, i) => {
191
+ const id = i === 0 ? 'recording' : `recording-${i + 1}`
192
+
193
+ return ` <video id="${id}" class="clip" src="${src}" muted playsinline
194
+ data-start="${s.start}" data-duration="${s.duration}" data-media-start="${s.mediaStart}" data-track-index="1"></video>`
195
+ })
196
+ .join('\n')
197
+ }
198
+
199
+ /** Markup for a hand-off card; the template's builder animates it from DEMO.transitions. */
200
+ function renderTransitionCard(tr: Timeline['transitions'][number], i: number, belt: number): string {
201
+ const { card } = tr
202
+ const roles =
203
+ card.from && card.to
204
+ ? `
205
+ <div class="roles">
206
+ <div class="role from">${escapeHtml(card.from)}</div>
207
+ <div class="arrow"></div>
208
+ <div class="role to"><div class="fill"></div><span>${escapeHtml(card.to)}</span></div>
209
+ </div>`
210
+ : ''
211
+ const subtitle = card.subtitle
212
+ ? `
213
+ <p class="subtitle">${escapeHtml(card.subtitle)}</p>`
214
+ : ''
215
+
216
+ return ` <!-- Hand-off ${i + 1}: demo.transition() in the scenario -->
217
+ <section id="transition-${i}" class="clip transition-card" data-start="${round(tr.at - belt)}" data-duration="${round(tr.gap + 2 * belt)}" data-track-index="2">
218
+ <div class="stack">${roles}
219
+ <h1 class="title">${escapeHtml(card.title)}</h1>${subtitle}
220
+ </div>
221
+ </section>`
222
+ }
223
+
224
+ function renderNarration(t: Timeline, src: string): string {
225
+ return ` <!-- Narration from the recording (kept in sync via the same start/media-start) -->
226
+ <audio id="narration" class="clip" src="${src}" data-start="${t.clipStart}" data-duration="${round(t.mediaEnd - t.mediaStart)}" data-media-start="${t.mediaStart}" data-track-index="3"></audio>`
227
+ }
228
+
229
+ function renderMusic(t: Timeline, src: string): string {
230
+ return ` <!-- Music bed, pre-rendered by reelson build (trim + loudnorm + fades) -->
231
+ <audio id="music" class="clip" src="${src}" data-start="0" data-duration="${t.total}" data-track-index="4"></audio>`
232
+ }
233
+
234
+ function renderVoice(t: Timeline, src: string): string {
235
+ return ` <!-- Voice-over, pre-mixed by reelson build (each callout spoken as it appears) -->
236
+ <audio id="voice" class="clip" src="${src}" data-start="0" data-duration="${t.total}" data-track-index="5"></audio>`
237
+ }
238
+
239
+ export function escapeHtml(s: string): string {
240
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
241
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The one place the HyperFrames version is pinned. Everything that renders or
3
+ * checks goes through `hyperframes()`, so an upgrade is a one-line change here
4
+ * (verified by the CI smoke test).
5
+ */
6
+ import { spawnSync } from 'node:child_process';
7
+ import { createHash } from 'node:crypto';
8
+ import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { dirname, resolve } from 'node:path';
11
+ export const HYPERFRAMES_VERSION = '0.8.46';
12
+ /** Runs `npx hyperframes@<pinned> <args>` in `cwd`; returns the exit code. */
13
+ export function hyperframes(args, cwd) {
14
+ const run = spawnSync('npx', ['--yes', `hyperframes@${HYPERFRAMES_VERSION}`, ...args], {
15
+ cwd,
16
+ stdio: 'inherit',
17
+ });
18
+ return run.status ?? 1;
19
+ }
20
+ /**
21
+ * Runs `hyperframes <args>` on one composition of a built video (index.html, portrait.html,
22
+ * square.html). `check` and `snapshot` only look at index.html, and lint a project with several
23
+ * root compositions as an error — so the composition runs alone, as the index.html of a scratch
24
+ * project that links the built assets. Output paths in `args` must be absolute.
25
+ */
26
+ export function hyperframesOn(videoDir, composition, args) {
27
+ if (composition === 'index.html' && args[0] !== 'check') {
28
+ return hyperframes(args, videoDir);
29
+ }
30
+ const scratch = mkdtempSync(resolve(tmpdir(), 'reelson-hf-'));
31
+ try {
32
+ symlinkSync(resolve(videoDir, 'assets'), resolve(scratch, 'assets'));
33
+ copyFileSync(resolve(videoDir, 'hyperframes.json'), resolve(scratch, 'hyperframes.json'));
34
+ copyFileSync(resolve(videoDir, composition), resolve(scratch, 'index.html'));
35
+ writeFileSync(resolve(scratch, 'package.json'), '{ "private": true }\n');
36
+ return hyperframes(args, scratch);
37
+ }
38
+ finally {
39
+ rmSync(scratch, { recursive: true, force: true });
40
+ }
41
+ }
42
+ /** HyperFrames' default frame rate (renders and snapshots): a video's last frame is at total − 1/FPS. */
43
+ export const FPS = 30;
44
+ /** Render flags that keep the 2x capture sharp (see the reelson-compose skill). */
45
+ export const RENDER_FLAGS = ['--video-frame-format', 'jpg', '-q', 'delivery'];
46
+ /** A quick look: half the frames, draft encoding — about twice as fast. */
47
+ export const DRAFT_FLAGS = ['--video-frame-format', 'jpg', '-q', 'draft', '--fps', '15'];
48
+ /**
49
+ * Renders `videoDir` to `output` (relative to it) with `flags`, unless nothing it depends on
50
+ * changed since the last render there: index.html, every asset (by size and mtime), the
51
+ * flags and the pinned HyperFrames version. Returns 'rendered' | 'unchanged' | 'failed'.
52
+ */
53
+ export function renderIfChanged(videoDir, output, flags, force = false, composition = 'index.html') {
54
+ const target = resolve(videoDir, output);
55
+ const stamp = `${target}.key`;
56
+ const key = renderKey(videoDir, [...flags, output], composition);
57
+ if (!force && existsSync(target) && existsSync(stamp) && readFileSync(stamp, 'utf8') === key) {
58
+ return 'unchanged';
59
+ }
60
+ const which = composition === 'index.html' ? [] : ['-c', composition];
61
+ if (hyperframes(['render', '.', ...which, ...flags, '-o', output], videoDir) !== 0) {
62
+ return 'failed';
63
+ }
64
+ writeFileSync(stamp, key);
65
+ return 'rendered';
66
+ }
67
+ /** The README-sized GIF `render --gif` makes: width (px), frame rate and palette size. */
68
+ export const GIF = { width: 720, fps: 12, colors: 128 };
69
+ /**
70
+ * Converts a rendered MP4 (relative to `videoDir`) to `output` with ffmpeg — a palette made for this
71
+ * video, no dithering (it turns the soft backgrounds into noise and doubles the size). Skipped when the
72
+ * MP4 has not been re-rendered since (its .key is unchanged). HyperFrames' own GIF encoder is not used:
73
+ * it fails with ffmpeg 7.0 and renders every frame a second time.
74
+ */
75
+ export function gifIfChanged(videoDir, source, output, force = false) {
76
+ const target = resolve(videoDir, output);
77
+ const stamp = `${target}.key`;
78
+ const sourceKey = resolve(videoDir, `${source}.key`);
79
+ const key = createHash('sha1')
80
+ .update(existsSync(sourceKey) ? readFileSync(sourceKey) : String(statSync(resolve(videoDir, source)).mtimeMs))
81
+ .update(JSON.stringify(GIF))
82
+ .digest('hex');
83
+ if (!force && existsSync(target) && existsSync(stamp) && readFileSync(stamp, 'utf8') === key) {
84
+ return 'unchanged';
85
+ }
86
+ const filter = `fps=${GIF.fps},scale=${GIF.width}:-2:flags=lanczos,split[a][b];` +
87
+ `[a]palettegen=stats_mode=diff:max_colors=${GIF.colors}[p];[b][p]paletteuse=dither=none:diff_mode=rectangle`;
88
+ const run = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', resolve(videoDir, source), '-vf', filter, '-loop', '0', target], {
89
+ stdio: 'inherit',
90
+ });
91
+ if (run.status !== 0) {
92
+ return 'failed';
93
+ }
94
+ writeFileSync(stamp, key);
95
+ return 'rendered';
96
+ }
97
+ export function renderKey(videoDir, args, composition = 'index.html') {
98
+ const hash = createHash('sha1').update(HYPERFRAMES_VERSION).update(JSON.stringify(args));
99
+ hash.update(readFileSync(resolve(videoDir, composition)));
100
+ const walk = (dir) => {
101
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
102
+ const path = resolve(dir, entry.name);
103
+ if (entry.isDirectory()) {
104
+ walk(path);
105
+ }
106
+ else if (!entry.name.endsWith('.key')) {
107
+ const { size, mtimeMs } = statSync(path);
108
+ hash.update(`${path}:${size}:${mtimeMs}\n`);
109
+ }
110
+ }
111
+ };
112
+ if (existsSync(resolve(videoDir, 'assets'))) {
113
+ walk(resolve(videoDir, 'assets'));
114
+ }
115
+ return hash.digest('hex');
116
+ }
117
+ let dist = null;
118
+ /**
119
+ * The pinned HyperFrames package's dist/ (player + runtime), from the npx cache.
120
+ * The studio serves both from here, so the preview works offline.
121
+ */
122
+ export function hyperframesDist() {
123
+ if (dist) {
124
+ return dist;
125
+ }
126
+ const which = spawnSync('npm', ['exec', '--yes', `--package=hyperframes@${HYPERFRAMES_VERSION}`, '-c', 'which hyperframes'], {
127
+ encoding: 'utf8',
128
+ });
129
+ const bin = which.stdout.trim().split('\n').at(-1);
130
+ if (which.status !== 0 || !bin) {
131
+ throw new Error(`could not install hyperframes@${HYPERFRAMES_VERSION}: ${which.stderr.trim()}`);
132
+ }
133
+ dist = resolve(dirname(realpathSync(bin)), '..', 'dist');
134
+ if (!existsSync(resolve(dist, 'hyperframes-player.global.js'))) {
135
+ throw new Error(`no HyperFrames player in ${dist}`);
136
+ }
137
+ return dist;
138
+ }