@devbycrux/editor 0.1.0 → 0.3.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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/video-adapter-contract.test.ts +89 -0
  3. package/src/carousel/CarouselEditor.tsx +28 -16
  4. package/src/carousel/SlideCanvas.tsx +12 -0
  5. package/src/carousel/SlidePropertyPanel.tsx +40 -2
  6. package/src/carousel/__tests__/CarouselEditor.test.tsx +52 -0
  7. package/src/index.ts +23 -1
  8. package/src/types.ts +161 -0
  9. package/src/video/RenderModal.tsx +252 -0
  10. package/src/video/VersionPanel.tsx +83 -0
  11. package/src/video/VideoEditor.tsx +508 -0
  12. package/src/video/__tests__/VideoEditor.test.tsx +213 -0
  13. package/src/video/__tests__/captionRepair.test.ts +134 -0
  14. package/src/video/__tests__/cuts.test.ts +198 -0
  15. package/src/video/captionRepair.ts +41 -0
  16. package/src/video/cuts.ts +369 -0
  17. package/src/video/design-canvas.ts +11 -0
  18. package/src/video/preview/CaptionPreview.tsx +83 -0
  19. package/src/video/preview/CarouselPreview.tsx +35 -0
  20. package/src/video/preview/OverlayItemsLayer.tsx +603 -0
  21. package/src/video/preview/PreviewPlayer.tsx +178 -0
  22. package/src/video/preview/useDragOverlay.ts +167 -0
  23. package/src/video/preview/useVideoPlayback.ts +761 -0
  24. package/src/video/timeline/AudioTrackRow.tsx +406 -0
  25. package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
  26. package/src/video/timeline/EditableSegment.tsx +30 -0
  27. package/src/video/timeline/Scrubber.tsx +184 -0
  28. package/src/video/timeline/Timeline.tsx +375 -0
  29. package/src/video/timeline/TimelineContext.ts +25 -0
  30. package/src/video/timeline/TranscriptModal.tsx +63 -0
  31. package/src/video/timeline/TranscriptPanel.tsx +86 -0
  32. package/src/video/timeline/VisualTrackRow.tsx +293 -0
  33. package/src/video/timeline/makeCaptionEdit.ts +32 -0
  34. package/src/video/timeline/multiSelectOps.ts +157 -0
  35. package/src/video/timeline/useItemDragDrop.ts +190 -0
  36. package/src/video/timeline/useTimelineZoom.ts +48 -0
  37. package/src/video/timeline/utils.ts +17 -0
