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,330 @@
1
+ /**
2
+ * `reelson studio <slug>`: a local preview and editor for a demo. The built
3
+ * composition plays in the HyperFrames player above a timeline drawn from the
4
+ * same plan the build uses (sections, callouts, zooms, hand-offs, clicks,
5
+ * markers, audio). video.json, markers.json, the recording and every
6
+ * template/section folder are watched: a change rebuilds and the page reloads
7
+ * in place, at the same time.
8
+ *
9
+ * Edits made on the page come back as a whole video.json (PUT /api/video): it
10
+ * is validated and planned before it is written, and refused if video.json
11
+ * changed on disk since the page loaded it.
12
+ *
13
+ * reelson studio <slug|dir> [--port 4800] [--no-open]
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { existsSync, readFileSync, statSync, watch, writeFileSync } from 'node:fs';
17
+ import { createServer } from 'node:http';
18
+ import { basename, extname, resolve, sep } from 'node:path';
19
+ import { fromRoot } from "../../reelson-record/scripts/config.js";
20
+ import { build, planSpec, serializeVideoSpec } from "./build.js";
21
+ import { hyperframesDist } from "./hyperframes.js";
22
+ import { BUILTIN_SECTIONS, BUILTIN_TEMPLATES, catalog, readMarkers, ReelsonError, validateVideoSpec } from "./project.js";
23
+ import { defaultCallouts, round, SLOTS } from "./timeline.js";
24
+ import { checkZoom, zoomOverlaps } from "./zooms.js";
25
+ const STUDIO_DIR = resolve(import.meta.dirname, '../studio');
26
+ export function studioData(slug, result, audio) {
27
+ const { spec, markers, design, timeline: t, clicks, zooms, warnings } = result;
28
+ const { intro, recap, outro } = design.sections;
29
+ const sections = [
30
+ { slot: 'intro', name: intro.name, start: 0, end: t.intro.duration, detail: `hands over at ${t.intro.exit}s` },
31
+ {
32
+ slot: 'recording',
33
+ name: 'recording',
34
+ start: t.clipStart,
35
+ end: t.clipEnd,
36
+ detail: `media ${t.mediaStart}–${t.mediaEnd}s of ${markers.durationSeconds}s`,
37
+ },
38
+ ];
39
+ if (recap && t.recap) {
40
+ sections.push({
41
+ slot: 'recap',
42
+ name: recap.name,
43
+ start: t.recap.start,
44
+ end: round(t.recap.start + t.recap.duration),
45
+ detail: `holds ${t.recap.maxSteps} steps`,
46
+ });
47
+ }
48
+ sections.push({ slot: 'outro', name: outro.name, start: t.outro.start, end: t.total, detail: '' });
49
+ // Without callouts in video.json the build makes one per marker, so all of them are used.
50
+ const overlaps = zoomOverlaps(zooms);
51
+ const used = new Set((spec.callouts ?? defaultCallouts(markers)).map((c) => c.marker).filter(Boolean));
52
+ return {
53
+ slug,
54
+ title: spec.title,
55
+ total: t.total,
56
+ frame: t.frame,
57
+ template: design.template.name,
58
+ // The page edits callouts by index, so the default ones are spelled out.
59
+ spec: { ...withoutSchema(spec), callouts: spec.callouts ?? defaultCallouts(markers) },
60
+ media: { start: t.mediaStart, end: t.mediaEnd, duration: markers.durationSeconds },
61
+ anchors: {
62
+ markers: markers.markers.map((m) => ({ label: m.label, at: m.at })),
63
+ clicks: (markers.clicks ?? []).map((c, i) => ({ n: i + 1, at: c.move ?? c.at })),
64
+ },
65
+ segments: t.segments,
66
+ sections,
67
+ callouts: t.callouts.map((c, i) => ({
68
+ n: i + 1,
69
+ source: c.source,
70
+ at: c.at,
71
+ end: round(c.at + c.duration),
72
+ text: c.text,
73
+ ...(c.group ? { group: c.group } : {}),
74
+ })),
75
+ zooms: zooms.map((z, i) => ({
76
+ n: i + 1,
77
+ at: z.at,
78
+ end: round(z.at + z.duration),
79
+ in: z.in,
80
+ out: z.out,
81
+ scale: z.scale,
82
+ x: z.x,
83
+ y: z.y,
84
+ problems: [...checkZoom(z, clicks, t).problems, ...overlaps.filter((o) => o.index === i).map((o) => o.message)],
85
+ })),
86
+ handOffs: t.transitions.map((tr) => ({
87
+ at: tr.at,
88
+ end: round(tr.at + tr.gap),
89
+ belt: t.belt,
90
+ title: tr.card.title,
91
+ ...(tr.card.subtitle ? { subtitle: tr.card.subtitle } : {}),
92
+ ...(tr.card.from ? { from: tr.card.from } : {}),
93
+ ...(tr.card.to ? { to: tr.card.to } : {}),
94
+ })),
95
+ clicks: clicks.map((c) => ({ n: c.index, kind: c.kind, glide: c.glide, at: c.comp, until: c.until })),
96
+ markers: markers.markers
97
+ .filter((m) => m.at >= t.mediaStart && m.at <= t.mediaEnd)
98
+ .map((m) => ({ label: m.label, at: t.toComposition(m.at), recordingAt: m.at, used: used.has(m.label) })),
99
+ audio: {
100
+ narration: audio.narration ? { start: t.clipStart, end: t.clipEnd } : null,
101
+ music: audio.music ? { start: 0, end: t.total } : null,
102
+ },
103
+ cursor: t.cursor
104
+ ? { state: 'layer', size: t.cursor.size, ripple: t.cursor.ripple, presses: t.cursor.presses.map(([at]) => at) }
105
+ : {
106
+ state: markers.cursor && !markers.cursor.drawn ? 'hidden' : 'filmed',
107
+ size: spec.cursor ? (spec.cursor.size ?? 44) : 44,
108
+ ripple: spec.cursor ? (spec.cursor.ripple ?? true) : true,
109
+ presses: [],
110
+ },
111
+ warnings,
112
+ };
113
+ }
114
+ /** A short hash of video.json as it is on disk ('' when there is none). */
115
+ export function specRevision(demoDir) {
116
+ const path = resolve(demoDir, 'video.json');
117
+ return existsSync(path) ? createHash('sha1').update(readFileSync(path)).digest('hex').slice(0, 12) : '';
118
+ }
119
+ /**
120
+ * The page's edit (PUT /api/video): refused (409) if video.json changed since the page read
121
+ * it (`base` is the revision it read), refused (422) if it fails the schema or the plan —
122
+ * nothing is written then — else written as the build would write it. The caller rebuilds.
123
+ */
124
+ export function applyEdit(demoDir, config, body) {
125
+ if (body.base !== specRevision(demoDir)) {
126
+ return { status: 409, body: { error: 'video.json changed on disk since the page loaded it — reloaded; redo the edit' } };
127
+ }
128
+ try {
129
+ const spec = validateVideoSpec(body.spec, 'the edit');
130
+ const { warnings } = planSpec(demoDir, spec, readMarkers(demoDir), config);
131
+ const written = serializeVideoSpec(spec, demoDir, config);
132
+ writeFileSync(resolve(demoDir, 'video.json'), written);
133
+ return { status: 200, body: { warnings }, written };
134
+ }
135
+ catch (e) {
136
+ return { status: 422, body: { error: e.message } };
137
+ }
138
+ }
139
+ function withoutSchema(spec) {
140
+ const { $schema: _schema, ...rest } = spec;
141
+ return rest;
142
+ }
143
+ /** Builds the demo, then serves the studio until the process exits. Resolves with its URL. */
144
+ export async function studio(demoDir, config, options = {}) {
145
+ const log = options.log ?? console.log;
146
+ const slug = basename(demoDir);
147
+ const videoDir = resolve(demoDir, 'video');
148
+ const dist = hyperframesDist();
149
+ let data = null;
150
+ let error = null;
151
+ let version = 0;
152
+ const clients = new Set();
153
+ const rebuild = () => {
154
+ try {
155
+ const result = build(demoDir, config, { log: () => { } });
156
+ const has = (file) => existsSync(resolve(videoDir, 'assets', file));
157
+ data = studioData(slug, result, { narration: has('narration.m4a'), music: has('music.m4a') });
158
+ error = null;
159
+ }
160
+ catch (e) {
161
+ // Keep serving the last good build; the page shows the error until the next save fixes it.
162
+ error = e.message;
163
+ }
164
+ version++;
165
+ for (const client of clients) {
166
+ client.write(`data: ${JSON.stringify({ version, error })}\n\n`);
167
+ }
168
+ };
169
+ rebuild();
170
+ if (!data) {
171
+ throw new ReelsonError(error ?? 'build failed');
172
+ }
173
+ const specPath = resolve(demoDir, 'video.json');
174
+ const revision = () => specRevision(demoDir);
175
+ // What the page can pick from: re-read per request, so a new section shows up without a restart.
176
+ const choices = () => {
177
+ const found = catalog(config);
178
+ return {
179
+ templates: found.templates.map((t) => t.name),
180
+ ...Object.fromEntries(SLOTS.map((slot) => [slot, found.sections[slot].map((s) => s.name)])),
181
+ };
182
+ };
183
+ /** The page's edit: checked like a build would, then written and built. */
184
+ let written = null;
185
+ const edit = (body) => {
186
+ const result = applyEdit(demoDir, config, body);
187
+ if (result.written === undefined) {
188
+ return result;
189
+ }
190
+ written = result.written;
191
+ rebuild();
192
+ // Written either way; a failed build shows on the page like any other.
193
+ return { status: 200, body: { ...result.body, error, revision: revision() } };
194
+ };
195
+ const server = createServer((req, res) => {
196
+ const url = new URL(req.url ?? '/', 'http://localhost');
197
+ const path = decodeURIComponent(url.pathname);
198
+ if (path === '/') {
199
+ return sendFile(res, resolve(STUDIO_DIR, 'index.html'));
200
+ }
201
+ if (path.startsWith('/studio/')) {
202
+ const file = resolve(STUDIO_DIR, `.${path.slice('/studio'.length)}`);
203
+ if (file.startsWith(STUDIO_DIR + sep)) {
204
+ return sendFile(res, file);
205
+ }
206
+ }
207
+ if (path === '/api/plan') {
208
+ return send(res, 200, 'application/json', JSON.stringify({ version, error, revision: revision(), choices: choices(), data }));
209
+ }
210
+ if (path === '/api/video' && req.method === 'PUT') {
211
+ readBody(req).then((raw) => {
212
+ let body;
213
+ try {
214
+ body = JSON.parse(raw);
215
+ }
216
+ catch {
217
+ return send(res, 400, 'application/json', JSON.stringify({ error: 'the edit is not JSON' }));
218
+ }
219
+ const result = edit(body);
220
+ send(res, result.status, 'application/json', JSON.stringify(result.body));
221
+ }, () => send(res, 400, 'application/json', JSON.stringify({ error: 'unreadable request' })));
222
+ return;
223
+ }
224
+ if (path === '/api/events') {
225
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
226
+ res.write(': connected\n\n');
227
+ clients.add(res);
228
+ req.on('close', () => clients.delete(res));
229
+ return;
230
+ }
231
+ if (path === '/hf/player.js') {
232
+ return sendFile(res, resolve(dist, 'hyperframes-player.global.js'));
233
+ }
234
+ if (path === '/hf/runtime.js') {
235
+ return sendFile(res, resolve(dist, 'hyperframe.runtime.iife.js'));
236
+ }
237
+ if (path === '/video/index.html') {
238
+ // The runtime ships with the page instead of the player fetching it from a CDN.
239
+ const html = readFileSync(resolve(videoDir, 'index.html'), 'utf8').replace('</head>', ' <script src="/hf/runtime.js"></script>\n </head>');
240
+ return send(res, 200, 'text/html', html);
241
+ }
242
+ if (path.startsWith('/video/')) {
243
+ const file = resolve(videoDir, `.${path.slice('/video'.length)}`);
244
+ if (file.startsWith(videoDir + sep)) {
245
+ return sendFile(res, file, req.headers.range);
246
+ }
247
+ }
248
+ send(res, 404, 'text/plain', 'not found');
249
+ });
250
+ // Rebuild on any input the build reads; editors write in bursts, so settle first.
251
+ let timer;
252
+ const schedule = () => {
253
+ clearTimeout(timer);
254
+ timer = setTimeout(() => {
255
+ rebuild();
256
+ log(error ? `rebuild failed: ${error}` : `rebuilt (${new Date().toLocaleTimeString()})`);
257
+ }, 250);
258
+ };
259
+ const inputs = new Set(['video.json', 'markers.json', 'recording.mp4']);
260
+ // The page's own writes are already built; only a change from elsewhere rebuilds.
261
+ const ownWrite = (file) => file === 'video.json' && written !== null && existsSync(specPath) && readFileSync(specPath, 'utf8') === written;
262
+ const watchers = [
263
+ watch(demoDir, (_event, file) => file && inputs.has(file.toString()) && !ownWrite(file.toString()) && schedule()),
264
+ ...[
265
+ BUILTIN_TEMPLATES,
266
+ BUILTIN_SECTIONS,
267
+ resolve(fromRoot(config, config.videosDir), '_templates'),
268
+ resolve(fromRoot(config, config.videosDir), '_sections'),
269
+ ]
270
+ .filter((dir) => existsSync(dir))
271
+ .map((dir) => watch(dir, { recursive: true }, schedule)),
272
+ ];
273
+ if (config.path) {
274
+ watchers.push(watch(config.path, schedule));
275
+ }
276
+ server.on('close', () => watchers.forEach((w) => w.close()));
277
+ const port = options.port ?? 4800;
278
+ await new Promise((done, fail) => {
279
+ server.once('error', (e) => fail(e.code === 'EADDRINUSE' ? new ReelsonError(`port ${port} is in use — pass --port`) : e));
280
+ server.listen(port, '127.0.0.1', done);
281
+ });
282
+ return `http://localhost:${port}/`;
283
+ }
284
+ function readBody(req) {
285
+ return new Promise((done, fail) => {
286
+ const chunks = [];
287
+ req.on('data', (chunk) => chunks.push(chunk));
288
+ req.on('end', () => done(Buffer.concat(chunks).toString('utf8')));
289
+ req.on('error', fail);
290
+ });
291
+ }
292
+ const TYPES = {
293
+ '.html': 'text/html',
294
+ '.js': 'text/javascript',
295
+ '.css': 'text/css',
296
+ '.json': 'application/json',
297
+ '.svg': 'image/svg+xml',
298
+ '.png': 'image/png',
299
+ '.webp': 'image/webp',
300
+ '.woff2': 'font/woff2',
301
+ '.mp4': 'video/mp4',
302
+ '.m4a': 'audio/mp4',
303
+ };
304
+ function send(res, status, type, body) {
305
+ res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
306
+ res.end(body);
307
+ }
308
+ /** A file, with byte ranges so the browser can seek video and audio. */
309
+ function sendFile(res, file, range) {
310
+ if (!existsSync(file) || !statSync(file).isFile()) {
311
+ return send(res, 404, 'text/plain', 'not found');
312
+ }
313
+ const type = TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream';
314
+ const body = readFileSync(file);
315
+ const match = range?.match(/^bytes=(\d*)-(\d*)$/);
316
+ if (!match) {
317
+ res.writeHead(200, { 'content-type': type, 'content-length': body.length, 'accept-ranges': 'bytes', 'cache-control': 'no-store' });
318
+ return void res.end(body);
319
+ }
320
+ const start = match[1] ? Number(match[1]) : Math.max(0, body.length - Number(match[2]));
321
+ const end = match[1] && match[2] ? Math.min(Number(match[2]), body.length - 1) : body.length - 1;
322
+ res.writeHead(206, {
323
+ 'content-type': type,
324
+ 'content-length': end - start + 1,
325
+ 'content-range': `bytes ${start}-${end}/${body.length}`,
326
+ 'accept-ranges': 'bytes',
327
+ 'cache-control': 'no-store',
328
+ });
329
+ res.end(body.subarray(start, end + 1));
330
+ }