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,394 @@
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, type FSWatcher } from 'node:fs'
17
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
18
+ import { basename, extname, resolve, sep } from 'node:path'
19
+ import { fromRoot, type LoadedConfig } from '../../reelson-record/scripts/config.ts'
20
+ import { build, planSpec, serializeVideoSpec, type Plan } from './build.ts'
21
+ import { hyperframesDist } from './hyperframes.ts'
22
+ import { BUILTIN_SECTIONS, BUILTIN_TEMPLATES, catalog, readMarkers, ReelsonError, validateVideoSpec } from './project.ts'
23
+ import { defaultCallouts, round, SLOTS, type VideoSpec } from './timeline.ts'
24
+ import { checkZoom, zoomOverlaps } from './zooms.ts'
25
+
26
+ const STUDIO_DIR = resolve(import.meta.dirname, '../studio')
27
+
28
+ /** What the studio page draws: the plan, flattened to plain JSON in composition seconds. */
29
+ export interface StudioData {
30
+ slug: string
31
+ title: string
32
+ total: number
33
+ frame: { width: number; height: number }
34
+ template: string
35
+ /** video.json as on disk (without `$schema`): what the page edits. */
36
+ spec: VideoSpec
37
+ /** The trim window and the recording's length, in recording seconds. */
38
+ media: { start: number; end: number; duration: number }
39
+ /** What a trim edge can be tied to, in recording seconds (click: when its glide starts). */
40
+ anchors: { markers: { label: string; at: number }[]; clicks: { n: number; at: number }[] }
41
+ /** Composition ↔ recording time: one entry per stretch of footage between hand-offs. */
42
+ segments: { start: number; duration: number; mediaStart: number }[]
43
+ sections: { slot: 'intro' | 'recording' | 'recap' | 'outro'; name: string; start: number; end: number; detail: string }[]
44
+ /** `source`: index in `spec.callouts` (or in the default callouts when it has none). */
45
+ callouts: { n: number; source: number; at: number; end: number; text: string; group?: string }[]
46
+ zooms: { n: number; at: number; end: number; in: number; out: number; scale: number; x: number; y: number; problems: string[] }[]
47
+ handOffs: { at: number; end: number; belt: number; title: string; subtitle?: string; from?: string; to?: string }[]
48
+ clicks: { n: number; kind: 'click' | 'type'; glide: number; at: number; until: number }[]
49
+ markers: { label: string; at: number; recordingAt: number; used: boolean }[]
50
+ audio: { narration: { start: number; end: number } | null; music: { start: number; end: number } | null }
51
+ /**
52
+ * 'layer': drawn by the video (size, ripple and `presses` apply); 'hidden': logged but
53
+ * turned off in video.json; 'filmed': part of the footage, nothing to set.
54
+ */
55
+ cursor: { state: 'layer' | 'hidden' | 'filmed'; size: number; ripple: boolean; presses: number[] }
56
+ warnings: string[]
57
+ }
58
+
59
+ export function studioData(slug: string, result: Plan, audio: { narration: boolean; music: boolean }): StudioData {
60
+ const { spec, markers, design, timeline: t, clicks, zooms, warnings } = result
61
+ const { intro, recap, outro } = design.sections
62
+ const sections: StudioData['sections'] = [
63
+ { slot: 'intro', name: intro.name, start: 0, end: t.intro.duration, detail: `hands over at ${t.intro.exit}s` },
64
+ {
65
+ slot: 'recording',
66
+ name: 'recording',
67
+ start: t.clipStart,
68
+ end: t.clipEnd,
69
+ detail: `media ${t.mediaStart}–${t.mediaEnd}s of ${markers.durationSeconds}s`,
70
+ },
71
+ ]
72
+ if (recap && t.recap) {
73
+ sections.push({
74
+ slot: 'recap',
75
+ name: recap.name,
76
+ start: t.recap.start,
77
+ end: round(t.recap.start + t.recap.duration),
78
+ detail: `holds ${t.recap.maxSteps} steps`,
79
+ })
80
+ }
81
+ sections.push({ slot: 'outro', name: outro.name, start: t.outro.start, end: t.total, detail: '' })
82
+
83
+ // Without callouts in video.json the build makes one per marker, so all of them are used.
84
+ const overlaps = zoomOverlaps(zooms)
85
+ const used = new Set((spec.callouts ?? defaultCallouts(markers)).map((c) => c.marker).filter(Boolean))
86
+
87
+ return {
88
+ slug,
89
+ title: spec.title,
90
+ total: t.total,
91
+ frame: t.frame,
92
+ template: design.template.name,
93
+ // The page edits callouts by index, so the default ones are spelled out.
94
+ spec: { ...withoutSchema(spec), callouts: spec.callouts ?? defaultCallouts(markers) },
95
+ media: { start: t.mediaStart, end: t.mediaEnd, duration: markers.durationSeconds },
96
+ anchors: {
97
+ markers: markers.markers.map((m) => ({ label: m.label, at: m.at })),
98
+ clicks: (markers.clicks ?? []).map((c, i) => ({ n: i + 1, at: c.move ?? c.at })),
99
+ },
100
+ segments: t.segments,
101
+ sections,
102
+ callouts: t.callouts.map((c, i) => ({
103
+ n: i + 1,
104
+ source: c.source,
105
+ at: c.at,
106
+ end: round(c.at + c.duration),
107
+ text: c.text,
108
+ ...(c.group ? { group: c.group } : {}),
109
+ })),
110
+ zooms: zooms.map((z, i) => ({
111
+ n: i + 1,
112
+ at: z.at,
113
+ end: round(z.at + z.duration),
114
+ in: z.in,
115
+ out: z.out,
116
+ scale: z.scale,
117
+ x: z.x,
118
+ y: z.y,
119
+ problems: [...checkZoom(z, clicks, t).problems, ...overlaps.filter((o) => o.index === i).map((o) => o.message)],
120
+ })),
121
+ handOffs: t.transitions.map((tr) => ({
122
+ at: tr.at,
123
+ end: round(tr.at + tr.gap),
124
+ belt: t.belt,
125
+ title: tr.card.title,
126
+ ...(tr.card.subtitle ? { subtitle: tr.card.subtitle } : {}),
127
+ ...(tr.card.from ? { from: tr.card.from } : {}),
128
+ ...(tr.card.to ? { to: tr.card.to } : {}),
129
+ })),
130
+ clicks: clicks.map((c) => ({ n: c.index, kind: c.kind, glide: c.glide, at: c.comp, until: c.until })),
131
+ markers: markers.markers
132
+ .filter((m) => m.at >= t.mediaStart && m.at <= t.mediaEnd)
133
+ .map((m) => ({ label: m.label, at: t.toComposition(m.at), recordingAt: m.at, used: used.has(m.label) })),
134
+ audio: {
135
+ narration: audio.narration ? { start: t.clipStart, end: t.clipEnd } : null,
136
+ music: audio.music ? { start: 0, end: t.total } : null,
137
+ },
138
+ cursor: t.cursor
139
+ ? { state: 'layer', size: t.cursor.size, ripple: t.cursor.ripple, presses: t.cursor.presses.map(([at]) => at) }
140
+ : {
141
+ state: markers.cursor && !markers.cursor.drawn ? 'hidden' : 'filmed',
142
+ size: spec.cursor ? (spec.cursor.size ?? 44) : 44,
143
+ ripple: spec.cursor ? (spec.cursor.ripple ?? true) : true,
144
+ presses: [],
145
+ },
146
+ warnings,
147
+ }
148
+ }
149
+
150
+ /** A short hash of video.json as it is on disk ('' when there is none). */
151
+ export function specRevision(demoDir: string): string {
152
+ const path = resolve(demoDir, 'video.json')
153
+ return existsSync(path) ? createHash('sha1').update(readFileSync(path)).digest('hex').slice(0, 12) : ''
154
+ }
155
+
156
+ /**
157
+ * The page's edit (PUT /api/video): refused (409) if video.json changed since the page read
158
+ * it (`base` is the revision it read), refused (422) if it fails the schema or the plan —
159
+ * nothing is written then — else written as the build would write it. The caller rebuilds.
160
+ */
161
+ export function applyEdit(
162
+ demoDir: string,
163
+ config: LoadedConfig,
164
+ body: { base?: string; spec?: unknown },
165
+ ): { status: number; body: { error?: string; warnings?: string[] }; written?: string } {
166
+ if (body.base !== specRevision(demoDir)) {
167
+ return { status: 409, body: { error: 'video.json changed on disk since the page loaded it — reloaded; redo the edit' } }
168
+ }
169
+ try {
170
+ const spec = validateVideoSpec(body.spec, 'the edit')
171
+ const { warnings } = planSpec(demoDir, spec, readMarkers(demoDir), config)
172
+ const written = serializeVideoSpec(spec, demoDir, config)
173
+ writeFileSync(resolve(demoDir, 'video.json'), written)
174
+ return { status: 200, body: { warnings }, written }
175
+ } catch (e) {
176
+ return { status: 422, body: { error: (e as Error).message } }
177
+ }
178
+ }
179
+
180
+ function withoutSchema(spec: VideoSpec): VideoSpec {
181
+ const { $schema: _schema, ...rest } = spec as VideoSpec & { $schema?: string }
182
+ return rest
183
+ }
184
+
185
+ export interface StudioOptions {
186
+ port?: number
187
+ log?: (line: string) => void
188
+ }
189
+
190
+ /** Builds the demo, then serves the studio until the process exits. Resolves with its URL. */
191
+ export async function studio(demoDir: string, config: LoadedConfig, options: StudioOptions = {}): Promise<string> {
192
+ const log = options.log ?? console.log
193
+ const slug = basename(demoDir)
194
+ const videoDir = resolve(demoDir, 'video')
195
+ const dist = hyperframesDist()
196
+
197
+ let data: StudioData | null = null
198
+ let error: string | null = null
199
+ let version = 0
200
+ const clients = new Set<ServerResponse>()
201
+ const rebuild = (): void => {
202
+ try {
203
+ const result = build(demoDir, config, { log: () => {} })
204
+ const has = (file: string) => existsSync(resolve(videoDir, 'assets', file))
205
+ data = studioData(slug, result, { narration: has('narration.m4a'), music: has('music.m4a') })
206
+ error = null
207
+ } catch (e) {
208
+ // Keep serving the last good build; the page shows the error until the next save fixes it.
209
+ error = (e as Error).message
210
+ }
211
+ version++
212
+ for (const client of clients) {
213
+ client.write(`data: ${JSON.stringify({ version, error })}\n\n`)
214
+ }
215
+ }
216
+ rebuild()
217
+ if (!data) {
218
+ throw new ReelsonError(error ?? 'build failed')
219
+ }
220
+
221
+ const specPath = resolve(demoDir, 'video.json')
222
+ const revision = (): string => specRevision(demoDir)
223
+ // What the page can pick from: re-read per request, so a new section shows up without a restart.
224
+ const choices = () => {
225
+ const found = catalog(config)
226
+ return {
227
+ templates: found.templates.map((t) => t.name),
228
+ ...Object.fromEntries(SLOTS.map((slot) => [slot, found.sections[slot].map((s) => s.name)])),
229
+ }
230
+ }
231
+ /** The page's edit: checked like a build would, then written and built. */
232
+ let written: string | null = null
233
+ const edit = (body: { base?: string; spec?: unknown }): { status: number; body: object } => {
234
+ const result = applyEdit(demoDir, config, body)
235
+ if (result.written === undefined) {
236
+ return result
237
+ }
238
+ written = result.written
239
+ rebuild()
240
+ // Written either way; a failed build shows on the page like any other.
241
+ return { status: 200, body: { ...result.body, error, revision: revision() } }
242
+ }
243
+
244
+ const server = createServer((req, res) => {
245
+ const url = new URL(req.url ?? '/', 'http://localhost')
246
+ const path = decodeURIComponent(url.pathname)
247
+ if (path === '/') {
248
+ return sendFile(res, resolve(STUDIO_DIR, 'index.html'))
249
+ }
250
+ if (path.startsWith('/studio/')) {
251
+ const file = resolve(STUDIO_DIR, `.${path.slice('/studio'.length)}`)
252
+ if (file.startsWith(STUDIO_DIR + sep)) {
253
+ return sendFile(res, file)
254
+ }
255
+ }
256
+ if (path === '/api/plan') {
257
+ return send(res, 200, 'application/json', JSON.stringify({ version, error, revision: revision(), choices: choices(), data }))
258
+ }
259
+ if (path === '/api/video' && req.method === 'PUT') {
260
+ readBody(req).then(
261
+ (raw) => {
262
+ let body
263
+ try {
264
+ body = JSON.parse(raw)
265
+ } catch {
266
+ return send(res, 400, 'application/json', JSON.stringify({ error: 'the edit is not JSON' }))
267
+ }
268
+ const result = edit(body)
269
+ send(res, result.status, 'application/json', JSON.stringify(result.body))
270
+ },
271
+ () => send(res, 400, 'application/json', JSON.stringify({ error: 'unreadable request' })),
272
+ )
273
+ return
274
+ }
275
+ if (path === '/api/events') {
276
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
277
+ res.write(': connected\n\n')
278
+ clients.add(res)
279
+ req.on('close', () => clients.delete(res))
280
+ return
281
+ }
282
+ if (path === '/hf/player.js') {
283
+ return sendFile(res, resolve(dist, 'hyperframes-player.global.js'))
284
+ }
285
+ if (path === '/hf/runtime.js') {
286
+ return sendFile(res, resolve(dist, 'hyperframe.runtime.iife.js'))
287
+ }
288
+ if (path === '/video/index.html') {
289
+ // The runtime ships with the page instead of the player fetching it from a CDN.
290
+ const html = readFileSync(resolve(videoDir, 'index.html'), 'utf8').replace(
291
+ '</head>',
292
+ ' <script src="/hf/runtime.js"></script>\n </head>',
293
+ )
294
+ return send(res, 200, 'text/html', html)
295
+ }
296
+ if (path.startsWith('/video/')) {
297
+ const file = resolve(videoDir, `.${path.slice('/video'.length)}`)
298
+ if (file.startsWith(videoDir + sep)) {
299
+ return sendFile(res, file, req.headers.range)
300
+ }
301
+ }
302
+ send(res, 404, 'text/plain', 'not found')
303
+ })
304
+
305
+ // Rebuild on any input the build reads; editors write in bursts, so settle first.
306
+ let timer: NodeJS.Timeout | undefined
307
+ const schedule = (): void => {
308
+ clearTimeout(timer)
309
+ timer = setTimeout(() => {
310
+ rebuild()
311
+ log(error ? `rebuild failed: ${error}` : `rebuilt (${new Date().toLocaleTimeString()})`)
312
+ }, 250)
313
+ }
314
+ const inputs = new Set(['video.json', 'markers.json', 'recording.mp4'])
315
+ // The page's own writes are already built; only a change from elsewhere rebuilds.
316
+ const ownWrite = (file: string): boolean =>
317
+ file === 'video.json' && written !== null && existsSync(specPath) && readFileSync(specPath, 'utf8') === written
318
+ const watchers: FSWatcher[] = [
319
+ watch(demoDir, (_event, file) => file && inputs.has(file.toString()) && !ownWrite(file.toString()) && schedule()),
320
+ ...[
321
+ BUILTIN_TEMPLATES,
322
+ BUILTIN_SECTIONS,
323
+ resolve(fromRoot(config, config.videosDir), '_templates'),
324
+ resolve(fromRoot(config, config.videosDir), '_sections'),
325
+ ]
326
+ .filter((dir) => existsSync(dir))
327
+ .map((dir) => watch(dir, { recursive: true }, schedule)),
328
+ ]
329
+ if (config.path) {
330
+ watchers.push(watch(config.path, schedule))
331
+ }
332
+ server.on('close', () => watchers.forEach((w) => w.close()))
333
+
334
+ const port = options.port ?? 4800
335
+ await new Promise<void>((done, fail) => {
336
+ server.once('error', (e: NodeJS.ErrnoException) =>
337
+ fail(e.code === 'EADDRINUSE' ? new ReelsonError(`port ${port} is in use — pass --port`) : e),
338
+ )
339
+ server.listen(port, '127.0.0.1', done)
340
+ })
341
+
342
+ return `http://localhost:${port}/`
343
+ }
344
+
345
+ function readBody(req: IncomingMessage): Promise<string> {
346
+ return new Promise((done, fail) => {
347
+ const chunks: Buffer[] = []
348
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
349
+ req.on('end', () => done(Buffer.concat(chunks).toString('utf8')))
350
+ req.on('error', fail)
351
+ })
352
+ }
353
+
354
+ const TYPES: Record<string, string> = {
355
+ '.html': 'text/html',
356
+ '.js': 'text/javascript',
357
+ '.css': 'text/css',
358
+ '.json': 'application/json',
359
+ '.svg': 'image/svg+xml',
360
+ '.png': 'image/png',
361
+ '.webp': 'image/webp',
362
+ '.woff2': 'font/woff2',
363
+ '.mp4': 'video/mp4',
364
+ '.m4a': 'audio/mp4',
365
+ }
366
+
367
+ function send(res: ServerResponse, status: number, type: string, body: string | Buffer): void {
368
+ res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' })
369
+ res.end(body)
370
+ }
371
+
372
+ /** A file, with byte ranges so the browser can seek video and audio. */
373
+ function sendFile(res: ServerResponse, file: string, range?: string): void {
374
+ if (!existsSync(file) || !statSync(file).isFile()) {
375
+ return send(res, 404, 'text/plain', 'not found')
376
+ }
377
+ const type = TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream'
378
+ const body = readFileSync(file)
379
+ const match = range?.match(/^bytes=(\d*)-(\d*)$/)
380
+ if (!match) {
381
+ res.writeHead(200, { 'content-type': type, 'content-length': body.length, 'accept-ranges': 'bytes', 'cache-control': 'no-store' })
382
+ return void res.end(body)
383
+ }
384
+ const start = match[1] ? Number(match[1]) : Math.max(0, body.length - Number(match[2]))
385
+ const end = match[1] && match[2] ? Math.min(Number(match[2]), body.length - 1) : body.length - 1
386
+ res.writeHead(206, {
387
+ 'content-type': type,
388
+ 'content-length': end - start + 1,
389
+ 'content-range': `bytes ${start}-${end}/${body.length}`,
390
+ 'accept-ranges': 'bytes',
391
+ 'cache-control': 'no-store',
392
+ })
393
+ res.end(body.subarray(start, end + 1))
394
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The composition timeline, computed from a recording's markers.json and the
3
+ * video's video.json. Pure: no files, no ffmpeg — so it is unit-tested.
4
+ *
5
+ * intro (0 → exit) → recording (callouts, zooms, hand-off cards)
6
+ * → recap (optional) → outro
7
+ *
8
+ * video.json times are recording times; everything this module returns is in
9
+ * composition seconds.
10
+ */
11
+ /**
12
+ * Timeline constants (seconds). The stage keys come from the template's
13
+ * template.json, the rest from the section chosen for each slot (section.json).
14
+ */
15
+ export const STAGE_TIMING = {
16
+ overlap: 0.4, // recording → recap → outro cross-fades
17
+ calloutDuration: 3.0,
18
+ /** A step's callout stays this long after its marker (the result), next step permitting. */
19
+ calloutHold: 1.2,
20
+ /** The least a step's callout is up before the next step's may replace it. */
21
+ calloutMinimum: 1.8,
22
+ transitionGap: 2.6, // seconds a hand-off card holds between the two belts
23
+ belt: 0.9, // the recording's exit before a hand-off card (matches the stage)
24
+ maxW: 1600, // the framed recording's box inside the 1920x1080 stage
25
+ maxH: 940,
26
+ };
27
+ /** A spoken callout stays up this long after its line ends (a breath before the next step). */
28
+ const SPOKEN_TAIL = 0.3;
29
+ export const SECTION_TIMING = {
30
+ intro: {
31
+ duration: 4.0, // intro clip length
32
+ exit: 3.0, // the intro hands over and the recording starts (= clipStart)
33
+ },
34
+ recap: {
35
+ base: 2.4, // + perStep per step, capped at max
36
+ perStep: 0.45,
37
+ max: 7.5,
38
+ maxSteps: 10, // the recap's capacity
39
+ },
40
+ outro: {
41
+ duration: 2.6,
42
+ },
43
+ };
44
+ export const SLOTS = Object.keys(SECTION_TIMING);
45
+ export const TIMING_DEFAULTS = { stage: STAGE_TIMING, ...SECTION_TIMING };
46
+ export class TimelineError extends Error {
47
+ }
48
+ export function computeTimeline(markers, spec, timing = TIMING_DEFAULTS,
49
+ /**
50
+ * Voice-over: seconds from a callout appearing until its spoken line is done (0: silent or
51
+ * not spoken yet). Such a callout stays up at least that long, and the next one waits.
52
+ */
53
+ spoken = () => 0) {
54
+ const warnings = [];
55
+ const mediaStart = Math.max(0, resolveTrimPoint(spec.trim?.start, markers, 'start') ?? 0);
56
+ const mediaEnd = Math.min(resolveTrimPoint(spec.trim?.end, markers, 'end') ?? markers.durationSeconds, markers.durationSeconds);
57
+ const mediaDuration = round(mediaEnd - mediaStart);
58
+ if (mediaDuration <= 0) {
59
+ throw new TimelineError(`trim window is empty (start ${mediaStart}s, end ${mediaEnd}s)`);
60
+ }
61
+ const stage = timing.stage;
62
+ const clipStart = timing.intro.exit;
63
+ const handOffs = (markers.transitions ?? []).filter((t) => t.at > mediaStart && t.at < mediaEnd);
64
+ const gap = stage.transitionGap;
65
+ const clipDuration = round(mediaDuration + gap * handOffs.length);
66
+ const clipEnd = round(clipStart + clipDuration);
67
+ const toComposition = (t) => round(clipStart + (t - mediaStart) + gap * handOffs.filter((h) => h.at <= t).length);
68
+ const transitions = handOffs.map((card, i) => ({
69
+ at: round(clipStart + (card.at - mediaStart) + gap * i),
70
+ gap,
71
+ card,
72
+ }));
73
+ const bounds = [mediaStart, ...handOffs.map((t) => t.at), mediaEnd];
74
+ const segments = bounds.slice(0, -1).map((from, i) => ({
75
+ start: round(clipStart + (from - mediaStart) + gap * i),
76
+ duration: round(bounds[i + 1] - from),
77
+ mediaStart: round(from),
78
+ }));
79
+ // Callouts: resolve each to a recording time, keep the ones inside the window, in order.
80
+ const specs = spec.callouts ?? defaultCallouts(markers);
81
+ const unknown = specs.filter((c) => c.marker !== undefined && !markers.markers.some((m) => m.label === c.marker));
82
+ if (unknown.length) {
83
+ throw new TimelineError(`callout(s) reference unknown markers: ${unknown.map((c) => JSON.stringify(c.marker)).join(', ')} ` +
84
+ `— markers.json has ${markers.markers.map((m) => JSON.stringify(m.label)).join(', ') || 'none'}`);
85
+ }
86
+ const timed = specs
87
+ .map((c, source) => {
88
+ if ((c.marker === undefined) === (c.at === undefined)) {
89
+ throw new TimelineError(`callout "${c.text}" needs exactly one of \`marker\` or \`at\``);
90
+ }
91
+ if (c.offset !== undefined && c.marker === undefined) {
92
+ throw new TimelineError(`callout "${c.text}": \`offset\` shifts a \`marker\`; with \`at\`, change \`at\` instead`);
93
+ }
94
+ if (c.anchor !== undefined && c.marker === undefined) {
95
+ throw new TimelineError(`callout "${c.text}": \`anchor\` needs a \`marker\``);
96
+ }
97
+ if (c.at !== undefined) {
98
+ return { ...c, recordingAt: c.at, shownUntil: c.at, source };
99
+ }
100
+ const marker = markers.markers.find((m) => m.label === c.marker).at;
101
+ const start = c.anchor === 'marker' ? marker : stepStart(markers, marker, mediaStart);
102
+ return { ...c, recordingAt: round(start + (c.offset ?? 0)), shownUntil: marker, source };
103
+ })
104
+ .filter((c) => {
105
+ const inside = c.recordingAt >= mediaStart && c.recordingAt <= mediaEnd;
106
+ if (!inside) {
107
+ warnings.push(`callout "${c.text}" at ${c.recordingAt}s is outside the trim window — dropped`);
108
+ }
109
+ return inside;
110
+ })
111
+ .sort((a, b) => a.recordingAt - b.recordingAt);
112
+ const transitionTimes = transitions.map((t) => t.at);
113
+ // When each shows. A step that starts as the footage does waits for the recording to
114
+ // arrive; one that starts right after the previous (a step that was only a page load) waits
115
+ // until that one has been up long enough to read. An explicit `at` is kept as given.
116
+ // With a voice-over, that is until its line has been said (and never past the recording).
117
+ const speaking = timed.map((c) => spoken(c));
118
+ const minimum = (i) => Math.max(stage.calloutMinimum, speaking[i] ? speaking[i] + SPOKEN_TAIL : 0);
119
+ const starts = [];
120
+ timed.forEach((c, i) => {
121
+ const at = toComposition(c.recordingAt);
122
+ const after = i ? starts[i - 1] + minimum(i - 1) : 0;
123
+ starts.push(c.at === undefined
124
+ ? round(Math.max(at, clipStart + stage.belt, Math.min(after, Math.max(at, clipEnd - stage.calloutMinimum))))
125
+ : at);
126
+ });
127
+ const callouts = timed.map((c, i) => {
128
+ const at = starts[i];
129
+ const next = starts[i + 1];
130
+ const nextTransition = transitionTimes.find((t) => t > at);
131
+ // Never overlap the next callout or a hand-off card; never outlive the recording.
132
+ const cap = Math.min(next !== undefined ? next - 0.2 : Infinity, nextTransition !== undefined ? nextTransition - stage.belt - 0.1 : Infinity, clipEnd - 0.3);
133
+ // Through its step, and a moment on its result (the marker), at least calloutDuration.
134
+ const wanted = Math.max(stage.calloutDuration, toComposition(c.shownUntil) - at + stage.calloutHold, speaking[i]);
135
+ const duration = round(c.duration ?? Math.max(1, Math.min(wanted, cap - at)));
136
+ if (c.duration !== undefined && at + c.duration > cap + 0.01) {
137
+ warnings.push(`callout "${c.text}" (${c.duration}s) overlaps the next step or the end of the recording`);
138
+ }
139
+ if (speaking[i] > duration + 0.25) {
140
+ warnings.push(`voice-over: "${c.say || c.text}" is still being said ${round(speaking[i] - duration)}s after its callout goes — ` +
141
+ 'shorten its `say`, or pause longer in the scenario');
142
+ }
143
+ // Two-actor videos: tag each step with who does it (the hand-off card's roles).
144
+ const before = handOffs.filter((t) => t.at <= c.recordingAt);
145
+ const group = c.group ?? (before.length ? before.at(-1)?.to : handOffs[0]?.from);
146
+ return { at, duration, text: c.text, ...(group ? { group } : {}), ...(c.say !== undefined ? { say: c.say } : {}), source: c.source };
147
+ });
148
+ const recapTiming = timing.recap;
149
+ if (recapTiming && callouts.length > recapTiming.maxSteps) {
150
+ warnings.push(`${callouts.length} callouts — the recap holds ${recapTiming.maxSteps}; merge or drop steps (extra ones are left out of the recap)`);
151
+ }
152
+ const scale = Math.min(stage.maxW / markers.viewport.width, stage.maxH / markers.viewport.height);
153
+ // recording → recap → outro, each cross-fading into the next by `overlap`. Without a
154
+ // recap the outro waits for the recording to fade out: its text never lands on the footage.
155
+ const recapStart = round(clipEnd - stage.overlap);
156
+ const recap = recapTiming
157
+ ? {
158
+ start: recapStart,
159
+ duration: round(Math.min(recapTiming.max, recapTiming.base + recapTiming.perStep * Math.min(callouts.length, recapTiming.maxSteps))),
160
+ maxSteps: recapTiming.maxSteps,
161
+ }
162
+ : null;
163
+ const outroStart = recap ? round(recap.start + recap.duration - stage.overlap) : clipEnd;
164
+ const total = round(outroStart + timing.outro.duration);
165
+ const frame = {
166
+ width: Math.round(markers.viewport.width * scale),
167
+ height: Math.round(markers.viewport.height * scale),
168
+ };
169
+ const log = markers.cursor;
170
+ if (spec.cursor !== undefined && (!log || log.drawn)) {
171
+ warnings.push('video.json `cursor` has no effect: this recording has the cursor filmed in — re-record (record.cursor "layer") to draw it as a layer');
172
+ }
173
+ let cursor = null;
174
+ if (log && !log.drawn && spec.cursor !== false && log.path.length) {
175
+ const inside = (t) => t >= mediaStart && t <= mediaEnd;
176
+ // Where the cursor rests when the footage starts: the last move before the trim.
177
+ const before = log.path.filter(([t]) => t < mediaStart).at(-1);
178
+ const path = [
179
+ ...(before ? [[clipStart, before[1], before[2]]] : []),
180
+ ...log.path.filter(([t]) => inside(t)).map(([t, x, y]) => [toComposition(t), x, y]),
181
+ ];
182
+ cursor = {
183
+ size: spec.cursor?.size ?? 44,
184
+ ripple: spec.cursor?.ripple ?? true,
185
+ idle: spec.cursor?.idle ?? 0,
186
+ scale: Math.round((frame.width / markers.viewport.width) * 10000) / 10000,
187
+ path,
188
+ presses: log.presses.filter(([t]) => inside(t)).map(([t, x, y]) => [toComposition(t), x, y]),
189
+ };
190
+ }
191
+ return {
192
+ timeline: {
193
+ total,
194
+ intro: { start: 0, duration: timing.intro.duration, exit: timing.intro.exit },
195
+ recap,
196
+ outro: { start: outroStart, duration: timing.outro.duration },
197
+ clipStart,
198
+ clipDuration,
199
+ clipEnd,
200
+ mediaStart: round(mediaStart),
201
+ mediaEnd: round(mediaEnd),
202
+ belt: stage.belt,
203
+ frame,
204
+ viewport: markers.viewport,
205
+ transitions,
206
+ segments,
207
+ callouts,
208
+ cursor,
209
+ focus: (markers.focus ?? [])
210
+ .filter((f) => f.at >= mediaStart && f.at <= mediaEnd)
211
+ .map((f) => ({ ...f, at: toComposition(f.at) })),
212
+ toComposition,
213
+ },
214
+ warnings,
215
+ };
216
+ }
217
+ /** A trim edge in recording seconds (undefined: not set). */
218
+ /**
219
+ * When the step that ends at `marker` began: the first glide (or click) after the previous
220
+ * marker — and after `from`, the trim — else the marker itself (nothing logged in between,
221
+ * e.g. a page load).
222
+ */
223
+ export function stepStart(markers, marker, from) {
224
+ const previous = Math.max(from, ...markers.markers.map((m) => m.at).filter((at) => at < marker - 1e-6));
225
+ const first = (markers.clicks ?? [])
226
+ .map((c) => c.move ?? c.at)
227
+ .filter((t) => t >= previous - 1e-6 && t < marker)
228
+ .sort((a, b) => a - b)[0];
229
+ return first ?? marker;
230
+ }
231
+ export function resolveTrimPoint(point, markers, edge) {
232
+ if (point === undefined || typeof point === 'number') {
233
+ return point;
234
+ }
235
+ if (point === 'auto') {
236
+ if (edge === 'end') {
237
+ throw new TimelineError('trim.end cannot be "auto" — leave it out to keep the recording to its end');
238
+ }
239
+ return suggestTrimStart(markers);
240
+ }
241
+ if ('marker' in point) {
242
+ const found = markers.markers.find((m) => m.label === point.marker);
243
+ if (!found) {
244
+ throw new TimelineError(`trim.${edge}: no marker "${point.marker}" — markers.json has ${markers.markers.map((m) => JSON.stringify(m.label)).join(', ') || 'none'}`);
245
+ }
246
+ return round(found.at + (point.offset ?? 0));
247
+ }
248
+ const click = markers.clicks?.[point.click - 1];
249
+ if (!click) {
250
+ throw new TimelineError(`trim.${edge}: no click ${point.click} — markers.json has ${markers.clicks?.length ?? 0}`);
251
+ }
252
+ return round((click.move ?? click.at) + (point.offset ?? 0));
253
+ }
254
+ /** Where the footage should start: just before the first logged glide (after the login). */
255
+ export function suggestTrimStart(markers) {
256
+ const candidates = [
257
+ ...(markers.clicks ?? []).map((c) => (c.move ?? c.at) - 0.5),
258
+ ...markers.markers.map((m) => m.at - 0.8),
259
+ ];
260
+ if (!candidates.length) {
261
+ return 0;
262
+ }
263
+ return Math.max(0, round(Math.min(...candidates)));
264
+ }
265
+ /** One callout per marker, worded as the marker label: the starting point of a new video.json. */
266
+ export function defaultCallouts(markers) {
267
+ return markers.markers.map((m) => ({ marker: m.label, text: m.label }));
268
+ }
269
+ export function round(n) {
270
+ return Math.round(n * 100) / 100;
271
+ }