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,238 @@
1
+ /**
2
+ * File-level plumbing shared by build, check and the CLI: where a demo lives,
3
+ * reading markers.json / video.json (validated), finding a template and its sections.
4
+ */
5
+ import { spawnSync } from 'node:child_process';
6
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
7
+ import { basename, dirname, extname, relative, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { fromRoot } from "../../reelson-record/scripts/config.js";
10
+ import { closest, loadSchema, validate } from "../../reelson-record/scripts/validate.js";
11
+ import { SECTION_TIMING, SLOTS, STAGE_TIMING, } from "./timeline.js";
12
+ const HERE = dirname(fileURLToPath(import.meta.url));
13
+ export const KIT_ROOT = resolve(HERE, '../../..');
14
+ /** The recorder, run in its own process: record.ts in a checkout, record.js in the published package (compiled beside it). */
15
+ export const RECORD_SCRIPT = resolve(KIT_ROOT, `skills/reelson-record/scripts/record${extname(fileURLToPath(import.meta.url))}`);
16
+ export const VIDEO_SCHEMA_PATH = resolve(HERE, '../schemas/video.schema.json');
17
+ export const BUILTIN_TEMPLATES = resolve(HERE, '../templates');
18
+ export const BUILTIN_SECTIONS = resolve(HERE, '../sections');
19
+ /** A user-facing failure: printed without a stack trace. */
20
+ export class ReelsonError extends Error {
21
+ }
22
+ /** `slug`, a demo directory, or a file inside one → the demo directory. */
23
+ export function resolveDemoDir(arg, config) {
24
+ const asPath = resolve(arg);
25
+ if (existsSync(asPath)) {
26
+ return arg.endsWith('.ts') || arg.endsWith('.json') ? dirname(asPath) : asPath;
27
+ }
28
+ const inVideos = resolve(fromRoot(config, config.videosDir), arg);
29
+ if (existsSync(inVideos)) {
30
+ return inVideos;
31
+ }
32
+ throw new ReelsonError(`no demo "${arg}" (looked for ${asPath} and ${inVideos})`);
33
+ }
34
+ /** Every demo folder under videosDir that has a video.json (for `render --all`). */
35
+ export function listDemos(config) {
36
+ const dir = fromRoot(config, config.videosDir);
37
+ if (!existsSync(dir)) {
38
+ return [];
39
+ }
40
+ const found = spawnSync('find', [dir, '-mindepth', '2', '-maxdepth', '2', '-name', 'video.json'], {
41
+ encoding: 'utf8',
42
+ });
43
+ return found.stdout
44
+ .split('\n')
45
+ .filter(Boolean)
46
+ .map((p) => dirname(p))
47
+ .filter((d) => !basename(d).startsWith('_'))
48
+ .sort();
49
+ }
50
+ export function readMarkers(demoDir) {
51
+ const path = resolve(demoDir, 'markers.json');
52
+ if (existsSync(path)) {
53
+ return JSON.parse(readFileSync(path, 'utf8'));
54
+ }
55
+ const recording = resolve(demoDir, 'recording.mp4');
56
+ if (!existsSync(recording)) {
57
+ throw new ReelsonError(`${demoDir} has no recording.mp4 — run \`reelson record\` first (or export one from OpenScreen)`);
58
+ }
59
+ return probeRecording(recording);
60
+ }
61
+ export function readVideoSpec(demoDir) {
62
+ const path = resolve(demoDir, 'video.json');
63
+ if (!existsSync(path)) {
64
+ return null;
65
+ }
66
+ let raw;
67
+ try {
68
+ raw = JSON.parse(readFileSync(path, 'utf8'));
69
+ }
70
+ catch (error) {
71
+ throw new ReelsonError(`${path} is not valid JSON: ${error.message}`);
72
+ }
73
+ return validateVideoSpec(raw, path);
74
+ }
75
+ /** `raw` checked against the video.json schema; `where` names it in the error. */
76
+ export function validateVideoSpec(raw, where) {
77
+ const problems = validate(raw, loadSchema(VIDEO_SCHEMA_PATH));
78
+ if (problems.length) {
79
+ throw new ReelsonError(`${where} is invalid:\n ${problems.join('\n ')}`);
80
+ }
81
+ return raw;
82
+ }
83
+ /**
84
+ * `$schema` for a new video.json: through the project's .claude/skills link
85
+ * when there is one (portable), else straight to the kit.
86
+ */
87
+ export function videoSchemaRef(demoDir, config) {
88
+ const viaProject = resolve(config.root, '.claude/skills/reelson-compose/schemas/video.schema.json');
89
+ const target = existsSync(viaProject) && realpathSync(viaProject) === realpathSync(VIDEO_SCHEMA_PATH)
90
+ ? viaProject
91
+ : VIDEO_SCHEMA_PATH;
92
+ return relative(demoDir, target);
93
+ }
94
+ /** The recap slot accepts this instead of a section name. */
95
+ export const NO_SECTION = 'none';
96
+ const templateDirs = (config) => [
97
+ resolve(fromRoot(config, config.videosDir), '_templates'),
98
+ BUILTIN_TEMPLATES,
99
+ ];
100
+ const sectionDirs = (config, slot) => [
101
+ resolve(fromRoot(config, config.videosDir), '_sections', slot),
102
+ resolve(BUILTIN_SECTIONS, slot),
103
+ ];
104
+ /**
105
+ * Template lookup: a project-local <videosDir>/_templates/<name>/ wins over the
106
+ * kit's templates/<name>/, so a project can fork a template without touching the kit.
107
+ */
108
+ export function findTemplate(name, config) {
109
+ const candidates = templateDirs(config).map((d) => resolve(d, name));
110
+ const dir = candidates.find((d) => existsSync(resolve(d, 'stage.html')));
111
+ if (!dir) {
112
+ throw new ReelsonError(`template "${name}" not found${hint(name, available(templateDirs(config), 'stage.html'))}; looked in:\n ${candidates.join('\n ')}`);
113
+ }
114
+ const metaPath = resolve(dir, 'template.json');
115
+ const meta = readJson(metaPath);
116
+ unknownKeys(metaPath, meta, ['description', 'sections', 'timing']);
117
+ unknownKeys(`${metaPath} timing`, meta.timing ?? {}, Object.keys(STAGE_TIMING));
118
+ unknownKeys(`${metaPath} sections`, meta.sections ?? {}, SLOTS);
119
+ const sections = { intro: 'poster', recap: 'steps', outro: 'wordmark', ...meta.sections };
120
+ return { name, dir, description: meta.description ?? '', sections, stage: { ...STAGE_TIMING, ...meta.timing } };
121
+ }
122
+ /** Section lookup: <videosDir>/_sections/<slot>/<name>/ first, then the kit's sections/<slot>/<name>/. */
123
+ export function findSection(slot, name, config) {
124
+ const candidates = sectionDirs(config, slot).map((d) => resolve(d, name));
125
+ const dir = candidates.find((d) => existsSync(resolve(d, 'section.html')));
126
+ if (!dir) {
127
+ const names = [...available(sectionDirs(config, slot)), ...(slot === 'recap' ? [NO_SECTION] : [])];
128
+ throw new ReelsonError(`${slot} section "${name}" not found${hint(name, names)} (available: ${names.join(', ')}); looked in:\n ${candidates.join('\n ')}`);
129
+ }
130
+ const metaPath = resolve(dir, 'section.json');
131
+ const meta = readJson(metaPath);
132
+ unknownKeys(metaPath, meta, ['description', 'timing', 'portrait', 'square']);
133
+ unknownKeys(`${metaPath} timing`, meta.timing ?? {}, Object.keys(SECTION_TIMING[slot]));
134
+ return { slot, name, dir, description: meta.description ?? '', timing: { ...SECTION_TIMING[slot], ...meta.timing }, portrait: meta.portrait === true, square: meta.square === true };
135
+ }
136
+ /**
137
+ * The template plus one section per slot. Later choices win: the template's
138
+ * defaults, then each entry of `choices` in order (demo.config.json, video.json).
139
+ */
140
+ export function resolveDesign(templateName, choices, config) {
141
+ const template = findTemplate(templateName, config);
142
+ const picked = Object.assign({}, template.sections, ...choices.filter(Boolean));
143
+ for (const slot of ['intro', 'outro']) {
144
+ if (picked[slot] === NO_SECTION) {
145
+ throw new ReelsonError(`the ${slot} can't be "none": every video opens on a poster and ends on the brand (style guide rules 1–2)`);
146
+ }
147
+ }
148
+ const intro = findSection('intro', picked.intro, config);
149
+ const recap = picked.recap === NO_SECTION ? null : findSection('recap', picked.recap, config);
150
+ const outro = findSection('outro', picked.outro, config);
151
+ return {
152
+ template,
153
+ sections: { intro, recap, outro },
154
+ timing: {
155
+ stage: template.stage,
156
+ intro: intro.timing,
157
+ recap: recap ? recap.timing : null,
158
+ outro: outro.timing,
159
+ },
160
+ };
161
+ }
162
+ /** A section's files for the composer; its assets/ (if any) belong at `assets` inside video/. */
163
+ export function readSection(section) {
164
+ const read = (file) => {
165
+ const path = resolve(section.dir, file);
166
+ return existsSync(path) ? readFileSync(path, 'utf8') : '';
167
+ };
168
+ return {
169
+ slot: section.slot,
170
+ name: section.name,
171
+ html: read('section.html'),
172
+ css: read('section.css'),
173
+ js: read('section.js'),
174
+ assets: existsSync(resolve(section.dir, 'assets')) ? `assets/sections/${section.slot}-${section.name}` : '',
175
+ portrait: section.portrait,
176
+ square: section.square,
177
+ };
178
+ }
179
+ /** Every template and section a project can use (project-local ones first), for `reelson templates`. */
180
+ export function catalog(config) {
181
+ const templates = available(templateDirs(config), 'stage.html').map((name) => {
182
+ const { dir, description } = findTemplate(name, config);
183
+ return { name, description, local: dir !== resolve(BUILTIN_TEMPLATES, name) };
184
+ });
185
+ const sections = Object.fromEntries(SLOTS.map((slot) => {
186
+ const found = available(sectionDirs(config, slot)).map((name) => {
187
+ const { dir, description } = findSection(slot, name, config);
188
+ return { name, description, local: dir !== resolve(BUILTIN_SECTIONS, slot, name) };
189
+ });
190
+ if (slot === 'recap') {
191
+ found.push({ name: NO_SECTION, description: 'No recap: the outro follows the recording.', local: false });
192
+ }
193
+ return [slot, found];
194
+ }));
195
+ return { templates, sections };
196
+ }
197
+ /** Folder names under `dirs` that hold `marker` (default section.html), deduplicated and sorted. */
198
+ function available(dirs, marker = 'section.html') {
199
+ const names = dirs.flatMap((d) => existsSync(d)
200
+ ? readdirSync(d, { withFileTypes: true })
201
+ .filter((e) => e.isDirectory() && existsSync(resolve(d, e.name, marker)))
202
+ .map((e) => e.name)
203
+ : []);
204
+ return [...new Set(names)].sort();
205
+ }
206
+ function hint(name, names) {
207
+ const match = closest(name, names);
208
+ return match ? ` — did you mean "${match}"?` : '';
209
+ }
210
+ function readJson(path) {
211
+ if (!existsSync(path)) {
212
+ return {};
213
+ }
214
+ try {
215
+ return JSON.parse(readFileSync(path, 'utf8'));
216
+ }
217
+ catch (error) {
218
+ throw new ReelsonError(`${path} is not valid JSON: ${error.message}`);
219
+ }
220
+ }
221
+ function unknownKeys(where, value, known) {
222
+ const unknown = Object.keys(value).filter((k) => !known.includes(k) && !k.startsWith('$'));
223
+ if (unknown.length) {
224
+ throw new ReelsonError(`${where}: unknown key(s) ${unknown.map((k) => `"${k}"${hint(k, [...known])}`).join(', ')} (known: ${known.join(', ')})`);
225
+ }
226
+ }
227
+ function probeRecording(path) {
228
+ const probe = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height:format=duration', '-of', 'json', path], { encoding: 'utf8' });
229
+ if (probe.status !== 0) {
230
+ throw new ReelsonError('ffprobe failed — install ffmpeg (brew install ffmpeg)');
231
+ }
232
+ const info = JSON.parse(probe.stdout);
233
+ return {
234
+ durationSeconds: Number.parseFloat(info.format.duration),
235
+ viewport: { width: info.streams[0].width, height: info.streams[0].height },
236
+ markers: [],
237
+ };
238
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * File-level plumbing shared by build, check and the CLI: where a demo lives,
3
+ * reading markers.json / video.json (validated), finding a template and its sections.
4
+ */
5
+ import { spawnSync } from 'node:child_process'
6
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
7
+ import { basename, dirname, extname, relative, resolve } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { fromRoot, type LoadedConfig } from '../../reelson-record/scripts/config.ts'
10
+ import { closest, loadSchema, validate } from '../../reelson-record/scripts/validate.ts'
11
+ import type { SectionSource } from './composition.ts'
12
+ import {
13
+ SECTION_TIMING,
14
+ SLOTS,
15
+ STAGE_TIMING,
16
+ type Markers,
17
+ type SectionChoice,
18
+ type Slot,
19
+ type StageTiming,
20
+ type Timing,
21
+ type VideoSpec,
22
+ } from './timeline.ts'
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url))
25
+ export const KIT_ROOT = resolve(HERE, '../../..')
26
+ /** The recorder, run in its own process: record.ts in a checkout, record.js in the published package (compiled beside it). */
27
+ export const RECORD_SCRIPT = resolve(KIT_ROOT, `skills/reelson-record/scripts/record${extname(fileURLToPath(import.meta.url))}`)
28
+ export const VIDEO_SCHEMA_PATH = resolve(HERE, '../schemas/video.schema.json')
29
+ export const BUILTIN_TEMPLATES = resolve(HERE, '../templates')
30
+ export const BUILTIN_SECTIONS = resolve(HERE, '../sections')
31
+
32
+ /** A user-facing failure: printed without a stack trace. */
33
+ export class ReelsonError extends Error {}
34
+
35
+ /** `slug`, a demo directory, or a file inside one → the demo directory. */
36
+ export function resolveDemoDir(arg: string, config: LoadedConfig): string {
37
+ const asPath = resolve(arg)
38
+ if (existsSync(asPath)) {
39
+ return arg.endsWith('.ts') || arg.endsWith('.json') ? dirname(asPath) : asPath
40
+ }
41
+ const inVideos = resolve(fromRoot(config, config.videosDir), arg)
42
+ if (existsSync(inVideos)) {
43
+ return inVideos
44
+ }
45
+ throw new ReelsonError(`no demo "${arg}" (looked for ${asPath} and ${inVideos})`)
46
+ }
47
+
48
+ /** Every demo folder under videosDir that has a video.json (for `render --all`). */
49
+ export function listDemos(config: LoadedConfig): string[] {
50
+ const dir = fromRoot(config, config.videosDir)
51
+ if (!existsSync(dir)) {
52
+ return []
53
+ }
54
+ const found = spawnSync('find', [dir, '-mindepth', '2', '-maxdepth', '2', '-name', 'video.json'], {
55
+ encoding: 'utf8',
56
+ })
57
+
58
+ return found.stdout
59
+ .split('\n')
60
+ .filter(Boolean)
61
+ .map((p) => dirname(p))
62
+ .filter((d) => !basename(d).startsWith('_'))
63
+ .sort()
64
+ }
65
+
66
+ export function readMarkers(demoDir: string): Markers {
67
+ const path = resolve(demoDir, 'markers.json')
68
+ if (existsSync(path)) {
69
+ return JSON.parse(readFileSync(path, 'utf8')) as Markers
70
+ }
71
+ const recording = resolve(demoDir, 'recording.mp4')
72
+ if (!existsSync(recording)) {
73
+ throw new ReelsonError(`${demoDir} has no recording.mp4 — run \`reelson record\` first (or export one from OpenScreen)`)
74
+ }
75
+
76
+ return probeRecording(recording)
77
+ }
78
+
79
+ export function readVideoSpec(demoDir: string): VideoSpec | null {
80
+ const path = resolve(demoDir, 'video.json')
81
+ if (!existsSync(path)) {
82
+ return null
83
+ }
84
+ let raw: unknown
85
+ try {
86
+ raw = JSON.parse(readFileSync(path, 'utf8'))
87
+ } catch (error) {
88
+ throw new ReelsonError(`${path} is not valid JSON: ${(error as Error).message}`)
89
+ }
90
+
91
+ return validateVideoSpec(raw, path)
92
+ }
93
+
94
+ /** `raw` checked against the video.json schema; `where` names it in the error. */
95
+ export function validateVideoSpec(raw: unknown, where: string): VideoSpec {
96
+ const problems = validate(raw, loadSchema(VIDEO_SCHEMA_PATH))
97
+ if (problems.length) {
98
+ throw new ReelsonError(`${where} is invalid:\n ${problems.join('\n ')}`)
99
+ }
100
+
101
+ return raw as VideoSpec
102
+ }
103
+
104
+ /**
105
+ * `$schema` for a new video.json: through the project's .claude/skills link
106
+ * when there is one (portable), else straight to the kit.
107
+ */
108
+ export function videoSchemaRef(demoDir: string, config: LoadedConfig): string {
109
+ const viaProject = resolve(config.root, '.claude/skills/reelson-compose/schemas/video.schema.json')
110
+ const target =
111
+ existsSync(viaProject) && realpathSync(viaProject) === realpathSync(VIDEO_SCHEMA_PATH)
112
+ ? viaProject
113
+ : VIDEO_SCHEMA_PATH
114
+
115
+ return relative(demoDir, target)
116
+ }
117
+
118
+ export interface Template {
119
+ name: string
120
+ dir: string
121
+ description: string
122
+ /** Sections used when neither demo.config.json nor video.json picks one. */
123
+ sections: Record<Slot, string>
124
+ stage: StageTiming
125
+ }
126
+
127
+ export interface Section {
128
+ slot: Slot
129
+ name: string
130
+ dir: string
131
+ description: string
132
+ timing: Record<string, number>
133
+ /** Has its own portrait layout (`#root.portrait …` rules); otherwise portrait zooms its 16:9 card. */
134
+ portrait: boolean
135
+ /** Has its own square layout (`#root.square …` rules); otherwise square zooms its 16:9 card. */
136
+ square: boolean
137
+ }
138
+
139
+ /** A template plus the section chosen for each slot: everything the build draws with. */
140
+ export interface Design {
141
+ template: Template
142
+ /** recap is null when the video has none. */
143
+ sections: { intro: Section; recap: Section | null; outro: Section }
144
+ timing: Timing
145
+ }
146
+
147
+ /** The recap slot accepts this instead of a section name. */
148
+ export const NO_SECTION = 'none'
149
+
150
+ const templateDirs = (config: LoadedConfig) => [
151
+ resolve(fromRoot(config, config.videosDir), '_templates'),
152
+ BUILTIN_TEMPLATES,
153
+ ]
154
+ const sectionDirs = (config: LoadedConfig, slot: Slot) => [
155
+ resolve(fromRoot(config, config.videosDir), '_sections', slot),
156
+ resolve(BUILTIN_SECTIONS, slot),
157
+ ]
158
+
159
+ /**
160
+ * Template lookup: a project-local <videosDir>/_templates/<name>/ wins over the
161
+ * kit's templates/<name>/, so a project can fork a template without touching the kit.
162
+ */
163
+ export function findTemplate(name: string, config: LoadedConfig): Template {
164
+ const candidates = templateDirs(config).map((d) => resolve(d, name))
165
+ const dir = candidates.find((d) => existsSync(resolve(d, 'stage.html')))
166
+ if (!dir) {
167
+ throw new ReelsonError(`template "${name}" not found${hint(name, available(templateDirs(config), 'stage.html'))}; looked in:\n ${candidates.join('\n ')}`)
168
+ }
169
+ const metaPath = resolve(dir, 'template.json')
170
+ const meta = readJson(metaPath) as { description?: string; sections?: SectionChoice; timing?: Partial<StageTiming> }
171
+ unknownKeys(metaPath, meta, ['description', 'sections', 'timing'])
172
+ unknownKeys(`${metaPath} timing`, meta.timing ?? {}, Object.keys(STAGE_TIMING))
173
+ unknownKeys(`${metaPath} sections`, meta.sections ?? {}, SLOTS)
174
+ const sections = { intro: 'poster', recap: 'steps', outro: 'wordmark', ...meta.sections }
175
+
176
+ return { name, dir, description: meta.description ?? '', sections, stage: { ...STAGE_TIMING, ...meta.timing } }
177
+ }
178
+
179
+ /** Section lookup: <videosDir>/_sections/<slot>/<name>/ first, then the kit's sections/<slot>/<name>/. */
180
+ export function findSection(slot: Slot, name: string, config: LoadedConfig): Section {
181
+ const candidates = sectionDirs(config, slot).map((d) => resolve(d, name))
182
+ const dir = candidates.find((d) => existsSync(resolve(d, 'section.html')))
183
+ if (!dir) {
184
+ const names = [...available(sectionDirs(config, slot)), ...(slot === 'recap' ? [NO_SECTION] : [])]
185
+ throw new ReelsonError(
186
+ `${slot} section "${name}" not found${hint(name, names)} (available: ${names.join(', ')}); looked in:\n ${candidates.join('\n ')}`,
187
+ )
188
+ }
189
+ const metaPath = resolve(dir, 'section.json')
190
+ const meta = readJson(metaPath) as { description?: string; timing?: Record<string, number>; portrait?: boolean; square?: boolean }
191
+ unknownKeys(metaPath, meta, ['description', 'timing', 'portrait', 'square'])
192
+ unknownKeys(`${metaPath} timing`, meta.timing ?? {}, Object.keys(SECTION_TIMING[slot]))
193
+
194
+ return { slot, name, dir, description: meta.description ?? '', timing: { ...SECTION_TIMING[slot], ...meta.timing }, portrait: meta.portrait === true, square: meta.square === true }
195
+ }
196
+
197
+ /**
198
+ * The template plus one section per slot. Later choices win: the template's
199
+ * defaults, then each entry of `choices` in order (demo.config.json, video.json).
200
+ */
201
+ export function resolveDesign(templateName: string, choices: (SectionChoice | undefined)[], config: LoadedConfig): Design {
202
+ const template = findTemplate(templateName, config)
203
+ const picked = Object.assign({}, template.sections, ...choices.filter(Boolean)) as Record<Slot, string>
204
+ for (const slot of ['intro', 'outro'] as const) {
205
+ if (picked[slot] === NO_SECTION) {
206
+ throw new ReelsonError(
207
+ `the ${slot} can't be "none": every video opens on a poster and ends on the brand (style guide rules 1–2)`,
208
+ )
209
+ }
210
+ }
211
+ const intro = findSection('intro', picked.intro, config)
212
+ const recap = picked.recap === NO_SECTION ? null : findSection('recap', picked.recap, config)
213
+ const outro = findSection('outro', picked.outro, config)
214
+
215
+ return {
216
+ template,
217
+ sections: { intro, recap, outro },
218
+ timing: {
219
+ stage: template.stage,
220
+ intro: intro.timing as Timing['intro'],
221
+ recap: recap ? (recap.timing as NonNullable<Timing['recap']>) : null,
222
+ outro: outro.timing as Timing['outro'],
223
+ },
224
+ }
225
+ }
226
+
227
+ /** A section's files for the composer; its assets/ (if any) belong at `assets` inside video/. */
228
+ export function readSection(section: Section): SectionSource {
229
+ const read = (file: string): string => {
230
+ const path = resolve(section.dir, file)
231
+ return existsSync(path) ? readFileSync(path, 'utf8') : ''
232
+ }
233
+
234
+ return {
235
+ slot: section.slot,
236
+ name: section.name,
237
+ html: read('section.html'),
238
+ css: read('section.css'),
239
+ js: read('section.js'),
240
+ assets: existsSync(resolve(section.dir, 'assets')) ? `assets/sections/${section.slot}-${section.name}` : '',
241
+ portrait: section.portrait,
242
+ square: section.square,
243
+ }
244
+ }
245
+
246
+ /** Every template and section a project can use (project-local ones first), for `reelson templates`. */
247
+ export function catalog(config: LoadedConfig): {
248
+ templates: { name: string; description: string; local: boolean }[]
249
+ sections: Record<Slot, { name: string; description: string; local: boolean }[]>
250
+ } {
251
+ const templates = available(templateDirs(config), 'stage.html').map((name) => {
252
+ const { dir, description } = findTemplate(name, config)
253
+ return { name, description, local: dir !== resolve(BUILTIN_TEMPLATES, name) }
254
+ })
255
+ const sections = Object.fromEntries(
256
+ SLOTS.map((slot) => {
257
+ const found = available(sectionDirs(config, slot)).map((name) => {
258
+ const { dir, description } = findSection(slot, name, config)
259
+ return { name, description, local: dir !== resolve(BUILTIN_SECTIONS, slot, name) }
260
+ })
261
+ if (slot === 'recap') {
262
+ found.push({ name: NO_SECTION, description: 'No recap: the outro follows the recording.', local: false })
263
+ }
264
+ return [slot, found]
265
+ }),
266
+ ) as Record<Slot, { name: string; description: string; local: boolean }[]>
267
+
268
+ return { templates, sections }
269
+ }
270
+
271
+ /** Folder names under `dirs` that hold `marker` (default section.html), deduplicated and sorted. */
272
+ function available(dirs: string[], marker = 'section.html'): string[] {
273
+ const names = dirs.flatMap((d) =>
274
+ existsSync(d)
275
+ ? readdirSync(d, { withFileTypes: true })
276
+ .filter((e) => e.isDirectory() && existsSync(resolve(d, e.name, marker)))
277
+ .map((e) => e.name)
278
+ : [],
279
+ )
280
+ return [...new Set(names)].sort()
281
+ }
282
+
283
+ function hint(name: string, names: string[]): string {
284
+ const match = closest(name, names)
285
+ return match ? ` — did you mean "${match}"?` : ''
286
+ }
287
+
288
+ function readJson(path: string): unknown {
289
+ if (!existsSync(path)) {
290
+ return {}
291
+ }
292
+ try {
293
+ return JSON.parse(readFileSync(path, 'utf8'))
294
+ } catch (error) {
295
+ throw new ReelsonError(`${path} is not valid JSON: ${(error as Error).message}`)
296
+ }
297
+ }
298
+
299
+ function unknownKeys(where: string, value: object, known: readonly string[]): void {
300
+ const unknown = Object.keys(value).filter((k) => !known.includes(k) && !k.startsWith('$'))
301
+ if (unknown.length) {
302
+ throw new ReelsonError(
303
+ `${where}: unknown key(s) ${unknown.map((k) => `"${k}"${hint(k, [...known])}`).join(', ')} (known: ${known.join(', ')})`,
304
+ )
305
+ }
306
+ }
307
+
308
+ function probeRecording(path: string): Markers {
309
+ const probe = spawnSync(
310
+ 'ffprobe',
311
+ ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height:format=duration', '-of', 'json', path],
312
+ { encoding: 'utf8' },
313
+ )
314
+ if (probe.status !== 0) {
315
+ throw new ReelsonError('ffprobe failed — install ffmpeg (brew install ffmpeg)')
316
+ }
317
+ const info = JSON.parse(probe.stdout)
318
+
319
+ return {
320
+ durationSeconds: Number.parseFloat(info.format.duration),
321
+ viewport: { width: info.streams[0].width, height: info.streams[0].height },
322
+ markers: [],
323
+ }
324
+ }