reelson 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/LICENSE +21 -0
- package/NOTICE.md +15 -0
- package/README.md +180 -0
- package/bin/reelson.js +649 -0
- package/bin/reelson.ts +676 -0
- package/bin/run.js +5 -0
- package/demo.config.example.json +51 -0
- package/docs/demo.webp +0 -0
- package/docs/prompting.md +72 -0
- package/docs/style-guide.md +117 -0
- package/examples/demo.config.json +18 -0
- package/examples/todo-add-item/scenario.ts +45 -0
- package/examples/todo-add-item/video.json +35 -0
- package/music/README.md +7 -0
- package/package.json +68 -0
- package/skills/reelson-compose/SKILL.md +226 -0
- package/skills/reelson-compose/schemas/video.schema.json +172 -0
- package/skills/reelson-compose/scripts/build.js +424 -0
- package/skills/reelson-compose/scripts/build.ts +532 -0
- package/skills/reelson-compose/scripts/captions.js +24 -0
- package/skills/reelson-compose/scripts/captions.ts +40 -0
- package/skills/reelson-compose/scripts/check.js +80 -0
- package/skills/reelson-compose/scripts/check.ts +93 -0
- package/skills/reelson-compose/scripts/composition.js +163 -0
- package/skills/reelson-compose/scripts/composition.ts +241 -0
- package/skills/reelson-compose/scripts/hyperframes.js +138 -0
- package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
- package/skills/reelson-compose/scripts/portrait.js +150 -0
- package/skills/reelson-compose/scripts/portrait.ts +218 -0
- package/skills/reelson-compose/scripts/project.js +238 -0
- package/skills/reelson-compose/scripts/project.ts +324 -0
- package/skills/reelson-compose/scripts/studio.js +330 -0
- package/skills/reelson-compose/scripts/studio.ts +394 -0
- package/skills/reelson-compose/scripts/timeline.js +271 -0
- package/skills/reelson-compose/scripts/timeline.ts +514 -0
- package/skills/reelson-compose/scripts/tts.js +303 -0
- package/skills/reelson-compose/scripts/tts.ts +371 -0
- package/skills/reelson-compose/scripts/verify.js +190 -0
- package/skills/reelson-compose/scripts/verify.ts +209 -0
- package/skills/reelson-compose/scripts/voice.js +178 -0
- package/skills/reelson-compose/scripts/voice.ts +224 -0
- package/skills/reelson-compose/scripts/zooms.js +259 -0
- package/skills/reelson-compose/scripts/zooms.ts +333 -0
- package/skills/reelson-compose/sections/README.md +5 -0
- package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
- package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
- package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
- package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
- package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
- package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
- package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
- package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
- package/skills/reelson-compose/sections/intro/split/section.css +30 -0
- package/skills/reelson-compose/sections/intro/split/section.html +12 -0
- package/skills/reelson-compose/sections/intro/split/section.js +19 -0
- package/skills/reelson-compose/sections/intro/split/section.json +9 -0
- package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
- package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
- package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
- package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
- package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
- package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
- package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
- package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
- package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
- package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
- package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
- package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
- package/skills/reelson-compose/studio/index.html +81 -0
- package/skills/reelson-compose/studio/studio.css +142 -0
- package/skills/reelson-compose/studio/studio.js +1031 -0
- package/skills/reelson-compose/templates/README.md +193 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/stage.html +447 -0
- package/skills/reelson-compose/templates/classic/template.json +5 -0
- package/skills/reelson-record/SKILL.md +220 -0
- package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
- package/skills/reelson-record/scripts/capture.js +142 -0
- package/skills/reelson-record/scripts/capture.ts +190 -0
- package/skills/reelson-record/scripts/config.js +148 -0
- package/skills/reelson-record/scripts/config.ts +264 -0
- package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
- package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
- package/skills/reelson-record/scripts/doctor.js +128 -0
- package/skills/reelson-record/scripts/doctor.ts +147 -0
- package/skills/reelson-record/scripts/languages.js +29 -0
- package/skills/reelson-record/scripts/languages.ts +41 -0
- package/skills/reelson-record/scripts/record.js +365 -0
- package/skills/reelson-record/scripts/record.ts +451 -0
- package/skills/reelson-record/scripts/scenario.js +356 -0
- package/skills/reelson-record/scripts/scenario.ts +570 -0
- package/skills/reelson-record/scripts/validate.js +143 -0
- package/skills/reelson-record/scripts/validate.ts +179 -0
|
@@ -0,0 +1,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)
|