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,1031 @@
1
+ // reelson studio: the built video in the HyperFrames player over a timeline of every
2
+ // layer. Every edit is a whole new video.json sent to the server (PUT /api/video), which
3
+ // checks it like a build would, writes it and rebuilds; the page then reloads in place.
4
+
5
+ const FPS = 30
6
+ const $ = (id) => document.getElementById(id)
7
+ const player = $('player')
8
+
9
+ let data = null
10
+ let version = -1
11
+ let revision = ''
12
+ let choices = null
13
+ let time = 0
14
+ let playing = false
15
+ let pxPerSec = null // null = fit
16
+ let selected = null // { kind, key }: callouts by their index in video.json, the rest by number
17
+ /** Time to hold across a reload: the player fires `ready` (and resets to 0) more than once. */
18
+ let restoring = null
19
+ /** After an add, focus the new item's text field once the inspector shows it. */
20
+ let focusText = false
21
+ const history = { undo: [], redo: [] }
22
+
23
+ const r2 = (n) => Math.round(n * 100) / 100
24
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n))
25
+ const fmt = (t) => t.toFixed(2)
26
+ const lastFrame = () => Math.max(0, Math.floor((data?.total ?? 0) * FPS - 1e-6) / FPS)
27
+
28
+ // ── data ───────────────────────────────────────────────────────────
29
+ async function load() {
30
+ const res = await fetch('/api/plan')
31
+ const body = await res.json()
32
+ showError(body.error)
33
+ if (!body.data) return
34
+ const first = !data
35
+ data = body.data
36
+ // Undo writes back a whole earlier video.json; after a change from elsewhere (Claude, an
37
+ // editor) that would silently revert it, so the history starts over.
38
+ if (!first && body.revision !== revision && (history.undo.length || history.redo.length)) {
39
+ history.undo = []
40
+ history.redo = []
41
+ toast('video.json changed outside the studio — undo history cleared')
42
+ }
43
+ revision = body.revision
44
+ choices = body.choices
45
+ if (body.version !== version) {
46
+ version = body.version
47
+ reloadPlayer(first ? 0 : time)
48
+ }
49
+ renderAll()
50
+ }
51
+
52
+ function renderAll() {
53
+ renderHeader()
54
+ renderWarnings()
55
+ renderTimeline()
56
+ renderInspector()
57
+ updateHistory()
58
+ }
59
+
60
+ function reloadPlayer(at) {
61
+ restoring = at
62
+ playing = false
63
+ updatePlay()
64
+ player.setAttribute('src', `/video/index.html?v=${version}`)
65
+ }
66
+
67
+ player.addEventListener('ready', () => {
68
+ if (restoring === null) return
69
+ time = Math.min(restoring, lastFrame())
70
+ player.seek(time)
71
+ updateTime()
72
+ })
73
+ player.addEventListener('timeupdate', (e) => {
74
+ if (restoring !== null) return
75
+ time = e.detail.currentTime
76
+ // The runtime's clock can run past the composition; the video ends at its last frame.
77
+ if (playing && time >= lastFrame()) {
78
+ player.pause()
79
+ seek(lastFrame())
80
+ return
81
+ }
82
+ updateTime()
83
+ })
84
+ player.addEventListener('play', () => { restoring = null; playing = true; updatePlay() })
85
+ player.addEventListener('pause', () => { playing = false; updatePlay() })
86
+ player.addEventListener('ended', () => { playing = false; updatePlay() })
87
+
88
+ function connect() {
89
+ const events = new EventSource('/api/events')
90
+ events.onopen = () => setStatus('live', '')
91
+ events.onmessage = (e) => {
92
+ const msg = JSON.parse(e.data)
93
+ if (msg.error) {
94
+ showError(msg.error)
95
+ } else if (msg.version !== version) {
96
+ load()
97
+ }
98
+ }
99
+ events.onerror = () => setStatus('studio stopped — rerun reelson studio', 'offline')
100
+ }
101
+
102
+ // ── edits ──────────────────────────────────────────────────────────
103
+ /** Applies `mutate` to a copy of video.json and saves it. Resolves true once written. */
104
+ async function commit(label, mutate) {
105
+ const before = structuredClone(data.spec)
106
+ const next = structuredClone(before)
107
+ mutate(next)
108
+ tidy(next)
109
+ if (JSON.stringify(next) === JSON.stringify(before)) {
110
+ renderTimeline()
111
+ return false
112
+ }
113
+ const ok = await save(next, label)
114
+ if (ok) {
115
+ history.undo.push({ spec: before, label })
116
+ history.redo = []
117
+ updateHistory()
118
+ }
119
+ return ok
120
+ }
121
+
122
+ async function save(spec, label) {
123
+ setStatus('saving…', 'busy')
124
+ let res, body
125
+ try {
126
+ res = await fetch('/api/video', {
127
+ method: 'PUT',
128
+ headers: { 'content-type': 'application/json' },
129
+ body: JSON.stringify({ base: revision, spec }),
130
+ })
131
+ body = await res.json()
132
+ } catch {
133
+ toast('The studio is not answering — is it still running?', true)
134
+ setStatus('studio stopped — rerun reelson studio', 'offline')
135
+ return false
136
+ }
137
+ if (!res.ok) {
138
+ toast(body.error, true)
139
+ if (res.status === 409) {
140
+ await load()
141
+ } else {
142
+ setStatus('live', '')
143
+ renderTimeline()
144
+ }
145
+ return false
146
+ }
147
+ revision = body.revision
148
+ toast(body.error ? `${label} — saved, but the build failed` : `${label}${body.warnings?.length ? ` · ${body.warnings.length} warning(s)` : ''}`, !!body.error)
149
+ await load()
150
+ return true
151
+ }
152
+
153
+ /** Drops what would only be noise in video.json. */
154
+ function tidy(spec) {
155
+ if (spec.trim && !Object.keys(spec.trim).length) delete spec.trim
156
+ if (spec.sections && !Object.keys(spec.sections).length) delete spec.sections
157
+ for (const c of spec.callouts ?? []) {
158
+ if (c.offset !== undefined && Math.abs(c.offset) < 0.005) delete c.offset
159
+ }
160
+ }
161
+
162
+ async function undo() {
163
+ const step = history.undo.pop()
164
+ if (!step) return
165
+ const current = structuredClone(data.spec)
166
+ if (await save(step.spec, `Undid: ${step.label}`)) {
167
+ history.redo.push({ spec: current, label: step.label })
168
+ } else {
169
+ history.undo.push(step)
170
+ }
171
+ updateHistory()
172
+ }
173
+
174
+ async function redo() {
175
+ const step = history.redo.pop()
176
+ if (!step) return
177
+ const current = structuredClone(data.spec)
178
+ if (await save(step.spec, `Redid: ${step.label}`)) {
179
+ history.undo.push({ spec: current, label: step.label })
180
+ } else {
181
+ history.redo.push(step)
182
+ }
183
+ updateHistory()
184
+ }
185
+
186
+ function updateHistory() {
187
+ $('undo').disabled = !history.undo.length
188
+ $('redo').disabled = !history.redo.length
189
+ $('undo').title = history.undo.length ? `Undo: ${history.undo.at(-1).label} (⌘Z)` : 'Nothing to undo'
190
+ $('redo').title = history.redo.length ? `Redo: ${history.redo.at(-1).label} (⇧⌘Z)` : 'Nothing to redo'
191
+ const inRecording = data && time >= data.sections[1].start && time <= data.sections[1].end
192
+ $('add-callout').disabled = !inRecording
193
+ }
194
+
195
+ /**
196
+ * A trim edge moved to `next` (recording s). An anchored edge keeps its anchor and changes
197
+ * its offset; "auto" or an unset edge is tied to the nearest marker or click, so the trim
198
+ * still follows the footage after a re-record; a plain number stays a number.
199
+ */
200
+ function movedTrim(point, next) {
201
+ if (point && typeof point === 'object') {
202
+ const resolved = anchorTime(point)
203
+ const moved = { ...point, offset: r2((point.offset ?? 0) + next - resolved) }
204
+ if (Math.abs(moved.offset) < 0.005) delete moved.offset
205
+ return moved
206
+ }
207
+ if (typeof point === 'number') return next
208
+ const candidates = [
209
+ ...data.anchors.markers.map((m) => ({ key: { marker: m.label }, at: m.at })),
210
+ ...data.anchors.clicks.map((c) => ({ key: { click: c.n }, at: c.at })),
211
+ ]
212
+ if (!candidates.length) return next
213
+ const near = candidates.reduce((best, c) => (Math.abs(c.at - next) < Math.abs(best.at - next) ? c : best))
214
+ const offset = r2(next - near.at)
215
+ return Math.abs(offset) < 0.005 ? near.key : { ...near.key, offset }
216
+ }
217
+
218
+ function anchorTime(point) {
219
+ const base = 'marker' in point
220
+ ? data.anchors.markers.find((m) => m.label === point.marker)?.at
221
+ : data.anchors.clicks.find((c) => c.n === point.click)?.at
222
+ return r2((base ?? 0) + (point.offset ?? 0))
223
+ }
224
+
225
+ function describeTrim(point, edge) {
226
+ if (point === undefined) return edge === 'start' ? 'the beginning' : 'the end of the recording'
227
+ if (point === 'auto') return 'auto — just before the first action'
228
+ if (typeof point === 'number') return 'a fixed time (goes stale when you re-record)'
229
+ const off = point.offset ? `${point.offset > 0 ? '+' : ''}${point.offset}s from ` : ''
230
+ return 'marker' in point ? `${off}marker “${point.marker}”` : `${off}the glide to click ${point.click}`
231
+ }
232
+
233
+ /** Composition time → recording time (footage pauses while a hand-off card is on screen). */
234
+ function toRecording(t) {
235
+ let seg = data.segments[0]
236
+ for (const s of data.segments) if (t >= s.start) seg = s
237
+ return r2(seg.mediaStart + clamp(t - seg.start, 0, seg.duration))
238
+ }
239
+
240
+ function addCalloutAt(t) {
241
+ if (t < data.sections[1].start || t > data.sections[1].end) {
242
+ toast('Callouts go over the recording — move the playhead into it first', true)
243
+ return
244
+ }
245
+ const source = data.spec.callouts.length
246
+ selected = { kind: 'callout', key: source }
247
+ focusText = true
248
+ commit('Added a callout', (spec) => spec.callouts.push({ at: toRecording(t), text: 'New step' }))
249
+ }
250
+
251
+ function removeSelected() {
252
+ if (!selected) return
253
+ if (selected.kind === 'callout') {
254
+ const i = Number(selected.key)
255
+ const c = data.spec.callouts[i]
256
+ selected = null
257
+ if (c) commit(`Deleted callout "${c.text}"`, (spec) => spec.callouts.splice(i, 1))
258
+ } else if (selected.kind === 'zoom') {
259
+ const i = Number(selected.key) - 1
260
+ selected = null
261
+ commit(`Deleted zoom ${i + 1}`, (spec) => spec.zooms.splice(i, 1))
262
+ }
263
+ }
264
+
265
+ // ── playback ───────────────────────────────────────────────────────
266
+ function seek(t) {
267
+ if (!data) return
268
+ restoring = null
269
+ time = clamp(t, 0, lastFrame())
270
+ player.seek(time)
271
+ updateTime()
272
+ }
273
+
274
+ function togglePlay() {
275
+ if (playing) {
276
+ player.pause()
277
+ } else {
278
+ if (time >= lastFrame() - 1e-3) seek(0)
279
+ restoring = null
280
+ player.play()
281
+ }
282
+ }
283
+
284
+ function updatePlay() {
285
+ $('play').textContent = playing ? '❚❚' : '▶︎'
286
+ }
287
+
288
+ function updateTime() {
289
+ if (!data) return
290
+ $('time').firstChild.textContent = `${fmt(time)} / ${fmt(data.total)}`
291
+ $('time').querySelector('.frame').textContent = `frame ${Math.round(time * FPS)}`
292
+ const head = document.querySelector('.playhead')
293
+ if (head) {
294
+ head.style.left = `${time * scale()}px`
295
+ if (playing) follow()
296
+ }
297
+ const inRecording = time >= data.sections[1].start && time <= data.sections[1].end
298
+ $('add-callout').disabled = !inRecording
299
+ }
300
+
301
+ /** Keep the playhead in view while playing. */
302
+ function follow() {
303
+ const body = $('tl-body')
304
+ const labelW = labelWidth()
305
+ const x = time * scale() + labelW
306
+ if (x < body.scrollLeft + labelW || x > body.scrollLeft + body.clientWidth - 40) {
307
+ body.scrollLeft = x - labelW - 40
308
+ }
309
+ }
310
+
311
+ // ── header, warnings ───────────────────────────────────────────────
312
+ function renderHeader() {
313
+ document.title = `${data.title} · reelson studio`
314
+ $('title').textContent = data.title
315
+ $('slug').textContent = data.slug
316
+ const current = Object.fromEntries(data.sections.map((s) => [s.slot, s.name]))
317
+ current.recap ??= 'none'
318
+ const pick = (label, options, value, onChange) => {
319
+ const chip = el('label', 'chip', `${label} `)
320
+ chip.append(dropdown(options, value, onChange))
321
+ return chip
322
+ }
323
+ $('design').replaceChildren(
324
+ pick('template', choices.templates, data.template, (v) => commit(`Template: ${v}`, (spec) => { spec.template = v })),
325
+ ...['intro', 'recap', 'outro'].map((slot) =>
326
+ pick(slot, choices[slot], current[slot], (v) => commit(`${slot}: ${v}`, (spec) => { spec.sections = { ...spec.sections, [slot]: v } })),
327
+ ),
328
+ )
329
+ $('viewer').classList.toggle('tall', data.frame.height > data.frame.width)
330
+ }
331
+
332
+ function renderWarnings() {
333
+ const list = $('warnings')
334
+ const items = [
335
+ ...data.warnings.map((w) => ({ text: w, bad: false })),
336
+ ...data.zooms.flatMap((z) => z.problems.map((p) => ({ text: `zoom ${z.n}: ${p}`, bad: true, select: { kind: 'zoom', key: z.n } }))),
337
+ ]
338
+ if (!items.length) {
339
+ const ok = el('li', 'ok', 'None — ready to render.')
340
+ list.replaceChildren(ok)
341
+ return
342
+ }
343
+ list.replaceChildren(
344
+ ...items.map((w) => {
345
+ const li = el('li', w.bad ? 'bad' : '', w.text)
346
+ if (w.select) {
347
+ li.style.cursor = 'pointer'
348
+ li.onclick = () => select(w.select.kind, w.select.key, true)
349
+ }
350
+ return li
351
+ }),
352
+ )
353
+ }
354
+
355
+ // ── timeline ───────────────────────────────────────────────────────
356
+ const LANES = [
357
+ ['sections', 'Sections'],
358
+ ['callouts', 'Callouts'],
359
+ ['zooms', 'Zooms'],
360
+ ['handOffs', 'Hand-offs'],
361
+ ['clicks', 'Clicks'],
362
+ ['markers', 'Markers'],
363
+ ['cursor', 'Cursor'],
364
+ ['audio', 'Audio'],
365
+ ]
366
+
367
+ function labelWidth() {
368
+ return $('labels').getBoundingClientRect().width
369
+ }
370
+
371
+ function fitScale() {
372
+ const w = $('tl-body').clientWidth - labelWidth() - 16
373
+ return Math.max(10, w / data.total)
374
+ }
375
+
376
+ function scale() {
377
+ return pxPerSec ?? fitScale()
378
+ }
379
+
380
+ function el(tag, cls, text) {
381
+ const node = document.createElement(tag)
382
+ if (cls) node.className = cls
383
+ if (text !== undefined) node.textContent = text
384
+ return node
385
+ }
386
+
387
+ function span(node, start, end) {
388
+ const s = scale()
389
+ node.style.left = `${start * s}px`
390
+ node.style.width = `${Math.max(3, (end - start) * s)}px`
391
+ return node
392
+ }
393
+
394
+ function hover(node, text) {
395
+ node.addEventListener('mouseenter', () => { const tip = $('tooltip'); tip.textContent = text; tip.style.display = 'block' })
396
+ node.addEventListener('mousemove', (e) => { const tip = $('tooltip'); tip.style.left = `${e.clientX + 12}px`; tip.style.top = `${e.clientY - 30}px` })
397
+ node.addEventListener('mouseleave', () => { $('tooltip').style.display = 'none' })
398
+ }
399
+
400
+ const isSelected = (kind, key) => selected && selected.kind === kind && String(selected.key) === String(key)
401
+
402
+ /**
403
+ * An item that selects (and seeks to `at`) on click. With `drag`, it can also be dragged:
404
+ * `drag.modes` lists 'move', 'start' and/or 'end' (the edges are 6 px handles);
405
+ * `drag.preview(mode, ds)` redraws it while dragging, `drag.drop(mode, ds)` saves.
406
+ */
407
+ function pickable(node, kind, key, at, drag) {
408
+ node.dataset.kind = kind
409
+ node.dataset.key = key
410
+ if (isSelected(kind, key)) node.classList.add('selected')
411
+ const modeAt = (e) => {
412
+ if (!drag) return null
413
+ const rect = node.getBoundingClientRect()
414
+ const x = e.clientX - rect.left
415
+ const edge = Math.min(6, rect.width / 3)
416
+ if (drag.modes.includes('start') && x <= edge) return 'start'
417
+ if (drag.modes.includes('end') && x >= rect.width - edge) return 'end'
418
+ return drag.modes.includes('move') ? 'move' : null
419
+ }
420
+ if (drag) {
421
+ node.classList.add('editable')
422
+ node.addEventListener('pointermove', (e) => {
423
+ if (e.buttons) return
424
+ const mode = modeAt(e)
425
+ node.style.cursor = mode === 'start' || mode === 'end' ? 'ew-resize' : mode === 'move' ? 'grab' : 'pointer'
426
+ })
427
+ }
428
+ node.addEventListener('pointerdown', (e) => {
429
+ // Items select or drag instead of scrubbing.
430
+ e.stopPropagation()
431
+ if (e.button !== 0) return
432
+ $('tooltip').style.display = 'none'
433
+ const mode = modeAt(e)
434
+ const x0 = e.clientX
435
+ let ds = 0
436
+ let moved = false
437
+ try { node.setPointerCapture(e.pointerId) } catch { /* synthetic events */ }
438
+ const onMove = (m) => {
439
+ if (!mode) return
440
+ if (!moved && Math.abs(m.clientX - x0) < 3) return
441
+ if (!moved) {
442
+ moved = true
443
+ if (playing) player.pause()
444
+ node.classList.add('dragging')
445
+ }
446
+ ds = Math.round(((m.clientX - x0) / scale()) * FPS) / FPS
447
+ ds = drag.snap ? drag.snap(mode, ds) : ds
448
+ drag.preview(mode, ds)
449
+ }
450
+ const onUp = () => {
451
+ node.removeEventListener('pointermove', onMove)
452
+ node.removeEventListener('pointerup', onUp)
453
+ node.removeEventListener('pointercancel', onUp)
454
+ if (moved) {
455
+ node.classList.remove('dragging')
456
+ if (Math.abs(ds) >= 1 / FPS) {
457
+ selected = { kind, key }
458
+ drag.drop(mode, ds)
459
+ } else {
460
+ renderTimeline()
461
+ }
462
+ return
463
+ }
464
+ if (playing) player.pause()
465
+ select(kind, key, false)
466
+ seek(at)
467
+ }
468
+ node.addEventListener('pointermove', onMove)
469
+ node.addEventListener('pointerup', onUp)
470
+ node.addEventListener('pointercancel', onUp)
471
+ })
472
+ return node
473
+ }
474
+
475
+ /** Snaps a dragged time onto a marker, a click or the playhead when within 6 px. */
476
+ function snapTo(t) {
477
+ const targets = [time, ...data.markers.map((m) => m.at), ...data.clicks.map((c) => c.at)]
478
+ const px = 6 / scale()
479
+ let best = t
480
+ let bestD = px
481
+ for (const target of targets) {
482
+ const d = Math.abs(target - t)
483
+ if (d < bestD) { best = target; bestD = d }
484
+ }
485
+ return best
486
+ }
487
+
488
+ function renderTimeline() {
489
+ const s = scale()
490
+ const width = data.total * s
491
+ $('labels').replaceChildren(el('div', 'ruler-label', ''), ...LANES.map(([, label]) => el('div', '', label)))
492
+ const tracks = $('tracks')
493
+ tracks.style.width = `${width}px`
494
+ $('tl-inner').style.width = `${width + labelWidth() + 16}px`
495
+
496
+ // Ruler: a labelled tick every `step` seconds, minor ticks between.
497
+ const ruler = el('div', 'ruler')
498
+ const step = [0.5, 1, 2, 5, 10, 15, 30, 60].find((st) => st * s >= 60) ?? 60
499
+ for (let t = 0; t <= data.total + 1e-6; t += step / 2) {
500
+ const major = Math.abs(t / step - Math.round(t / step)) < 1e-6
501
+ const tick = el('div', major ? 'tick' : 'tick minor')
502
+ tick.style.left = `${t * s}px`
503
+ if (major) tick.append(el('span', '', `${+t.toFixed(1)}s`))
504
+ ruler.append(tick)
505
+ }
506
+
507
+ const lanes = Object.fromEntries(LANES.map(([key]) => [key, el('div', 'lane')]))
508
+
509
+ for (const sec of data.sections) {
510
+ const node = span(el('div', `item ${sec.slot}`, sec.slot === 'recording' ? 'recording' : `${sec.slot} · ${sec.name}`), sec.start, sec.end)
511
+ let drag
512
+ if (sec.slot === 'recording') {
513
+ hover(node, `recording: ${fmt(sec.start)}–${fmt(sec.end)}s ${sec.detail} — drag an edge to trim`)
514
+ drag = {
515
+ modes: ['start', 'end'],
516
+ preview: (mode, ds) => (mode === 'start' ? span(node, sec.start + ds, sec.end) : span(node, sec.start, sec.end + ds)),
517
+ drop: (mode, ds) => {
518
+ const { start, end, duration } = data.media
519
+ if (mode === 'start') {
520
+ const next = clamp(r2(start + ds), 0, end - 1)
521
+ commit(`Trim start: ${next}s`, (spec) => {
522
+ spec.trim = { ...spec.trim, start: movedTrim(spec.trim?.start, next) }
523
+ if (next === 0) delete spec.trim.start
524
+ })
525
+ } else {
526
+ const next = clamp(r2(end + ds), start + 1, duration)
527
+ commit(`Trim end: ${next >= duration - 0.01 ? 'the end' : `${next}s`}`, (spec) => {
528
+ spec.trim = { ...spec.trim, end: movedTrim(spec.trim?.end, next) }
529
+ if (next >= duration - 0.01) delete spec.trim.end
530
+ })
531
+ }
532
+ },
533
+ }
534
+ } else {
535
+ hover(node, `${sec.slot} "${sec.name}": ${fmt(sec.start)}–${fmt(sec.end)}s ${sec.detail}`)
536
+ }
537
+ lanes.sections.append(pickable(node, 'section', sec.slot, sec.start, drag))
538
+ }
539
+
540
+ data.callouts.forEach((c, i) => {
541
+ // An overlap is already a warning; outline both callouts so it shows where.
542
+ const overlaps = (a, b) => a && b && a.end > b.at + 0.01
543
+ const bad = overlaps(c, data.callouts[i + 1]) || overlaps(data.callouts[i - 1], c)
544
+ const node = span(el('div', `item callout${bad ? ' bad' : ''}`, `${c.n} ${c.text}`), c.at, c.end)
545
+ hover(node, `${c.n}. ${c.text} (${fmt(c.at)}–${fmt(c.end)}s)${bad ? ' — overlaps a neighbour' : ''} — drag to move, drag the right edge to resize`)
546
+ lanes.callouts.append(pickable(node, 'callout', c.source, c.at, {
547
+ modes: ['move', 'end'],
548
+ snap: (mode, ds) => (mode === 'move' ? snapTo(c.at + ds) - c.at : snapTo(c.end + ds) - c.end),
549
+ preview: (mode, ds) => (mode === 'move' ? span(node, c.at + ds, c.end + ds) : span(node, c.at, Math.max(c.at + 0.5, c.end + ds))),
550
+ drop: (mode, ds) => {
551
+ if (mode === 'move') {
552
+ const delta = r2(toRecording(c.at + ds) - toRecording(c.at))
553
+ commit(`Moved callout ${c.n} ${delta > 0 ? '+' : ''}${delta}s`, (spec) => {
554
+ const src = spec.callouts[c.source]
555
+ if (src.marker !== undefined) src.offset = r2((src.offset ?? 0) + delta)
556
+ else src.at = Math.max(0, r2(src.at + delta))
557
+ })
558
+ } else {
559
+ const length = Math.max(0.5, r2(c.end - c.at + ds))
560
+ commit(`Callout ${c.n} length: ${length}s`, (spec) => { spec.callouts[c.source].duration = length })
561
+ }
562
+ },
563
+ }))
564
+ })
565
+
566
+ for (const z of data.zooms) {
567
+ const node = span(el('div', `item zoom${z.problems.length ? ' bad' : ''}`), z.at, z.end)
568
+ const dur = z.end - z.at
569
+ const a = (z.in / dur) * 100
570
+ const b = 100 - (z.out / dur) * 100
571
+ node.innerHTML = `<svg viewBox="0 0 100 20" preserveAspectRatio="none"><polygon points="0,20 ${a},2 ${b},2 100,20" fill="color-mix(in srgb, var(--zoom) 55%, transparent)" stroke="var(--zoom)" stroke-width="1" vector-effect="non-scaling-stroke"/></svg>`
572
+ node.append(el('span', '', `${z.scale}×${data.spec.zooms?.[z.n - 1]?.follow ? ' · follow' : ''}`))
573
+ const src = data.spec.zooms?.[z.n - 1]
574
+ const manual = src && !src.clicks
575
+ hover(node, `zoom ${z.n}: ${z.scale}× at ${z.x}, ${z.y} · ${fmt(z.at)}–${fmt(z.end)}s (in ${z.in}s, out ${z.out}s)${manual ? ' — drag to move, drag an edge to resize' : ' — timed from its clicks'}`)
576
+ lanes.zooms.append(pickable(node, 'zoom', z.n, z.at, manual ? {
577
+ modes: ['move', 'start', 'end'],
578
+ preview: (mode, ds) => span(node, mode === 'end' ? z.at : z.at + ds, mode === 'start' ? z.end : z.end + ds),
579
+ drop: (mode, ds) => commit(`${mode === 'move' ? 'Moved' : 'Resized'} zoom ${z.n}`, (spec) => {
580
+ const zoom = spec.zooms[z.n - 1]
581
+ if (mode !== 'end') zoom.at = Math.max(0, r2(toRecording(z.at + ds)))
582
+ if (mode === 'start') zoom.duration = Math.max(1, r2(zoom.duration - ds))
583
+ if (mode === 'end') zoom.duration = Math.max(1, r2(zoom.duration + ds))
584
+ }),
585
+ } : undefined))
586
+ }
587
+
588
+ data.handOffs.forEach((h, i) => {
589
+ lanes.handOffs.append(span(el('div', 'belt'), h.at - h.belt, h.at))
590
+ const node = span(el('div', 'item handoff', h.title), h.at, h.end)
591
+ hover(node, `hand-off: ${h.title}${h.from ? ` (${h.from} → ${h.to})` : ''} · ${fmt(h.at)}–${fmt(h.end)}s`)
592
+ lanes.handOffs.append(pickable(node, 'handoff', i + 1, h.at - h.belt))
593
+ })
594
+
595
+ for (const c of data.clicks) {
596
+ if (c.at > c.glide) lanes.clicks.append(span(el('div', 'glide'), c.glide, c.at))
597
+ if (c.kind === 'type' && c.until > c.at) lanes.clicks.append(span(el('div', 'typing'), c.at, c.until))
598
+ const dot = el('div', `dot ${c.kind}`)
599
+ dot.style.left = `${c.at * s}px`
600
+ hover(dot, `${c.kind} #${c.n} at ${fmt(c.at)}s${c.kind === 'type' ? ` (typing until ${fmt(c.until)}s)` : ''}, glide from ${fmt(c.glide)}s`)
601
+ lanes.clicks.append(pickable(dot, 'click', c.n, c.at))
602
+ }
603
+
604
+ data.markers.forEach((m, i) => {
605
+ const pin = el('div', `pin${m.used ? '' : ' unused'}`)
606
+ pin.style.left = `${m.at * s}px`
607
+ hover(pin, `marker "${m.label}" at ${fmt(m.at)}s (recording ${fmt(m.recordingAt)}s)${m.used ? '' : ' — no callout uses it'}`)
608
+ lanes.markers.append(pickable(pin, 'marker', i + 1, m.at))
609
+ })
610
+
611
+ {
612
+ const rec = data.sections[1]
613
+ const c = data.cursor
614
+ const label = c.state === 'layer' ? `cursor layer · ${c.size}px${c.ripple ? '' : ' · no ripple'}` : c.state === 'hidden' ? 'cursor hidden' : 'cursor filmed in'
615
+ const node = span(el('div', `item cursor ${c.state}`, label), rec.start, rec.end)
616
+ hover(node, c.state === 'filmed' ? 'The cursor is part of the footage (recorded with record.cursor "recorded", or before reelson 0.4)' : `${label} — click to change`)
617
+ lanes.cursor.append(pickable(node, 'cursor', 'cursor', rec.start))
618
+ for (const at of c.presses) {
619
+ const tick = el('div', 'press')
620
+ tick.style.left = `${at * s}px`
621
+ lanes.cursor.append(tick)
622
+ }
623
+ }
624
+
625
+ const audio = [['narration', data.audio.narration], ['music', data.audio.music]].filter(([, a]) => a)
626
+ audio.forEach(([name, a], i) => {
627
+ const node = span(el('div', `item audio${audio.length > 1 ? ' half' : ''}`, name), a.start, a.end)
628
+ if (audio.length > 1) node.style.top = i ? '15px' : '4px'
629
+ lanes.audio.append(pickable(node, 'audio', name, a.start))
630
+ })
631
+ if (!audio.length) {
632
+ const none = el('div', 'hint lane-note', 'no narration, no music')
633
+ lanes.audio.append(none)
634
+ }
635
+
636
+ tracks.replaceChildren(ruler, ...Object.values(lanes), el('div', 'playhead'))
637
+ updateTime()
638
+ }
639
+
640
+ // Scrub: pointer down + drag anywhere on the tracks (items select instead).
641
+ let scrubbing = false
642
+ function timeAt(e) {
643
+ const rect = $('tracks').getBoundingClientRect()
644
+ return (e.clientX - rect.left) / scale()
645
+ }
646
+ $('tracks').addEventListener('pointerdown', (e) => {
647
+ if (!data) return
648
+ scrubbing = true
649
+ $('tracks').setPointerCapture(e.pointerId)
650
+ if (playing) player.pause()
651
+ seek(timeAt(e))
652
+ })
653
+ $('tracks').addEventListener('pointermove', (e) => { if (scrubbing) seek(timeAt(e)) })
654
+ $('tracks').addEventListener('pointerup', () => { scrubbing = false })
655
+
656
+ function setZoom(next) {
657
+ const body = $('tl-body')
658
+ const center = (body.scrollLeft + body.clientWidth / 2 - labelWidth()) / scale()
659
+ pxPerSec = next
660
+ renderTimeline()
661
+ body.scrollLeft = center * scale() - body.clientWidth / 2 + labelWidth()
662
+ }
663
+ $('zoom-in').onclick = () => setZoom(scale() * 1.5)
664
+ $('zoom-out').onclick = () => setZoom(scale() / 1.5 <= fitScale() ? null : scale() / 1.5)
665
+ $('zoom-fit').onclick = () => setZoom(null)
666
+ $('tl-body').addEventListener('wheel', (e) => {
667
+ if (!(e.ctrlKey || e.metaKey) || !data) return
668
+ e.preventDefault()
669
+ const next = scale() * (e.deltaY < 0 ? 1.15 : 1 / 1.15)
670
+ setZoom(next <= fitScale() ? null : next)
671
+ }, { passive: false })
672
+ new ResizeObserver(() => { if (data && pxPerSec === null) renderTimeline() }).observe($('tl-body'))
673
+
674
+ // ── inspector ──────────────────────────────────────────────────────
675
+ function select(kind, key, andSeek) {
676
+ selected = { kind, key }
677
+ document.querySelectorAll('.selected').forEach((n) => n.classList.remove('selected'))
678
+ document.querySelectorAll(`[data-kind="${kind}"][data-key="${key}"]`).forEach((n) => n.classList.add('selected'))
679
+ renderInspector()
680
+ if (andSeek) {
681
+ const at = seekTarget()
682
+ if (at !== undefined) seek(at)
683
+ }
684
+ }
685
+
686
+ function seekTarget() {
687
+ const { kind, key } = selected
688
+ const k = String(key)
689
+ if (kind === 'section') return data.sections.find((x) => x.slot === k)?.start
690
+ if (kind === 'callout') return data.callouts.find((x) => String(x.source) === k)?.at
691
+ if (kind === 'zoom') return data.zooms.find((x) => String(x.n) === k)?.at
692
+ return undefined
693
+ }
694
+
695
+ // Form controls: each saves on change (Enter or leaving the field).
696
+ function textInput(value, onSave, placeholder = '') {
697
+ const input = el('input')
698
+ input.type = 'text'
699
+ input.value = value ?? ''
700
+ input.placeholder = placeholder
701
+ input.addEventListener('change', () => onSave(input.value.trim()))
702
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') input.blur(); if (e.key === 'Escape') { input.value = value ?? ''; input.blur() } })
703
+ return input
704
+ }
705
+
706
+ function numberInput(value, onSave, { step = 0.1, min, max, placeholder = '' } = {}) {
707
+ const input = el('input')
708
+ input.type = 'number'
709
+ input.step = String(step)
710
+ if (min !== undefined) input.min = String(min)
711
+ if (max !== undefined) input.max = String(max)
712
+ input.value = value ?? ''
713
+ input.placeholder = placeholder
714
+ input.addEventListener('change', () => onSave(input.value === '' ? undefined : r2(Number(input.value))))
715
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') input.blur() })
716
+ return input
717
+ }
718
+
719
+ function dropdown(options, value, onChange) {
720
+ const sel = el('select')
721
+ for (const o of options) {
722
+ const opt = el('option', '', o)
723
+ opt.value = o
724
+ sel.append(opt)
725
+ }
726
+ sel.value = value
727
+ sel.addEventListener('change', () => onChange(sel.value))
728
+ return sel
729
+ }
730
+
731
+ function button(label, onClick, cls = '') {
732
+ const b = el('button', cls, label)
733
+ b.type = 'button'
734
+ b.onclick = onClick
735
+ return b
736
+ }
737
+
738
+ let inspected = ''
739
+
740
+ function renderInspector() {
741
+ const box = $('inspector')
742
+ // A rebuild redraws the panel; keep the field being typed in (and what is typed) when it
743
+ // still shows the same item.
744
+ const key = selected ? `${selected.kind}:${selected.key}` : ''
745
+ const fields = () => [...box.querySelectorAll('input, select')]
746
+ const active = key === inspected ? fields().indexOf(document.activeElement) : -1
747
+ const typed = active >= 0 ? document.activeElement.value : null
748
+ inspected = key
749
+ const view = selected && data ? inspect(selected.kind, String(selected.key)) : null
750
+ if (!view) {
751
+ box.replaceChildren(el('p', 'empty', 'Click an item on the timeline.'))
752
+ return
753
+ }
754
+ const kind = el('span', 'kind', view.label)
755
+ kind.style.background = view.color
756
+ const dl = el('dl')
757
+ for (const [k, v] of view.rows) {
758
+ if (v === null || v === undefined || v === '') continue
759
+ dl.append(el('dt', '', k), typeof v === 'string' ? el('dd', '', v) : wrap('dd', v))
760
+ }
761
+ const actions = el('div', 'actions')
762
+ actions.append(...(view.actions ?? []))
763
+ box.replaceChildren(kind, dl, ...(view.problems ?? []).map((p) => el('p', 'problem', p)), ...(view.note ? [el('p', 'note', view.note)] : []), actions)
764
+ if (focusText) {
765
+ focusText = false
766
+ const input = box.querySelector('input[type=text]')
767
+ input?.focus()
768
+ input?.select()
769
+ } else if (active >= 0) {
770
+ const field = fields()[active]
771
+ if (field) {
772
+ field.value = typed
773
+ field.focus()
774
+ }
775
+ }
776
+ }
777
+
778
+ function wrap(tag, child) {
779
+ const node = el(tag)
780
+ node.append(child)
781
+ return node
782
+ }
783
+
784
+ function inspect(kind, k) {
785
+ const spec = data.spec
786
+ if (kind === 'section') {
787
+ const s = data.sections.find((x) => x.slot === k)
788
+ if (!s) return null
789
+ const base = [['from', `${fmt(s.start)}s`], ['to', `${fmt(s.end)}s`], ['length', `${fmt(s.end - s.start)}s`]]
790
+ const pickSection = (slot, value) => dropdown(choices[slot], value, (v) => commit(`${slot}: ${v}`, (sp) => { sp.sections = { ...sp.sections, [slot]: v } }))
791
+ if (s.slot === 'intro') {
792
+ return { label: 'intro', color: 'var(--intro)', rows: [
793
+ ['section', pickSection('intro', s.name)],
794
+ ['title', textInput(spec.title, (v) => v ? commit('Title', (sp) => { sp.title = v }) : (toast('The title cannot be empty', true), renderInspector()))],
795
+ ['subtitle', textInput(spec.subtitle, (v) => commit('Subtitle', (sp) => { if (v) sp.subtitle = v; else delete sp.subtitle }), 'none')],
796
+ ...base, ['', s.detail],
797
+ ] }
798
+ }
799
+ if (s.slot === 'recording') {
800
+ const { start, end, duration } = data.media
801
+ const setEdge = (edge, v) => commit(`Trim ${edge}`, (sp) => {
802
+ const next = v === undefined ? undefined : clamp(v, edge === 'start' ? 0 : start + 1, edge === 'start' ? end - 1 : duration)
803
+ sp.trim = { ...sp.trim, [edge]: next === undefined ? undefined : movedTrim(sp.trim?.[edge], next) }
804
+ if (next === undefined || (edge === 'start' && next === 0) || (edge === 'end' && next >= duration - 0.01)) delete sp.trim[edge]
805
+ })
806
+ return { label: 'recording', color: 'var(--recording)', note: 'Drag the recording’s edges on the timeline to trim. A trim tied to a marker or click follows the footage when you re-record.', rows: [
807
+ ['start', numberInput(start, (v) => setEdge('start', v), { min: 0, max: end - 1 })],
808
+ ['', describeTrim(spec.trim?.start, 'start')],
809
+ ['end', numberInput(end, (v) => setEdge('end', v), { min: start + 1, max: duration })],
810
+ ['', describeTrim(spec.trim?.end, 'end')],
811
+ ['uses', `${fmt(start)}–${fmt(end)}s of ${fmt(duration)}s`],
812
+ ...base,
813
+ ], actions: [
814
+ ...(spec.trim?.start !== 'auto' ? [button('Auto start', () => commit('Trim start: auto', (sp) => { sp.trim = { ...sp.trim, start: 'auto' } }))] : []),
815
+ ...(spec.trim?.end !== undefined ? [button('Full length', () => commit('Trim end: the end', (sp) => { sp.trim = { ...sp.trim }; delete sp.trim.end }))] : []),
816
+ ] }
817
+ }
818
+ if (s.slot === 'recap') {
819
+ return { label: 'recap', color: 'var(--recap)', rows: [
820
+ ['section', pickSection('recap', s.name)],
821
+ ['title', textInput(spec.recapTitle, (v) => commit('Recap title', (sp) => { if (v) sp.recapTitle = v; else delete sp.recapTitle }), 'from demo.config.json')],
822
+ ...base, ['', s.detail],
823
+ ] }
824
+ }
825
+ return { label: 'outro', color: 'var(--outro)', rows: [['section', pickSection('outro', s.name)], ...base] }
826
+ }
827
+
828
+ if (kind === 'callout') {
829
+ const c = data.callouts.find((x) => String(x.source) === k)
830
+ const src = spec.callouts[Number(k)]
831
+ if (!src) return null
832
+ if (!c) {
833
+ return { label: 'callout (dropped)', color: 'var(--bad)', note: 'This callout is outside the trim window, so the video leaves it out.', rows: [['text', src.text]], actions: [button('Delete', removeSelected, 'danger')] }
834
+ }
835
+ const rows = [
836
+ ['text', textInput(src.text, (v) => v ? commit(`Callout ${c.n} text`, (sp) => { sp.callouts[c.source].text = v }) : (toast('A callout needs text — delete it instead', true), renderInspector()))],
837
+ ]
838
+ if (spec.voice) {
839
+ rows.push(['say', textInput(typeof src.say === 'string' ? src.say : '', (v) => commit(`Callout ${c.n} voice-over`, (sp) => { if (v) sp.callouts[c.source].say = v; else delete sp.callouts[c.source].say }), src.say === false ? 'silent' : 'the text, spoken')])
840
+ }
841
+ if (src.marker !== undefined) {
842
+ rows.push(['marker', src.marker])
843
+ rows.push(['offset', numberInput(src.offset, (v) => commit(`Callout ${c.n} offset`, (sp) => { if (v) sp.callouts[c.source].offset = v; else delete sp.callouts[c.source].offset }), { step: 0.1, placeholder: src.anchor === 'marker' ? '0 (on the marker)' : '0 (as its step begins)' })])
844
+ } else {
845
+ rows.push(['at', numberInput(src.at, (v) => v !== undefined && commit(`Callout ${c.n} time`, (sp) => { sp.callouts[c.source].at = Math.max(0, v) }), { min: 0, placeholder: 'recording seconds' })])
846
+ }
847
+ rows.push(['length', numberInput(src.duration, (v) => commit(`Callout ${c.n} length`, (sp) => { if (v) sp.callouts[c.source].duration = Math.max(0.5, v); else delete sp.callouts[c.source].duration }), { min: 0.5, placeholder: `auto (${fmt(c.end - c.at)}s)` })])
848
+ if (data.handOffs.length || src.group) {
849
+ rows.push(['role', textInput(src.group, (v) => commit(`Callout ${c.n} role`, (sp) => { if (v) sp.callouts[c.source].group = v; else delete sp.callouts[c.source].group }), c.group ?? 'from the hand-off cards')])
850
+ }
851
+ rows.push(['on screen', `${fmt(c.at)}–${fmt(c.end)}s`])
852
+ return { label: `callout ${c.n}`, color: 'var(--callout)', rows, actions: [
853
+ ...(src.offset ? [button(src.anchor === 'marker' ? 'Snap to marker' : 'Snap to its step', () => commit(`Callout ${c.n} back ${src.anchor === 'marker' ? 'on its marker' : 'on its step'}`, (sp) => { delete sp.callouts[c.source].offset }))] : []),
854
+ ...(src.marker !== undefined
855
+ ? [src.anchor === 'marker'
856
+ ? button('Start with its step', () => commit(`Callout ${c.n} starts with its step`, (sp) => { delete sp.callouts[c.source].anchor; delete sp.callouts[c.source].offset }))
857
+ : button('Start on the marker', () => commit(`Callout ${c.n} starts on its marker`, (sp) => { sp.callouts[c.source].anchor = 'marker'; delete sp.callouts[c.source].offset }))]
858
+ : []),
859
+ button('Delete', removeSelected, 'danger'),
860
+ ] }
861
+ }
862
+
863
+ if (kind === 'zoom') {
864
+ const z = data.zooms.find((x) => String(x.n) === k)
865
+ const src = spec.zooms?.[Number(k) - 1]
866
+ if (!z || !src) return null
867
+ const set = (label, key, v) => commit(`Zoom ${z.n} ${label}`, (sp) => { if (v === undefined) delete sp.zooms[z.n - 1][key]; else sp.zooms[z.n - 1][key] = v })
868
+ const rows = [['scale', numberInput(src.scale, (v) => v && set('scale', 'scale', clamp(v, 1.1, 4)), { min: 1.1, max: 4, step: 0.1 })]]
869
+ if (src.clicks) {
870
+ const numbers = data.clicks.map((c) => String(c.n))
871
+ const [a, b = a] = src.clicks
872
+ const setClicks = (first, last) => set('clicks', 'clicks', first === last ? [first] : [first, last])
873
+ rows.push(['first click', dropdown(numbers, String(a), (v) => setClicks(Number(v), Math.max(Number(v), b)))])
874
+ rows.push(['last click', dropdown(numbers, String(b), (v) => setClicks(Math.min(a, Number(v)), Number(v)))])
875
+ } else {
876
+ rows.push(['at', numberInput(src.at, (v) => v !== undefined && set('time', 'at', Math.max(0, v)), { min: 0, placeholder: 'recording seconds' })])
877
+ rows.push(['length', numberInput(src.duration, (v) => v && set('length', 'duration', Math.max(1, v)), { min: 1 })])
878
+ }
879
+ if (data.cursor.state === 'layer' || src.follow) {
880
+ rows.push(['follow cursor', dropdown(['off', 'on'], src.follow ? 'on' : 'off', (v) => set('follow', 'follow', v === 'on' ? true : undefined))])
881
+ }
882
+ rows.push(['focus x', numberInput(src.x, (v) => set('focus', 'x', v === undefined ? undefined : clamp(v, 0, 1)), { min: 0, max: 1, step: 0.01, placeholder: `auto (${z.x})` })])
883
+ rows.push(['focus y', numberInput(src.y, (v) => set('focus', 'y', v === undefined ? undefined : clamp(v, 0, 1)), { min: 0, max: 1, step: 0.01, placeholder: `auto (${z.y})` })])
884
+ rows.push(['ease in', numberInput(src.in, (v) => set('ease', 'in', v === undefined ? undefined : Math.max(0.4, v)), { min: 0.4, placeholder: `${z.in}s` })])
885
+ rows.push(['ease out', numberInput(src.out, (v) => set('ease', 'out', v === undefined ? undefined : Math.max(0.4, v)), { min: 0.4, placeholder: `${z.out}s` })])
886
+ rows.push(['on screen', `${fmt(z.at)}–${fmt(z.end)}s`])
887
+ return { label: `zoom ${z.n}`, color: 'var(--zoom)', problems: z.problems, rows, actions: [button('Delete', removeSelected, 'danger')],
888
+ note: src.clicks ? `Timed from its clicks: the zoom rides along with the cursor${src.follow ? ' and pans to keep it in view' : ''}.` : 'Manual zoom: drag it on the timeline.' }
889
+ }
890
+
891
+ if (kind === 'handoff') {
892
+ const h = data.handOffs[Number(k) - 1]
893
+ return h && { label: 'hand-off', color: 'var(--handoff)', note: 'Hand-off cards come from the recording (demo.handOff in the scenario).', rows: [
894
+ ['title', h.title], ['subtitle', h.subtitle], ['roles', h.from ? `${h.from} → ${h.to}` : ''],
895
+ ['belt out', `${fmt(h.at - h.belt)}s`], ['card in', `${fmt(h.at)}s`], ['holds to', `${fmt(h.end)}s`],
896
+ ] }
897
+ }
898
+
899
+ if (kind === 'click') {
900
+ const c = data.clicks.find((x) => String(x.n) === k)
901
+ if (!c) return null
902
+ const zoomed = (spec.zooms ?? []).findIndex((z) => z.clicks && c.n >= z.clicks[0] && c.n <= (z.clicks[1] ?? z.clicks[0]))
903
+ return { label: `${c.kind} #${c.n}`, color: 'var(--click)', rows: [
904
+ ['glide from', `${fmt(c.glide)}s`], [c.kind, `${fmt(c.at)}s`], ['typing to', c.kind === 'type' ? `${fmt(c.until)}s` : ''],
905
+ ['zoom', zoomed >= 0 ? `zoom ${zoomed + 1}` : 'none'],
906
+ ], actions: [
907
+ zoomed >= 0
908
+ ? button(`Select zoom ${zoomed + 1}`, () => select('zoom', zoomed + 1, true))
909
+ : button('Zoom on this click', () => {
910
+ selected = { kind: 'zoom', key: (spec.zooms?.length ?? 0) + 1 }
911
+ commit(`Zoom on ${c.kind} #${c.n}`, (sp) => { sp.zooms = [...(sp.zooms ?? []), { clicks: [c.n], scale: 1.7 }] })
912
+ }),
913
+ button('Callout here', () => addCalloutAt(c.glide)),
914
+ ] }
915
+ }
916
+
917
+ if (kind === 'marker') {
918
+ const m = data.markers[Number(k) - 1]
919
+ if (!m) return null
920
+ const source = spec.callouts.findIndex((c) => c.marker === m.label)
921
+ return { label: 'marker', color: 'var(--marker)', rows: [
922
+ ['label', m.label], ['at', `${fmt(m.at)}s`], ['recording', `${fmt(m.recordingAt)}s`], ['callout', source >= 0 ? 'yes' : 'none uses it'],
923
+ ], actions: [
924
+ source >= 0
925
+ ? button('Select its callout', () => select('callout', source, true))
926
+ : button('Add a callout', () => {
927
+ selected = { kind: 'callout', key: spec.callouts.length }
928
+ focusText = true
929
+ commit(`Callout on "${m.label}"`, (sp) => sp.callouts.push({ marker: m.label, text: m.label }))
930
+ }),
931
+ ] }
932
+ }
933
+
934
+ if (kind === 'cursor') {
935
+ const c = data.cursor
936
+ if (c.state === 'filmed') {
937
+ return { label: 'cursor', color: 'var(--marker)', note: 'This recording has the cursor filmed in. Re-record with record.cursor "layer" (the default) to size it, restyle it or hide it here.', rows: [] }
938
+ }
939
+ const set = (label, mutate) => commit(label, (sp) => { const cur = sp.cursor && typeof sp.cursor === 'object' ? sp.cursor : {}; mutate(cur); sp.cursor = cur; if (!Object.keys(cur).length) delete sp.cursor })
940
+ if (c.state === 'hidden') {
941
+ return { label: 'cursor', color: 'var(--marker)', note: 'Hidden: video.json has "cursor": false.', rows: [], actions: [button('Show the cursor', () => commit('Cursor shown', (sp) => { delete sp.cursor }))] }
942
+ }
943
+ return { label: 'cursor', color: 'var(--click)', note: 'Drawn by the video from the recorder\u2019s log: it keeps its size during zooms.', rows: [
944
+ ['size', numberInput(spec.cursor?.size, (v) => set('Cursor size', (cur) => { if (v === undefined || v === 44) delete cur.size; else cur.size = clamp(v, 16, 120) }), { min: 16, max: 120, step: 2, placeholder: '44 (classic)' })],
945
+ ['ripple', dropdown(['on', 'off'], c.ripple ? 'on' : 'off', (v) => set(`Ripple ${v}`, (cur) => { if (v === 'on') delete cur.ripple; else cur.ripple = false }))],
946
+ ['idle fade', numberInput(spec.cursor?.idle, (v) => set('Cursor idle fade', (cur) => { if (!v) delete cur.idle; else cur.idle = clamp(v, 0.5, 30) }), { min: 0, max: 30, step: 0.5, placeholder: 'never (s of stillness)' })],
947
+ ['presses', String(c.presses.length)],
948
+ ], actions: [button('Hide the cursor', () => commit('Cursor hidden', (sp) => { sp.cursor = false }), 'danger')] }
949
+ }
950
+
951
+ if (kind === 'audio') {
952
+ const a = data.audio[k]
953
+ return a && { label: k, color: 'var(--audio)', note: k === 'music' ? 'Music: demo.config.json music.file, or "music" in video.json.' : 'Narration: the recording’s own audio track.', rows: [['from', `${fmt(a.start)}s`], ['to', `${fmt(a.end)}s`]] }
954
+ }
955
+ return null
956
+ }
957
+
958
+ // ── status, keys ───────────────────────────────────────────────────
959
+ function setStatus(text, cls) {
960
+ $('status').textContent = text
961
+ $('status').className = `status ${cls}`
962
+ }
963
+
964
+ function showError(error) {
965
+ $('banner').textContent = error ? `Build failed — showing the last good build.\n${error}` : ''
966
+ $('banner').classList.toggle('show', !!error)
967
+ setStatus(error ? 'build failed' : 'live', error ? 'error' : '')
968
+ }
969
+
970
+ let toastTimer
971
+ function toast(text, bad = false) {
972
+ const node = $('toast')
973
+ node.textContent = text
974
+ node.className = `toast show${bad ? ' bad' : ''}`
975
+ clearTimeout(toastTimer)
976
+ toastTimer = setTimeout(() => { node.className = 'toast' }, bad ? 8000 : 3500)
977
+ }
978
+
979
+ /** Every start/end on the timeline, for [ and ]. */
980
+ function edges() {
981
+ const all = [
982
+ 0, data.total,
983
+ ...data.sections.flatMap((s) => [s.start, s.end]),
984
+ ...data.callouts.flatMap((c) => [c.at, c.end]),
985
+ ...data.zooms.flatMap((z) => [z.at, z.at + z.in, z.end - z.out, z.end]),
986
+ ...data.handOffs.flatMap((h) => [h.at - h.belt, h.at, h.end]),
987
+ ...data.clicks.map((c) => c.at),
988
+ ...data.markers.map((m) => m.at),
989
+ ]
990
+ return [...new Set(all.map((t) => Math.round(t * 1000) / 1000))].sort((a, b) => a - b)
991
+ }
992
+
993
+ document.addEventListener('keydown', (e) => {
994
+ if (!data || (e.target instanceof Element && e.target.closest('input, textarea, select'))) return
995
+ const mod = e.metaKey || e.ctrlKey
996
+ if (mod && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); return }
997
+ if (mod && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); return }
998
+ if (mod) return
999
+ const big = e.shiftKey ? 1 : 1 / FPS
1000
+ switch (e.key) {
1001
+ case ' ': e.preventDefault(); togglePlay(); break
1002
+ case 'ArrowLeft': e.preventDefault(); seek(time - big); break
1003
+ case 'ArrowRight': e.preventDefault(); seek(time + big); break
1004
+ case 'Home': seek(0); break
1005
+ case 'End': seek(lastFrame()); break
1006
+ case '[': { const prev = edges().filter((t) => t < time - 1e-3).at(-1); if (prev !== undefined) seek(prev); break }
1007
+ case ']': { const next = edges().find((t) => t > time + 1e-3); if (next !== undefined) seek(next); break }
1008
+ case '+': case '=': $('zoom-in').click(); break
1009
+ case '-': $('zoom-out').click(); break
1010
+ case '0': setZoom(null); break
1011
+ case 'm': case 'M': $('mute').click(); break
1012
+ case 'c': case 'C': addCalloutAt(time); break
1013
+ case 'Delete': case 'Backspace': e.preventDefault(); removeSelected(); break
1014
+ case 'Escape': selected = null; renderTimeline(); renderInspector(); break
1015
+ }
1016
+ })
1017
+
1018
+ $('play').onclick = togglePlay
1019
+ $('start').onclick = () => seek(0)
1020
+ $('end').onclick = () => seek(lastFrame())
1021
+ $('back').onclick = () => seek(time - 1 / FPS)
1022
+ $('fwd').onclick = () => seek(time + 1 / FPS)
1023
+ $('undo').onclick = undo
1024
+ $('redo').onclick = redo
1025
+ $('add-callout').onclick = () => addCalloutAt(time)
1026
+ $('mute').onclick = () => {
1027
+ player.muted = !player.muted
1028
+ $('mute').textContent = player.muted ? '🔇' : '🔊'
1029
+ }
1030
+
1031
+ load().then(connect)