@devbycrux/editor 0.1.0 → 0.2.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/package.json +1 -1
- package/src/__tests__/video-adapter-contract.test.ts +89 -0
- package/src/index.ts +23 -1
- package/src/types.ts +141 -0
- package/src/video/RenderModal.tsx +252 -0
- package/src/video/VersionPanel.tsx +83 -0
- package/src/video/VideoEditor.tsx +508 -0
- package/src/video/__tests__/VideoEditor.test.tsx +213 -0
- package/src/video/__tests__/captionRepair.test.ts +134 -0
- package/src/video/__tests__/cuts.test.ts +198 -0
- package/src/video/captionRepair.ts +41 -0
- package/src/video/cuts.ts +369 -0
- package/src/video/design-canvas.ts +11 -0
- package/src/video/preview/CaptionPreview.tsx +83 -0
- package/src/video/preview/CarouselPreview.tsx +35 -0
- package/src/video/preview/OverlayItemsLayer.tsx +603 -0
- package/src/video/preview/PreviewPlayer.tsx +178 -0
- package/src/video/preview/useDragOverlay.ts +167 -0
- package/src/video/preview/useVideoPlayback.ts +761 -0
- package/src/video/timeline/AudioTrackRow.tsx +406 -0
- package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
- package/src/video/timeline/EditableSegment.tsx +30 -0
- package/src/video/timeline/Scrubber.tsx +184 -0
- package/src/video/timeline/Timeline.tsx +375 -0
- package/src/video/timeline/TimelineContext.ts +25 -0
- package/src/video/timeline/TranscriptModal.tsx +63 -0
- package/src/video/timeline/TranscriptPanel.tsx +86 -0
- package/src/video/timeline/VisualTrackRow.tsx +293 -0
- package/src/video/timeline/makeCaptionEdit.ts +32 -0
- package/src/video/timeline/multiSelectOps.ts +157 -0
- package/src/video/timeline/useItemDragDrop.ts +190 -0
- package/src/video/timeline/useTimelineZoom.ts +48 -0
- 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
|
+
}
|