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,514 @@
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
+ /**
13
+ * Timeline constants (seconds). The stage keys come from the template's
14
+ * template.json, the rest from the section chosen for each slot (section.json).
15
+ */
16
+ export const STAGE_TIMING = {
17
+ overlap: 0.4, // recording → recap → outro cross-fades
18
+ calloutDuration: 3.0,
19
+ /** A step's callout stays this long after its marker (the result), next step permitting. */
20
+ calloutHold: 1.2,
21
+ /** The least a step's callout is up before the next step's may replace it. */
22
+ calloutMinimum: 1.8,
23
+ transitionGap: 2.6, // seconds a hand-off card holds between the two belts
24
+ belt: 0.9, // the recording's exit before a hand-off card (matches the stage)
25
+ maxW: 1600, // the framed recording's box inside the 1920x1080 stage
26
+ maxH: 940,
27
+ }
28
+ /** A spoken callout stays up this long after its line ends (a breath before the next step). */
29
+ const SPOKEN_TAIL = 0.3
30
+
31
+ export const SECTION_TIMING = {
32
+ intro: {
33
+ duration: 4.0, // intro clip length
34
+ exit: 3.0, // the intro hands over and the recording starts (= clipStart)
35
+ },
36
+ recap: {
37
+ base: 2.4, // + perStep per step, capped at max
38
+ perStep: 0.45,
39
+ max: 7.5,
40
+ maxSteps: 10, // the recap's capacity
41
+ },
42
+ outro: {
43
+ duration: 2.6,
44
+ },
45
+ }
46
+ export type StageTiming = typeof STAGE_TIMING
47
+ export type IntroTiming = typeof SECTION_TIMING.intro
48
+ export type RecapTiming = typeof SECTION_TIMING.recap
49
+ export type OutroTiming = typeof SECTION_TIMING.outro
50
+ export type Slot = keyof typeof SECTION_TIMING
51
+ export const SLOTS = Object.keys(SECTION_TIMING) as Slot[]
52
+
53
+ export interface Timing {
54
+ stage: StageTiming
55
+ intro: IntroTiming
56
+ /** null: no recap section ("recap": "none"). */
57
+ recap: RecapTiming | null
58
+ outro: OutroTiming
59
+ }
60
+ export const TIMING_DEFAULTS: Timing = { stage: STAGE_TIMING, ...SECTION_TIMING }
61
+
62
+ export interface Click {
63
+ at: number
64
+ move?: number
65
+ until?: number
66
+ x: number
67
+ y: number
68
+ kind: 'click' | 'type'
69
+ }
70
+
71
+ export interface HandOff {
72
+ at: number
73
+ title: string
74
+ subtitle?: string
75
+ from?: string
76
+ to?: string
77
+ }
78
+
79
+ /** What reelson-record writes (or what build.ts probes from a manual recording). */
80
+ export interface Markers {
81
+ scenario?: string
82
+ viewport: { width: number; height: number }
83
+ durationSeconds: number
84
+ markers: { label: string; at: number }[]
85
+ clicks?: Click[]
86
+ transitions?: HandOff[]
87
+ cuts?: { from: number; to: number }[]
88
+ cursor?: CursorLog
89
+ /** What the demo worked on (reelson-record's Focus): recording s, viewport CSS px. */
90
+ focus?: { at: number; box: Box; area: Box }[]
91
+ }
92
+
93
+ export interface Box {
94
+ x: number
95
+ y: number
96
+ width: number
97
+ height: number
98
+ }
99
+
100
+ /** The cursor as reelson-record logged it: [t, x, y] in recording seconds and viewport CSS px. */
101
+ export interface CursorLog {
102
+ /** Already filmed into recording.mp4 (record.cursor "recorded"): the video must not draw it. */
103
+ drawn: boolean
104
+ path: [number, number, number][]
105
+ presses: [number, number, number][]
106
+ }
107
+
108
+ /** video.json `cursor`: how the video draws a logged cursor. */
109
+ export interface CursorSpec {
110
+ /** Arrow height in recording CSS px (44 = the classic macOS-style size). */
111
+ size?: number
112
+ /** The ring on each press. */
113
+ ripple?: boolean
114
+ /** Fade the cursor out after this many seconds without moving or clicking (0 = never). */
115
+ idle?: number
116
+ }
117
+
118
+ export interface CalloutSpec {
119
+ text: string
120
+ marker?: string
121
+ /**
122
+ * Where a marker callout starts: "step" (default) — as its step begins, i.e. the first
123
+ * glide or click after the previous marker (the marker comes after the action it names);
124
+ * "marker" — on the marker itself.
125
+ */
126
+ anchor?: 'step' | 'marker'
127
+ /** Seconds after (or, negative, before) that start. */
128
+ offset?: number
129
+ at?: number
130
+ duration?: number
131
+ group?: string
132
+ /** Voice-over: what is spoken for this step (default: its text); false: nothing. */
133
+ say?: string | false
134
+ }
135
+
136
+ export interface ZoomSpec {
137
+ scale: number
138
+ /** Pan with the cursor while zoomed (needs a logged cursor: record.cursor "layer"). */
139
+ follow?: boolean
140
+ clicks?: number[]
141
+ x?: number
142
+ y?: number
143
+ at?: number
144
+ duration?: number
145
+ in?: number
146
+ out?: number
147
+ }
148
+
149
+ /**
150
+ * A trim edge in video.json: a recording time, "auto" (start only: just before the first
151
+ * glide or marker), or a time relative to a marker / to the glide towards a click — so a
152
+ * re-record moves the trim with the footage.
153
+ */
154
+ export type TrimPoint = number | 'auto' | { marker: string; offset?: number } | { click: number; offset?: number }
155
+
156
+ /** Section name per slot; "none" is allowed for the recap only. */
157
+ export type SectionChoice = Partial<Record<Slot, string>>
158
+
159
+ /** video.json */
160
+ export interface VideoSpec {
161
+ title: string
162
+ subtitle?: string
163
+ template?: string
164
+ sections?: SectionChoice
165
+ recapTitle?: string
166
+ brand?: { name?: string; tagline?: string; eyebrow?: string; logo?: string | null }
167
+ trim?: { start?: TrimPoint; end?: TrimPoint }
168
+ music?: string | boolean | null
169
+ callouts?: CalloutSpec[]
170
+ zooms?: ZoomSpec[]
171
+ /** false: no cursor at all. Only for recordings with a logged (not filmed) cursor. */
172
+ cursor?: false | CursorSpec
173
+ /**
174
+ * Where the portrait video (`render --portrait`) comes from: "mobile" — the phone take
175
+ * (`reelson record --mobile`); "desktop" — a camera over the desktop recording; "auto"
176
+ * (default) — the phone take when there is one.
177
+ */
178
+ portrait?: 'auto' | 'mobile' | 'desktop'
179
+ /**
180
+ * Voice-over: true speaks each callout (demo.config.json `voice` settings); an object
181
+ * overrides the provider / model / voice / instructions / speed for this video and may add
182
+ * a line over the intro.
183
+ */
184
+ voice?:
185
+ | boolean
186
+ | {
187
+ provider?: 'openai' | 'elevenlabs' | 'piper' | 'command'
188
+ model?: string
189
+ voice?: string
190
+ instructions?: string
191
+ speed?: number
192
+ intro?: string
193
+ }
194
+ /** Versions every `reelson render` adds besides the 16:9 one (as --portrait / --square). */
195
+ formats?: ('portrait' | 'square')[]
196
+ }
197
+
198
+ export interface Callout {
199
+ at: number
200
+ duration: number
201
+ text: string
202
+ group?: string
203
+ /** Voice-over line, when it differs from the text (false: silent). */
204
+ say?: string | false
205
+ /** Index in video.json `callouts` (or in the default callouts when it has none). */
206
+ source: number
207
+ }
208
+
209
+ export interface Timeline {
210
+ total: number
211
+ intro: { start: number; duration: number; exit: number }
212
+ recap: { start: number; duration: number; maxSteps: number } | null
213
+ outro: { start: number; duration: number }
214
+ clipStart: number
215
+ clipDuration: number
216
+ clipEnd: number
217
+ /** Recording window used (trim). */
218
+ mediaStart: number
219
+ mediaEnd: number
220
+ /** Seconds the recording takes to leave before a hand-off card. */
221
+ belt: number
222
+ frame: { width: number; height: number }
223
+ viewport: { width: number; height: number }
224
+ /** Hand-off cards: composition time the card is fully in, and how long it holds. */
225
+ transitions: { at: number; gap: number; card: HandOff }[]
226
+ /** One <video> clip per stretch of footage between hand-offs. */
227
+ segments: { start: number; duration: number; mediaStart: number }[]
228
+ callouts: Callout[]
229
+ /**
230
+ * The cursor layer, or null when the footage already shows the cursor (or none is wanted).
231
+ * Composition seconds; x/y in recording CSS px (× `scale` for frame px).
232
+ */
233
+ cursor: {
234
+ size: number
235
+ ripple: boolean
236
+ /** Seconds of stillness before it fades out; 0 = always shown. */
237
+ idle: number
238
+ /** A phone take: show the taps (ripples) only, no arrow. */
239
+ touch?: boolean
240
+ scale: number
241
+ path: [number, number, number][]
242
+ presses: [number, number, number][]
243
+ } | null
244
+ /** What the demo worked on, in composition time (empty for recordings made before 0.5). */
245
+ focus: { at: number; box: Box; area: Box }[]
246
+ /** Recording time → composition time (footage after a hand-off is pushed back by the gap). */
247
+ toComposition: (recordingTime: number) => number
248
+ }
249
+
250
+ export class TimelineError extends Error {}
251
+
252
+ export function computeTimeline(
253
+ markers: Markers,
254
+ spec: VideoSpec,
255
+ timing: Timing = TIMING_DEFAULTS,
256
+ /**
257
+ * Voice-over: seconds from a callout appearing until its spoken line is done (0: silent or
258
+ * not spoken yet). Such a callout stays up at least that long, and the next one waits.
259
+ */
260
+ spoken: (c: { text: string; say?: string | false }) => number = () => 0,
261
+ ): { timeline: Timeline; warnings: string[] } {
262
+ const warnings: string[] = []
263
+ const mediaStart = Math.max(0, resolveTrimPoint(spec.trim?.start, markers, 'start') ?? 0)
264
+ const mediaEnd = Math.min(resolveTrimPoint(spec.trim?.end, markers, 'end') ?? markers.durationSeconds, markers.durationSeconds)
265
+ const mediaDuration = round(mediaEnd - mediaStart)
266
+ if (mediaDuration <= 0) {
267
+ throw new TimelineError(`trim window is empty (start ${mediaStart}s, end ${mediaEnd}s)`)
268
+ }
269
+
270
+ const stage = timing.stage
271
+ const clipStart = timing.intro.exit
272
+ const handOffs = (markers.transitions ?? []).filter((t) => t.at > mediaStart && t.at < mediaEnd)
273
+ const gap = stage.transitionGap
274
+ const clipDuration = round(mediaDuration + gap * handOffs.length)
275
+ const clipEnd = round(clipStart + clipDuration)
276
+ const toComposition = (t: number): number =>
277
+ round(clipStart + (t - mediaStart) + gap * handOffs.filter((h) => h.at <= t).length)
278
+ const transitions = handOffs.map((card, i) => ({
279
+ at: round(clipStart + (card.at - mediaStart) + gap * i),
280
+ gap,
281
+ card,
282
+ }))
283
+
284
+ const bounds = [mediaStart, ...handOffs.map((t) => t.at), mediaEnd]
285
+ const segments = bounds.slice(0, -1).map((from, i) => ({
286
+ start: round(clipStart + (from - mediaStart) + gap * i),
287
+ duration: round(bounds[i + 1] - from),
288
+ mediaStart: round(from),
289
+ }))
290
+
291
+ // Callouts: resolve each to a recording time, keep the ones inside the window, in order.
292
+ const specs = spec.callouts ?? defaultCallouts(markers)
293
+ const unknown = specs.filter((c) => c.marker !== undefined && !markers.markers.some((m) => m.label === c.marker))
294
+ if (unknown.length) {
295
+ throw new TimelineError(
296
+ `callout(s) reference unknown markers: ${unknown.map((c) => JSON.stringify(c.marker)).join(', ')} ` +
297
+ `— markers.json has ${markers.markers.map((m) => JSON.stringify(m.label)).join(', ') || 'none'}`,
298
+ )
299
+ }
300
+ const timed = specs
301
+ .map((c, source) => {
302
+ if ((c.marker === undefined) === (c.at === undefined)) {
303
+ throw new TimelineError(`callout "${c.text}" needs exactly one of \`marker\` or \`at\``)
304
+ }
305
+ if (c.offset !== undefined && c.marker === undefined) {
306
+ throw new TimelineError(`callout "${c.text}": \`offset\` shifts a \`marker\`; with \`at\`, change \`at\` instead`)
307
+ }
308
+ if (c.anchor !== undefined && c.marker === undefined) {
309
+ throw new TimelineError(`callout "${c.text}": \`anchor\` needs a \`marker\``)
310
+ }
311
+ if (c.at !== undefined) {
312
+ return { ...c, recordingAt: c.at, shownUntil: c.at, source }
313
+ }
314
+ const marker = (markers.markers.find((m) => m.label === c.marker) as { at: number }).at
315
+ const start = c.anchor === 'marker' ? marker : stepStart(markers, marker, mediaStart)
316
+ return { ...c, recordingAt: round(start + (c.offset ?? 0)), shownUntil: marker, source }
317
+ })
318
+ .filter((c) => {
319
+ const inside = c.recordingAt >= mediaStart && c.recordingAt <= mediaEnd
320
+ if (!inside) {
321
+ warnings.push(`callout "${c.text}" at ${c.recordingAt}s is outside the trim window — dropped`)
322
+ }
323
+ return inside
324
+ })
325
+ .sort((a, b) => a.recordingAt - b.recordingAt)
326
+
327
+ const transitionTimes = transitions.map((t) => t.at)
328
+ // When each shows. A step that starts as the footage does waits for the recording to
329
+ // arrive; one that starts right after the previous (a step that was only a page load) waits
330
+ // until that one has been up long enough to read. An explicit `at` is kept as given.
331
+ // With a voice-over, that is until its line has been said (and never past the recording).
332
+ const speaking = timed.map((c) => spoken(c))
333
+ const minimum = (i: number): number => Math.max(stage.calloutMinimum, speaking[i] ? speaking[i] + SPOKEN_TAIL : 0)
334
+ const starts: number[] = []
335
+ timed.forEach((c, i) => {
336
+ const at = toComposition(c.recordingAt)
337
+ const after = i ? starts[i - 1] + minimum(i - 1) : 0
338
+ starts.push(
339
+ c.at === undefined
340
+ ? round(Math.max(at, clipStart + stage.belt, Math.min(after, Math.max(at, clipEnd - stage.calloutMinimum))))
341
+ : at,
342
+ )
343
+ })
344
+ const callouts: Callout[] = timed.map((c, i) => {
345
+ const at = starts[i]
346
+ const next = starts[i + 1]
347
+ const nextTransition = transitionTimes.find((t) => t > at)
348
+ // Never overlap the next callout or a hand-off card; never outlive the recording.
349
+ const cap = Math.min(
350
+ next !== undefined ? next - 0.2 : Infinity,
351
+ nextTransition !== undefined ? nextTransition - stage.belt - 0.1 : Infinity,
352
+ clipEnd - 0.3,
353
+ )
354
+ // Through its step, and a moment on its result (the marker), at least calloutDuration.
355
+ const wanted = Math.max(stage.calloutDuration, toComposition(c.shownUntil) - at + stage.calloutHold, speaking[i])
356
+ const duration = round(c.duration ?? Math.max(1, Math.min(wanted, cap - at)))
357
+ if (c.duration !== undefined && at + c.duration > cap + 0.01) {
358
+ warnings.push(`callout "${c.text}" (${c.duration}s) overlaps the next step or the end of the recording`)
359
+ }
360
+ if (speaking[i] > duration + 0.25) {
361
+ warnings.push(
362
+ `voice-over: "${c.say || c.text}" is still being said ${round(speaking[i] - duration)}s after its callout goes — ` +
363
+ 'shorten its `say`, or pause longer in the scenario',
364
+ )
365
+ }
366
+ // Two-actor videos: tag each step with who does it (the hand-off card's roles).
367
+ const before = handOffs.filter((t) => t.at <= c.recordingAt)
368
+ const group = c.group ?? (before.length ? before.at(-1)?.to : handOffs[0]?.from)
369
+
370
+ return { at, duration, text: c.text, ...(group ? { group } : {}), ...(c.say !== undefined ? { say: c.say } : {}), source: c.source }
371
+ })
372
+ const recapTiming = timing.recap
373
+ if (recapTiming && callouts.length > recapTiming.maxSteps) {
374
+ warnings.push(
375
+ `${callouts.length} callouts — the recap holds ${recapTiming.maxSteps}; merge or drop steps (extra ones are left out of the recap)`,
376
+ )
377
+ }
378
+
379
+ const scale = Math.min(stage.maxW / markers.viewport.width, stage.maxH / markers.viewport.height)
380
+ // recording → recap → outro, each cross-fading into the next by `overlap`. Without a
381
+ // recap the outro waits for the recording to fade out: its text never lands on the footage.
382
+ const recapStart = round(clipEnd - stage.overlap)
383
+ const recap = recapTiming
384
+ ? {
385
+ start: recapStart,
386
+ duration: round(
387
+ Math.min(
388
+ recapTiming.max,
389
+ recapTiming.base + recapTiming.perStep * Math.min(callouts.length, recapTiming.maxSteps),
390
+ ),
391
+ ),
392
+ maxSteps: recapTiming.maxSteps,
393
+ }
394
+ : null
395
+ const outroStart = recap ? round(recap.start + recap.duration - stage.overlap) : clipEnd
396
+ const total = round(outroStart + timing.outro.duration)
397
+
398
+ const frame = {
399
+ width: Math.round(markers.viewport.width * scale),
400
+ height: Math.round(markers.viewport.height * scale),
401
+ }
402
+ const log = markers.cursor
403
+ if (spec.cursor !== undefined && (!log || log.drawn)) {
404
+ warnings.push(
405
+ 'video.json `cursor` has no effect: this recording has the cursor filmed in — re-record (record.cursor "layer") to draw it as a layer',
406
+ )
407
+ }
408
+ let cursor: Timeline['cursor'] = null
409
+ if (log && !log.drawn && spec.cursor !== false && log.path.length) {
410
+ const inside = (t: number): boolean => t >= mediaStart && t <= mediaEnd
411
+ // Where the cursor rests when the footage starts: the last move before the trim.
412
+ const before = log.path.filter(([t]) => t < mediaStart).at(-1)
413
+ const path: [number, number, number][] = [
414
+ ...(before ? [[clipStart, before[1], before[2]] as [number, number, number]] : []),
415
+ ...log.path.filter(([t]) => inside(t)).map(([t, x, y]): [number, number, number] => [toComposition(t), x, y]),
416
+ ]
417
+ cursor = {
418
+ size: spec.cursor?.size ?? 44,
419
+ ripple: spec.cursor?.ripple ?? true,
420
+ idle: spec.cursor?.idle ?? 0,
421
+ scale: Math.round((frame.width / markers.viewport.width) * 10000) / 10000,
422
+ path,
423
+ presses: log.presses.filter(([t]) => inside(t)).map(([t, x, y]) => [toComposition(t), x, y]),
424
+ }
425
+ }
426
+
427
+ return {
428
+ timeline: {
429
+ total,
430
+ intro: { start: 0, duration: timing.intro.duration, exit: timing.intro.exit },
431
+ recap,
432
+ outro: { start: outroStart, duration: timing.outro.duration },
433
+ clipStart,
434
+ clipDuration,
435
+ clipEnd,
436
+ mediaStart: round(mediaStart),
437
+ mediaEnd: round(mediaEnd),
438
+ belt: stage.belt,
439
+ frame,
440
+ viewport: markers.viewport,
441
+ transitions,
442
+ segments,
443
+ callouts,
444
+ cursor,
445
+ focus: (markers.focus ?? [])
446
+ .filter((f) => f.at >= mediaStart && f.at <= mediaEnd)
447
+ .map((f) => ({ ...f, at: toComposition(f.at) })),
448
+ toComposition,
449
+ },
450
+ warnings,
451
+ }
452
+ }
453
+
454
+ /** A trim edge in recording seconds (undefined: not set). */
455
+ /**
456
+ * When the step that ends at `marker` began: the first glide (or click) after the previous
457
+ * marker — and after `from`, the trim — else the marker itself (nothing logged in between,
458
+ * e.g. a page load).
459
+ */
460
+ export function stepStart(markers: Markers, marker: number, from: number): number {
461
+ const previous = Math.max(from, ...markers.markers.map((m) => m.at).filter((at) => at < marker - 1e-6))
462
+ const first = (markers.clicks ?? [])
463
+ .map((c) => c.move ?? c.at)
464
+ .filter((t) => t >= previous - 1e-6 && t < marker)
465
+ .sort((a, b) => a - b)[0]
466
+ return first ?? marker
467
+ }
468
+
469
+ export function resolveTrimPoint(point: TrimPoint | undefined, markers: Markers, edge: 'start' | 'end'): number | undefined {
470
+ if (point === undefined || typeof point === 'number') {
471
+ return point
472
+ }
473
+ if (point === 'auto') {
474
+ if (edge === 'end') {
475
+ throw new TimelineError('trim.end cannot be "auto" — leave it out to keep the recording to its end')
476
+ }
477
+ return suggestTrimStart(markers)
478
+ }
479
+ if ('marker' in point) {
480
+ const found = markers.markers.find((m) => m.label === point.marker)
481
+ if (!found) {
482
+ throw new TimelineError(
483
+ `trim.${edge}: no marker "${point.marker}" — markers.json has ${markers.markers.map((m) => JSON.stringify(m.label)).join(', ') || 'none'}`,
484
+ )
485
+ }
486
+ return round(found.at + (point.offset ?? 0))
487
+ }
488
+ const click = markers.clicks?.[point.click - 1]
489
+ if (!click) {
490
+ throw new TimelineError(`trim.${edge}: no click ${point.click} — markers.json has ${markers.clicks?.length ?? 0}`)
491
+ }
492
+ return round((click.move ?? click.at) + (point.offset ?? 0))
493
+ }
494
+
495
+ /** Where the footage should start: just before the first logged glide (after the login). */
496
+ export function suggestTrimStart(markers: Markers): number {
497
+ const candidates = [
498
+ ...(markers.clicks ?? []).map((c) => (c.move ?? c.at) - 0.5),
499
+ ...markers.markers.map((m) => m.at - 0.8),
500
+ ]
501
+ if (!candidates.length) {
502
+ return 0
503
+ }
504
+ return Math.max(0, round(Math.min(...candidates)))
505
+ }
506
+
507
+ /** One callout per marker, worded as the marker label: the starting point of a new video.json. */
508
+ export function defaultCallouts(markers: Markers): CalloutSpec[] {
509
+ return markers.markers.map((m) => ({ marker: m.label, text: m.label }))
510
+ }
511
+
512
+ export function round(n: number): number {
513
+ return Math.round(n * 100) / 100
514
+ }