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.
- package/CHANGELOG.md +99 -0
- package/LICENSE +21 -0
- package/NOTICE.md +15 -0
- package/README.md +180 -0
- package/bin/reelson.js +649 -0
- package/bin/reelson.ts +676 -0
- package/bin/run.js +5 -0
- package/demo.config.example.json +51 -0
- package/docs/demo.webp +0 -0
- package/docs/prompting.md +72 -0
- package/docs/style-guide.md +117 -0
- package/examples/demo.config.json +18 -0
- package/examples/todo-add-item/scenario.ts +45 -0
- package/examples/todo-add-item/video.json +35 -0
- package/music/README.md +7 -0
- package/package.json +68 -0
- package/skills/reelson-compose/SKILL.md +226 -0
- package/skills/reelson-compose/schemas/video.schema.json +172 -0
- package/skills/reelson-compose/scripts/build.js +424 -0
- package/skills/reelson-compose/scripts/build.ts +532 -0
- package/skills/reelson-compose/scripts/captions.js +24 -0
- package/skills/reelson-compose/scripts/captions.ts +40 -0
- package/skills/reelson-compose/scripts/check.js +80 -0
- package/skills/reelson-compose/scripts/check.ts +93 -0
- package/skills/reelson-compose/scripts/composition.js +163 -0
- package/skills/reelson-compose/scripts/composition.ts +241 -0
- package/skills/reelson-compose/scripts/hyperframes.js +138 -0
- package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
- package/skills/reelson-compose/scripts/portrait.js +150 -0
- package/skills/reelson-compose/scripts/portrait.ts +218 -0
- package/skills/reelson-compose/scripts/project.js +238 -0
- package/skills/reelson-compose/scripts/project.ts +324 -0
- package/skills/reelson-compose/scripts/studio.js +330 -0
- package/skills/reelson-compose/scripts/studio.ts +394 -0
- package/skills/reelson-compose/scripts/timeline.js +271 -0
- package/skills/reelson-compose/scripts/timeline.ts +514 -0
- package/skills/reelson-compose/scripts/tts.js +303 -0
- package/skills/reelson-compose/scripts/tts.ts +371 -0
- package/skills/reelson-compose/scripts/verify.js +190 -0
- package/skills/reelson-compose/scripts/verify.ts +209 -0
- package/skills/reelson-compose/scripts/voice.js +178 -0
- package/skills/reelson-compose/scripts/voice.ts +224 -0
- package/skills/reelson-compose/scripts/zooms.js +259 -0
- package/skills/reelson-compose/scripts/zooms.ts +333 -0
- package/skills/reelson-compose/sections/README.md +5 -0
- package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
- package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
- package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
- package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
- package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
- package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
- package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
- package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
- package/skills/reelson-compose/sections/intro/split/section.css +30 -0
- package/skills/reelson-compose/sections/intro/split/section.html +12 -0
- package/skills/reelson-compose/sections/intro/split/section.js +19 -0
- package/skills/reelson-compose/sections/intro/split/section.json +9 -0
- package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
- package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
- package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
- package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
- package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
- package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
- package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
- package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
- package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
- package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
- package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
- package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
- package/skills/reelson-compose/studio/index.html +81 -0
- package/skills/reelson-compose/studio/studio.css +142 -0
- package/skills/reelson-compose/studio/studio.js +1031 -0
- package/skills/reelson-compose/templates/README.md +193 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/stage.html +447 -0
- package/skills/reelson-compose/templates/classic/template.json +5 -0
- package/skills/reelson-record/SKILL.md +220 -0
- package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
- package/skills/reelson-record/scripts/capture.js +142 -0
- package/skills/reelson-record/scripts/capture.ts +190 -0
- package/skills/reelson-record/scripts/config.js +148 -0
- package/skills/reelson-record/scripts/config.ts +264 -0
- package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
- package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
- package/skills/reelson-record/scripts/doctor.js +128 -0
- package/skills/reelson-record/scripts/doctor.ts +147 -0
- package/skills/reelson-record/scripts/languages.js +29 -0
- package/skills/reelson-record/scripts/languages.ts +41 -0
- package/skills/reelson-record/scripts/record.js +365 -0
- package/skills/reelson-record/scripts/record.ts +451 -0
- package/skills/reelson-record/scripts/scenario.js +356 -0
- package/skills/reelson-record/scripts/scenario.ts +570 -0
- package/skills/reelson-record/scripts/validate.js +143 -0
- 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
|
+
}
|