@@ -0,0 +1,761 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { EditorProject as Project } from '../../schema'
3
+
4
+ // Typed extension for the shared AudioContext cached on window
5
+ interface MontajWindow {
6
+ __montajSharedCtx?: AudioContext
7
+ }
8
+
9
+ // Typed extension for video elements that cache their GainNode
10
+ interface MontajVideoElement extends HTMLVideoElement {
11
+ __montajGain?: GainNode
12
+ }
13
+
14
+ /**
15
+ * Resolve the file path the browser should load for previewing this clip.
16
+ *
17
+ * For bg-removed clips, prefer `nobg_preview_src` (small WebM with alpha,
18
+ * browser-friendly) over `src` (the original raw file with bg present), so
19
+ * the preview pane shows what the final render will composite — not the
20
+ * un-cut-out source. Mirrors the same pattern used by
21
+ * `OverlayItemsLayer.tsx:406` for tracks[1+] items; this generalises it to
22
+ * the main-track preview so bg-removed clips placed on tracks[0] don't show
23
+ * the wrong layer in preview while rendering correctly. Falls back to `src`
24
+ * when `nobg_preview_src` is absent (most clips).
25
+ *
26
+ * Note: `nobg_src` is the ProRes 4444 render-only artifact and is NEVER
27
+ * loaded into a `<video>` element — browsers can't decode ProRes.
28
+ */
29
+ function playbackSrcFor(clip: { src?: string; nobg_preview_src?: string }): string {
30
+ return clip.nobg_preview_src ?? clip.src ?? ''
31
+ }
32
+
33
+ export function useVideoPlayback(
34
+ project: Project,
35
+ currentTime: number,
36
+ onTimeUpdate: (t: number) => void,
37
+ fileUrl: (path: string) => string,
38
+ ) {
39
+ // Double-buffer video elements for seamless clip transitions
40
+ const video0Ref = useRef<HTMLVideoElement>(null)
41
+ const video1Ref = useRef<HTMLVideoElement>(null)
42
+ const activeSlotRef = useRef<0 | 1>(0)
43
+ const [activeSlot, setActiveSlot] = useState<0 | 1>(0)
44
+ // Tracks what src is preloaded in the inactive slot (relative URL)
45
+ const preloadSrcRef = useRef('')
46
+
47
+ const activeIdxRef = useRef(0)
48
+ const seekingRef = useRef(false)
49
+ const lastTimeRef = useRef(currentTime)
50
+ const loopOffsetRef = useRef(0)
51
+ const rafRef = useRef<number | null>(null)
52
+ const rafLastMs = useRef<number | null>(null)
53
+ const audioRefsMap = useRef<Map<string, HTMLAudioElement>>(new Map())
54
+ const audioSrcMap = useRef<Map<string, string>>(new Map())
55
+ // Web Audio API: GainNode per audio track allows volume > 1.0 (amplification).
56
+ // All gain nodes route through the shared window AudioContext (see getSharedAudioContext).
57
+ const gainNodesMap = useRef<Map<string, GainNode>>(new Map())
58
+ // Video slot GainNodes — route video element audio through Web Audio API for amplification (volume > 1.0)
59
+ const videoGainRef = useRef<[GainNode | null, GainNode | null]>([null, null])
60
+ const [isPlaying, setIsPlaying] = useState(false)
61
+ const isPlayingRef = useRef(false)
62
+ // Keep ref in sync so effects with narrow deps can read current playing state
63
+ useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying])
64
+ const [showVideo, setShowVideo] = useState(true)
65
+
66
+ // Gap clock — advances time through lift-style gaps between primary clips
67
+ const gapRAFRef = useRef<number | null>(null)
68
+ const inGapRef = useRef(false)
69
+ const gapWallRef = useRef(0)
70
+ const gapFromRef = useRef(0)
71
+ const gapTargetRef = useRef(0)
72
+ const gapNextIdxRef = useRef(0)
73
+
74
+ // Keep fileUrl in a ref so callbacks that captured it early don't go stale
75
+ const fileUrlRef = useRef(fileUrl)
76
+ useEffect(() => { fileUrlRef.current = fileUrl }, [fileUrl])
77
+
78
+ function getActiveVideo() { return activeSlotRef.current === 0 ? video0Ref.current : video1Ref.current }
79
+ function getInactiveVideo() { return activeSlotRef.current === 0 ? video1Ref.current : video0Ref.current }
80
+
81
+ // ── Video timeline ─────────────────────────────────────────────────────────
82
+ // Only video items drive the double-buffer player; non-video items (images, etc.)
83
+ // in tracks[0] are exposed separately for the preview to render as a background layer.
84
+ const clips = useMemo(() => (project.tracks?.[0] ?? []).filter(c => c.type === 'video').sort((a, b) => a.start - b.start), [project])
85
+ const tracks0NonVideo = useMemo(() => (project.tracks?.[0] ?? []).filter(c => c.type !== 'video'), [project])
86
+ const overlayTracks = useMemo(() => project.tracks?.slice(1) ?? [], [project])
87
+
88
+ // Canvas project: no primary video in tracks[0] (e.g. image-only background track)
89
+ const isCanvasProject = clips.length === 0
90
+
91
+ // Total project end — includes opaque overlays and audio that extend beyond video clips.
92
+ // Used to decide whether to keep playing after the last video clip ends.
93
+ const projectEnd = useMemo(() => {
94
+ const videoEnd = clips.length > 0 ? clips[clips.length - 1].end : 0
95
+ const overlayEnd = overlayTracks.flat().reduce((m, i) => Math.max(m, i.end), 0)
96
+ const audioEnd = (project.audio?.tracks ?? []).reduce((m, t) => Math.max(m, t.end ?? 0), 0)
97
+ return Math.max(videoEnd, overlayEnd, audioEnd)
98
+ }, [clips, overlayTracks, project.audio?.tracks])
99
+
100
+ // Wire a video slot through a Web Audio GainNode (once per element — createMediaElementSource
101
+ // can only be called once). After this, video.volume/muted have no audible effect; all volume
102
+ // control goes through the GainNode, which supports values > 1.0 for amplification.
103
+ //
104
+ // CRITICAL: ALL slots AND ALL audio tracks share the SAME AudioContext. Fresh
105
+ // AudioContexts start suspended and require a user gesture to resume. Creating
106
+ // separate contexts (one per slot, or a separate one for audio tracks) means
107
+ // those born inside a useEffect have no user-gesture activation — they stay
108
+ // suspended → silent. Worse, video frame production is gated on the wired
109
+ // AudioContext clock running, so a suspended context with a wired <video>
110
+ // produces "video plays but no frames render" after a hard refresh.
111
+ // Stash the single shared context on `window` so it survives strict-mode remounts
112
+ // and is reused across audio tracks, video slots, and re-mounts.
113
+ function getSharedAudioContext(): AudioContext {
114
+ const w = window as Window & MontajWindow
115
+ // If the cached context is closed (shouldn't happen, but defensive), recreate.
116
+ if (!w.__montajSharedCtx || w.__montajSharedCtx.state === 'closed') {
117
+ w.__montajSharedCtx = new AudioContext()
118
+ }
119
+ return w.__montajSharedCtx
120
+ }
121
+
122
+ /**
123
+ * MUST be called from inside a user-gesture handler (keydown, click, etc.).
124
+ * Browsers credit a `resume()` call as gesture-driven only when it happens
125
+ * synchronously inside a gesture-rooted call stack. Calling resume() from
126
+ * a useEffect or a setTimeout silently fails — the context stays suspended.
127
+ *
128
+ * Symptom of a suspended context with a wired <video>: video appears to
129
+ * play (paused=false, currentTime advances internally per the DOM clock)
130
+ * but no frames render and no audio plays — the entire pipeline is gated
131
+ * on the AudioContext clock running. This bites specifically after a page
132
+ * refresh, because SPA navigation carries gesture activation across pages
133
+ * but a hard reload does not.
134
+ */
135
+ function resumeAudioContextFromGesture() {
136
+ const w = window as Window & MontajWindow
137
+ const ctx: AudioContext | undefined = w.__montajSharedCtx
138
+ if (ctx && ctx.state === 'suspended') {
139
+ // Fire-and-forget; resume() returns a Promise but the gesture-credit
140
+ // happens at the synchronous call site, not when the promise resolves.
141
+ ctx.resume().catch(() => {})
142
+ }
143
+ }
144
+
145
+ function ensureVideoGain(slot: 0 | 1): GainNode | null {
146
+ if (videoGainRef.current[slot]) return videoGainRef.current[slot]
147
+ const video = slot === 0 ? video0Ref.current : video1Ref.current
148
+ if (!video) return null
149
+ // createMediaElementSource can only be called ONCE per <video> element,
150
+ // and source/gain/context must all belong to the same AudioContext.
151
+ // Cache the gain on the element so it survives React strict-mode
152
+ // double-invocation and component remounts.
153
+ const v = video as MontajVideoElement
154
+ if (!v.__montajGain) {
155
+ const ctx = getSharedAudioContext()
156
+ const source = ctx.createMediaElementSource(video)
157
+ const gain = ctx.createGain()
158
+ source.connect(gain)
159
+ gain.connect(ctx.destination)
160
+ v.__montajGain = gain
161
+ }
162
+ videoGainRef.current[slot] = v.__montajGain ?? null
163
+ return v.__montajGain ?? null
164
+ }
165
+
166
+ // Set video clip volume via GainNode (supports amplification > 1.0).
167
+ // Muted clips get gain 0; unmuted clips get the clip's volume value.
168
+ function applyClipVolume(clip: { muted?: boolean; volume?: number }) {
169
+ const slot = activeSlotRef.current
170
+ const gain = ensureVideoGain(slot)
171
+ if (gain) gain.gain.value = clip.muted ? 0 : (clip.volume ?? 1)
172
+ }
173
+
174
+ // Apply video clip volume via Web Audio GainNode (supports > 1.0 amplification)
175
+ useEffect(() => {
176
+ const idx = activeIdxRef.current
177
+ const clip = clips[idx]
178
+ if (!clip) return
179
+ applyClipVolume(clip)
180
+ }, [clips, activeSlot])
181
+
182
+ useEffect(() => {
183
+ if (!isCanvasProject) return
184
+ const captionEnd = (project.captions?.segments ?? []).reduce((m: number, s) => Math.max(m, s.end), 0)
185
+ const maxEnd = Math.max(
186
+ overlayTracks.flat().reduce((m, i) => Math.max(m, i.end), 0),
187
+ captionEnd,
188
+ )
189
+
190
+ function tick(ms: number) {
191
+ if (rafLastMs.current !== null) {
192
+ const dt = (ms - rafLastMs.current) / 1000
193
+ const next = Math.min(lastTimeRef.current + dt, maxEnd)
194
+ lastTimeRef.current = next
195
+ onTimeUpdate(next)
196
+ if (next >= maxEnd) {
197
+ setIsPlaying(false)
198
+ rafRef.current = null
199
+ rafLastMs.current = null
200
+ return
201
+ }
202
+ }
203
+ rafLastMs.current = ms
204
+ rafRef.current = requestAnimationFrame(tick)
205
+ }
206
+
207
+ if (isPlaying) {
208
+ rafRef.current = requestAnimationFrame(tick)
209
+ } else {
210
+ if (rafRef.current) cancelAnimationFrame(rafRef.current)
211
+ rafRef.current = null
212
+ rafLastMs.current = null
213
+ }
214
+
215
+ return () => {
216
+ if (rafRef.current) cancelAnimationFrame(rafRef.current)
217
+ }
218
+ }, [isPlaying, isCanvasProject, overlayTracks, project, onTimeUpdate])
219
+
220
+ // ── Multi-track audio management ───────────────────────────────────────────
221
+ // Derive unmuted tracks. The full tracks array is a new reference on every
222
+ // project spread, so we stabilize by comparing the *identity key* (id+muted+src)
223
+ // rather than array reference so we don't tear down audio elements on volume drag.
224
+ const unmutedAudioTracks = useMemo(() => {
225
+ return (project.audio?.tracks ?? []).filter(t => !t.muted && t.src)
226
+ }, [project.audio?.tracks])
227
+
228
+ // Stable key: only changes when the set of unmuted track ids or their src changes.
229
+ // Volume, start/end, inPoint/outPoint changes do NOT trigger element create/destroy.
230
+ const audioTrackIdentity = useMemo(
231
+ () => unmutedAudioTracks.map(t => `${t.id}:${t.src}`).join('|'),
232
+ [unmutedAudioTracks]
233
+ )
234
+
235
+ // Create / destroy audio elements when track set changes
236
+ useEffect(() => {
237
+ const map = audioRefsMap.current
238
+ const srcMap = audioSrcMap.current
239
+ const gains = gainNodesMap.current
240
+ const activeIds = new Set(unmutedAudioTracks.map(t => t.id))
241
+
242
+ // Remove elements for tracks that were deleted or muted
243
+ for (const [id, el] of map) {
244
+ if (!activeIds.has(id)) {
245
+ el.pause()
246
+ el.src = ''
247
+ map.delete(id)
248
+ srcMap.delete(id)
249
+ gains.delete(id)
250
+ }
251
+ }
252
+
253
+ // Create new elements for newly-added tracks, routed through GainNode
254
+ for (const track of unmutedAudioTracks) {
255
+ let el = map.get(track.id)
256
+ if (!el) {
257
+ el = new Audio()
258
+ el.preload = 'auto'
259
+ map.set(track.id, el)
260
+
261
+ // Wire through Web Audio API: element → GainNode → destination
262
+ // This allows volume > 1.0 for amplification in preview.
263
+ // createMediaElementSource can only be called once per element.
264
+ const ctx = getSharedAudioContext()
265
+ const source = ctx.createMediaElementSource(el)
266
+ const gain = ctx.createGain()
267
+ gain.gain.value = track.volume ?? 1
268
+ source.connect(gain)
269
+ gain.connect(ctx.destination)
270
+ gains.set(track.id, gain)
271
+ }
272
+ if (srcMap.get(track.id) !== track.src) {
273
+ el.src = fileUrlRef.current(track.src!)
274
+ srcMap.set(track.id, track.src!)
275
+ }
276
+ // Volume is controlled via GainNode, not el.volume
277
+ const gain = gains.get(track.id)
278
+ if (gain) gain.gain.value = track.volume ?? 1
279
+ }
280
+ // Keyed on identity string — only fires when tracks are added/removed/src changes
281
+ }, [audioTrackIdentity])
282
+
283
+ // Update volume in-place on every render via GainNode — cheap, no element churn
284
+ useEffect(() => {
285
+ for (const track of unmutedAudioTracks) {
286
+ const gain = gainNodesMap.current.get(track.id)
287
+ if (gain) gain.gain.value = track.volume ?? 1
288
+ }
289
+ }, [unmutedAudioTracks])
290
+
291
+ // Cleanup on unmount only. The shared AudioContext (window.__montajSharedCtx)
292
+ // is intentionally NOT closed — it's window-scoped and reused across remounts
293
+ // and across project pages. Audio elements are paused and dropped; their
294
+ // MediaElementSources and GainNodes are GC'd along with them.
295
+ useEffect(() => {
296
+ const map = audioRefsMap.current
297
+ const srcMap = audioSrcMap.current
298
+ const gains = gainNodesMap.current
299
+ const vidGains = videoGainRef.current
300
+ return () => {
301
+ for (const el of map.values()) { el.pause(); el.src = '' }
302
+ map.clear()
303
+ srcMap.clear()
304
+ gains.clear()
305
+ vidGains[0] = null; vidGains[1] = null
306
+ }
307
+ }, [])
308
+
309
+ // syncAudioTracks reads from refs + unmutedAudioTracks for window math.
310
+ // We use a ref to avoid recreating the callback on every track property change.
311
+ const unmutedAudioTracksRef = useRef(unmutedAudioTracks)
312
+ useEffect(() => { unmutedAudioTracksRef.current = unmutedAudioTracks }, [unmutedAudioTracks])
313
+
314
+ const syncAudioTracks = useCallback(function syncAudioTracks(playhead: number, playing: boolean) {
315
+ for (const track of unmutedAudioTracksRef.current) {
316
+ const el = audioRefsMap.current.get(track.id)
317
+ if (!el) continue
318
+
319
+ const inPt = track.inPoint ?? 0
320
+ const trackTime = (playhead - track.start) + inPt
321
+ // Derive outPoint from the timeline span — the stored outPoint can drift
322
+ // out of sync with start/end during trim operations, causing premature silence.
323
+ // The HTML audio element naturally stops at end-of-file, so sourceDuration
324
+ // acts as an implicit ceiling without needing an explicit check here.
325
+ const outPoint = inPt + (track.end - track.start)
326
+ const outsideWindow =
327
+ playhead < track.start ||
328
+ playhead >= track.end ||
329
+ trackTime < 0 ||
330
+ trackTime >= outPoint
331
+
332
+ if (outsideWindow) {
333
+ if (!el.paused) el.pause()
334
+ continue
335
+ }
336
+
337
+ if (Math.abs(el.currentTime - trackTime) > 0.3) {
338
+ el.currentTime = Math.max(0, trackTime)
339
+ }
340
+
341
+ if (playing && el.paused) el.play().catch(() => {})
342
+ if (!playing && !el.paused) el.pause()
343
+
344
+ // Apply fade-in / fade-out gain envelope
345
+ const gain = gainNodesMap.current.get(track.id)
346
+ if (gain) {
347
+ const fadeIn = track.fadeIn ?? 0
348
+ const fadeOut = track.fadeOut ?? 0
349
+ const baseVol = track.volume ?? 1
350
+ const elapsed = playhead - track.start
351
+ const remaining = track.end - playhead
352
+
353
+ let fadeMul = 1
354
+ if (fadeIn > 0 && elapsed < fadeIn) {
355
+ fadeMul = elapsed / fadeIn
356
+ }
357
+ if (fadeOut > 0 && remaining < fadeOut) {
358
+ fadeMul = Math.min(fadeMul, remaining / fadeOut)
359
+ }
360
+ gain.gain.value = baseVol * Math.max(0, fadeMul)
361
+ }
362
+ }
363
+ }, [])
364
+
365
+ // Keep background audio in sync with playback (canvas and video projects)
366
+ useEffect(() => {
367
+ // Skip toggling during a seek — prevents audio stutter from brief pause/play events
368
+ if (seekingRef.current) return
369
+ syncAudioTracks(lastTimeRef.current, isPlaying)
370
+ }, [isPlaying, syncAudioTracks])
371
+
372
+ useEffect(() => {
373
+ syncAudioTracks(currentTime, isPlayingRef.current)
374
+ }, [currentTime, syncAudioTracks])
375
+
376
+ // Space = play/pause
377
+ useEffect(() => {
378
+ const onKeyDown = (e: KeyboardEvent) => {
379
+ const el = e.target as HTMLElement
380
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable) return
381
+ if (e.code === 'Space') {
382
+ e.preventDefault()
383
+ // GESTURE-ANCHORED: resume the AudioContext synchronously here. After
384
+ // a hard page refresh, the context was first created in a useEffect
385
+ // (no gesture) and stays suspended. With <video> wired through
386
+ // MediaElementSource → GainNode → ctx.destination, a suspended context
387
+ // also gates video frame production in some browsers (Safari notably).
388
+ // Resuming inside the keydown handler credits the resume() call as
389
+ // gesture-driven, unblocking both audio and frame advance.
390
+ resumeAudioContextFromGesture()
391
+ if (isCanvasProject) { setIsPlaying(prev => !prev); return }
392
+ if (inGapRef.current) {
393
+ if (gapRAFRef.current !== null) {
394
+ cancelAnimationFrame(gapRAFRef.current)
395
+ gapRAFRef.current = null
396
+ setIsPlaying(false)
397
+ } else {
398
+ gapFromRef.current = lastTimeRef.current
399
+ gapWallRef.current = performance.now()
400
+ gapRAFRef.current = requestAnimationFrame(tickGap)
401
+ setIsPlaying(true)
402
+ }
403
+ return
404
+ }
405
+ const video = getActiveVideo()
406
+ if (!video) return
407
+ if (video.paused) { video.play().catch(() => {}) } else { video.pause() }
408
+ }
409
+ }
410
+ document.addEventListener('keydown', onKeyDown)
411
+ return () => document.removeEventListener('keydown', onKeyDown)
412
+ }, [isCanvasProject])
413
+
414
+ // Track clip identity to avoid reloading when only overlays change
415
+ const clipsSourceRef = useRef('')
416
+
417
+ // Load first clip into active slot when clips change
418
+ useEffect(() => {
419
+ const video = getActiveVideo()
420
+ if (!video || !clips.length || !clips[0].src) return
421
+ // Only reload if the actual clip sources/trim points changed — not just overlay edits.
422
+ // Identity includes nobg_preview_src so a bg-removal completing mid-session
423
+ // (the field appearing on a clip whose src was already loaded) triggers a
424
+ // reload to swap the preview to the cutout version.
425
+ const identity = clips
426
+ .map(c => `${c.nobg_preview_src ?? ''}|${c.src}|${c.inPoint ?? 0}|${c.outPoint ?? ''}`)
427
+ .join(',')
428
+ if (identity === clipsSourceRef.current) return
429
+ clipsSourceRef.current = identity
430
+ activeIdxRef.current = 0
431
+ activeSlotRef.current = 0
432
+ loopOffsetRef.current = 0
433
+ setActiveSlot(0)
434
+ preloadSrcRef.current = ''
435
+ video.src = fileUrlRef.current(playbackSrcFor(clips[0]))
436
+ video.currentTime = clips[0].inPoint ?? 0
437
+ applyClipVolume(clips[0])
438
+ // Clear inactive slot
439
+ const inactive = getInactiveVideo()
440
+ if (inactive) { inactive.pause(); inactive.removeAttribute('src') }
441
+ }, [clips])
442
+
443
+ const handlePause = useCallback(() => {
444
+ // Ignore pause events while the gap clock owns playback state
445
+ if (inGapRef.current) return
446
+ setIsPlaying(false)
447
+ }, [])
448
+
449
+ const cancelGap = useCallback(() => {
450
+ if (gapRAFRef.current !== null) {
451
+ cancelAnimationFrame(gapRAFRef.current)
452
+ gapRAFRef.current = null
453
+ }
454
+ inGapRef.current = false
455
+ }, [])
456
+
457
+ const tickGap = useCallback(function tickGap() {
458
+ if (!inGapRef.current) return
459
+ const elapsed = (performance.now() - gapWallRef.current) / 1000
460
+ const t = Math.min(gapFromRef.current + elapsed, gapTargetRef.current)
461
+ lastTimeRef.current = t
462
+ onTimeUpdate(t)
463
+
464
+ if (t < gapTargetRef.current) {
465
+ gapRAFRef.current = requestAnimationFrame(tickGap)
466
+ return
467
+ }
468
+
469
+ // Gap over — transition to next clip (or end of project)
470
+ inGapRef.current = false
471
+ gapRAFRef.current = null
472
+ const ni = gapNextIdxRef.current
473
+ const nc = clips[ni]
474
+ if (!nc?.src) {
475
+ // No next clip — we were advancing through a trailing overlay/audio section.
476
+ setIsPlaying(false)
477
+ syncAudioTracks(t, false)
478
+ return
479
+ }
480
+ const ns = (1 - activeSlotRef.current) as 0 | 1
481
+ const nv = ns === 0 ? video0Ref.current : video1Ref.current
482
+ lastTimeRef.current = nc.start
483
+ onTimeUpdate(nc.start)
484
+ activeIdxRef.current = ni
485
+ if (nv) {
486
+ const src = fileUrlRef.current(playbackSrcFor(nc))
487
+ if (preloadSrcRef.current !== src) { nv.src = src; nv.currentTime = nc.inPoint ?? 0 }
488
+ const gain = ensureVideoGain(ns)
489
+ if (gain) gain.gain.value = nc.muted ? 0 : (nc.volume ?? 1)
490
+ nv.play().catch(() => {})
491
+ }
492
+ void (activeSlotRef.current === 0 ? video0Ref.current : video1Ref.current)?.pause()
493
+ activeSlotRef.current = ns
494
+ setActiveSlot(ns)
495
+ setShowVideo(true)
496
+ preloadSrcRef.current = ''
497
+ }, [clips, onTimeUpdate])
498
+
499
+ // Scrub: seek active slot when currentTime jumps externally
500
+ useEffect(() => {
501
+ if (Math.abs(currentTime - lastTimeRef.current) < 0.05) return
502
+ cancelGap()
503
+ lastTimeRef.current = currentTime
504
+ const idx = clips.findIndex(c => currentTime >= c.start && currentTime < c.end)
505
+ if (idx === -1) {
506
+ // Scrubbed into a gap or image section — hide the main video so it doesn't bleed through
507
+ setShowVideo(false)
508
+ // If currently playing, pause the active video and restart the gap clock from the new position
509
+ if (isPlayingRef.current) {
510
+ const nextIdx = clips.findIndex(c => c.start > currentTime)
511
+ if (nextIdx !== -1) {
512
+ inGapRef.current = true // set before pause so handlePause ignores the event
513
+ gapFromRef.current = currentTime
514
+ gapWallRef.current = performance.now()
515
+ gapTargetRef.current = clips[nextIdx].start
516
+ gapNextIdxRef.current = nextIdx
517
+ getActiveVideo()?.pause()
518
+ gapRAFRef.current = requestAnimationFrame(tickGap)
519
+ }
520
+ }
521
+ return
522
+ }
523
+ setShowVideo(true)
524
+ seekingRef.current = true
525
+ try {
526
+ const clipIdx = idx
527
+ const clip = clips[clipIdx]
528
+ if (!clip?.src) return
529
+ activeIdxRef.current = clipIdx
530
+ const video = getActiveVideo()
531
+ if (!video) return
532
+ const targetSrc = fileUrlRef.current(playbackSrcFor(clip))
533
+ if (video.src !== targetSrc) {
534
+ video.src = targetSrc
535
+ // Clear preloaded inactive slot — it may no longer be the right next clip
536
+ preloadSrcRef.current = ''
537
+ const inactive = getInactiveVideo()
538
+ if (inactive) { inactive.pause(); inactive.removeAttribute('src') }
539
+ }
540
+ applyClipVolume(clip)
541
+ const inPoint = clip.inPoint ?? 0
542
+ if (clip.loop && clip.outPoint) {
543
+ const loopDur = clip.outPoint - inPoint
544
+ const elapsed = currentTime - clip.start
545
+ const loops = Math.floor(elapsed / loopDur)
546
+ loopOffsetRef.current = loops * loopDur
547
+ video.currentTime = inPoint + (elapsed % loopDur)
548
+ } else {
549
+ loopOffsetRef.current = 0
550
+ video.currentTime = Math.max(inPoint, inPoint + (currentTime - clip.start))
551
+ }
552
+ } finally {
553
+ // Delay clearing seekingRef so the pause/play events the browser fires
554
+ // during currentTime assignment don't toggle isPlaying
555
+ setTimeout(() => {
556
+ seekingRef.current = false
557
+ // Sync isPlaying state and audio to video's actual state after seek settles
558
+ const v = getActiveVideo()
559
+ if (!v) return
560
+ setIsPlaying(!v.paused)
561
+ syncAudioTracks(lastTimeRef.current, !v.paused)
562
+ }, 100)
563
+ }
564
+ }, [currentTime, clips])
565
+
566
+ const handleTimeUpdate = useCallback(() => {
567
+ // Gap clock owns time during gaps — ignore timeupdate events from the paused video element
568
+ // to prevent it from resetting currentTime and cancelling the gap clock.
569
+ if (inGapRef.current) return
570
+ const slot = activeSlotRef.current
571
+ const video = slot === 0 ? video0Ref.current : video1Ref.current
572
+ if (!video || seekingRef.current) return
573
+ const clip = clips[activeIdxRef.current]
574
+ if (!clip) return
575
+
576
+ const outPoint = clip.outPoint ?? clip.end - clip.start + (clip.inPoint ?? 0)
577
+
578
+ // Preload next clip into inactive slot ~1s before end
579
+ const timeLeft = outPoint - video.currentTime
580
+ if (timeLeft < 1.0) {
581
+ const nextIdx = activeIdxRef.current + 1
582
+ if (nextIdx < clips.length && clips[nextIdx].src) {
583
+ const inactiveVideo = slot === 0 ? video1Ref.current : video0Ref.current
584
+ const nextSrc = fileUrlRef.current(playbackSrcFor(clips[nextIdx]))
585
+ if (inactiveVideo && preloadSrcRef.current !== nextSrc) {
586
+ preloadSrcRef.current = nextSrc
587
+ inactiveVideo.src = nextSrc
588
+ inactiveVideo.currentTime = clips[nextIdx].inPoint ?? 0
589
+ const inactiveSlot = (1 - slot) as 0 | 1
590
+ const nextGain = ensureVideoGain(inactiveSlot)
591
+ if (nextGain) nextGain.gain.value = clips[nextIdx].muted ? 0 : (clips[nextIdx].volume ?? 1)
592
+ }
593
+ }
594
+ }
595
+
596
+ if (video.currentTime >= outPoint) {
597
+ if (clip.loop) {
598
+ const projectT = clip.start + loopOffsetRef.current + (video.currentTime - (clip.inPoint ?? 0))
599
+ if (projectT < clip.end) {
600
+ // Still within the clip's project window — loop the source video
601
+ const loopDur = outPoint - (clip.inPoint ?? 0)
602
+ loopOffsetRef.current += loopDur
603
+ video.currentTime = clip.inPoint ?? 0
604
+ return
605
+ }
606
+ // Project end reached — fall through to the stop/next-clip logic below
607
+ }
608
+ const nextIdx = activeIdxRef.current + 1
609
+ if (nextIdx < clips.length && clips[nextIdx].src) {
610
+ const next = clips[nextIdx]
611
+ const cur = clips[activeIdxRef.current]
612
+
613
+ if (next.start > cur.end + 0.02) {
614
+ // Gap between clips — hide video (black), advance time via RAF clock
615
+ video.pause()
616
+ setShowVideo(false)
617
+ inGapRef.current = true
618
+ gapFromRef.current = cur.end
619
+ gapWallRef.current = performance.now()
620
+ gapTargetRef.current = next.start
621
+ gapNextIdxRef.current = nextIdx
622
+ gapRAFRef.current = requestAnimationFrame(tickGap)
623
+ // Keep isPlaying=true so overlay videos (e.g. floating_head) continue playing
624
+ setIsPlaying(true)
625
+ } else {
626
+ // Contiguous — immediate switch
627
+ const nextSlot = (1 - slot) as 0 | 1
628
+ const nextVideo = nextSlot === 0 ? video0Ref.current : video1Ref.current
629
+
630
+ lastTimeRef.current = next.start
631
+ onTimeUpdate(next.start)
632
+ activeIdxRef.current = nextIdx
633
+
634
+ if (nextVideo) {
635
+ const nextSrc = fileUrlRef.current(playbackSrcFor(next))
636
+ if (preloadSrcRef.current !== nextSrc) {
637
+ nextVideo.src = nextSrc
638
+ nextVideo.currentTime = next.inPoint ?? 0
639
+ }
640
+ const nextGain = ensureVideoGain(nextSlot)
641
+ if (nextGain) nextGain.gain.value = next.muted ? 0 : (next.volume ?? 1)
642
+ nextVideo.play().catch(() => {})
643
+ }
644
+
645
+ activeSlotRef.current = nextSlot
646
+ setActiveSlot(nextSlot)
647
+ preloadSrcRef.current = ''
648
+ video.pause()
649
+ }
650
+ } else {
651
+ // Last video clip ended
652
+ video.pause()
653
+ const finalTime = clips[activeIdxRef.current].end
654
+ lastTimeRef.current = finalTime
655
+ onTimeUpdate(finalTime)
656
+
657
+ // If overlays or audio extend beyond the last video clip,
658
+ // continue advancing time via the gap clock (shows overlays, plays audio).
659
+ if (finalTime < projectEnd) {
660
+ setShowVideo(false)
661
+ inGapRef.current = true
662
+ gapFromRef.current = finalTime
663
+ gapWallRef.current = performance.now()
664
+ gapTargetRef.current = projectEnd
665
+ gapNextIdxRef.current = clips.length // no next clip
666
+ gapRAFRef.current = requestAnimationFrame(tickGap)
667
+ setIsPlaying(true)
668
+ }
669
+ }
670
+ return
671
+ }
672
+
673
+ const t = clip.start + loopOffsetRef.current + (video.currentTime - (clip.inPoint ?? 0))
674
+
675
+ // For looping clips, stop when project time reaches clip.end mid-loop
676
+ if (clip.loop && t >= clip.end) {
677
+ video.pause()
678
+ lastTimeRef.current = clip.end
679
+ onTimeUpdate(clip.end)
680
+ for (const el of audioRefsMap.current.values()) { if (!el.paused) el.pause() }
681
+ setIsPlaying(false)
682
+ return
683
+ }
684
+
685
+ lastTimeRef.current = t
686
+ onTimeUpdate(t)
687
+ }, [clips, onTimeUpdate])
688
+
689
+ const handleEnded = useCallback(() => {
690
+ // For looping clips the ended event fires when the source video reaches its natural end.
691
+ // handleTimeUpdate already handles the loop/stop decision via outPoint + clip.end checks.
692
+ // Just call handleTimeUpdate to ensure the transition fires even if timeupdate didn't catch it.
693
+ handleTimeUpdate()
694
+ }, [handleTimeUpdate])
695
+
696
+ function togglePlay() {
697
+ // GESTURE-ANCHORED: same rationale as the keydown handler — resume the
698
+ // AudioContext synchronously inside this user-gesture call so a wired
699
+ // <video> isn't trapped in a suspended Web Audio graph after page refresh.
700
+ resumeAudioContextFromGesture()
701
+ if (isCanvasProject) { setIsPlaying(p => !p); return }
702
+ // If current time is in a gap/image section (not inside any video clip), drive via gap clock
703
+ const t = lastTimeRef.current
704
+ const inVideoClip = clips.some(c => t >= c.start && t < c.end)
705
+ if (!inVideoClip || inGapRef.current) {
706
+ if (gapRAFRef.current !== null) {
707
+ // Currently playing through gap → pause
708
+ cancelAnimationFrame(gapRAFRef.current)
709
+ gapRAFRef.current = null
710
+ inGapRef.current = false
711
+ setIsPlaying(false)
712
+ } else {
713
+ // Paused in gap/image section → find next video clip and advance via gap clock
714
+ const nextIdx = clips.findIndex(c => c.start > t)
715
+ if (nextIdx === -1) {
716
+ // No more video clips — advance through trailing overlays/audio if any
717
+ if (t < projectEnd) {
718
+ inGapRef.current = true
719
+ gapFromRef.current = t
720
+ gapWallRef.current = performance.now()
721
+ gapTargetRef.current = projectEnd
722
+ gapNextIdxRef.current = clips.length
723
+ gapRAFRef.current = requestAnimationFrame(tickGap)
724
+ setIsPlaying(true)
725
+ }
726
+ return
727
+ }
728
+ inGapRef.current = true
729
+ gapFromRef.current = t
730
+ gapWallRef.current = performance.now()
731
+ gapTargetRef.current = clips[nextIdx].start
732
+ gapNextIdxRef.current = nextIdx
733
+ gapRAFRef.current = requestAnimationFrame(tickGap)
734
+ setIsPlaying(true)
735
+ }
736
+ return
737
+ }
738
+ const video = getActiveVideo()
739
+ if (!video) return
740
+ if (video.paused) { video.play().catch(() => {}) } else { video.pause() }
741
+ }
742
+
743
+ return {
744
+ video0Ref,
745
+ video1Ref,
746
+ activeSlotRef,
747
+ activeSlot,
748
+ showVideo,
749
+ isPlaying,
750
+ setIsPlaying,
751
+ handleTimeUpdate,
752
+ handlePause,
753
+ handleEnded,
754
+ togglePlay,
755
+ isCanvasProject,
756
+ clips,
757
+ tracks0NonVideo,
758
+ overlayTracks,
759
+ unmutedAudioTracks,
760
+ }
761
+ }