@bycrux/editor 1.2.0 → 1.2.2
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 +2 -2
- package/src/engine/__tests__/scheduler.test.ts +35 -0
- package/src/engine/scheduler.ts +10 -2
- package/src/index.ts +33 -0
- package/src/lib/__tests__/font-faces.test.ts +244 -0
- package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
- package/src/lib/__tests__/google-fonts.test.ts +319 -2
- package/src/lib/font-families.ts +286 -0
- package/src/lib/google-fonts.ts +132 -10
- package/src/text/FontPicker.tsx +82 -11
- package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
- package/src/types.ts +68 -0
- package/src/video/CaptionRegenModal.tsx +34 -8
- package/src/video/VersionPanel.tsx +1 -1
- package/src/video/VideoEditor.tsx +32 -4
- package/src/video/__tests__/CaptionRegenModal.test.tsx +161 -2
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +102 -0
- package/src/video/__tests__/captionProfileDefaults.test.ts +121 -0
- package/src/video/captionProfileDefaults.ts +86 -0
- package/src/video/panels/LeftPanelTabs.tsx +20 -2
- package/src/video/preview/PreviewPlayer.tsx +52 -2
- package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
- package/src/video/preview/useEnginePlayback.ts +58 -8
- package/src/video/preview/useVideoPlayback.ts +48 -16
- package/src/video/timeline/canvas/TimelineCanvas.tsx +10 -8
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
- package/src/video/timeline/canvas/draw.ts +78 -1
- package/src/video/timeline/timeline-model.ts +19 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 4c — the `muted` master override on `PreviewPlayer` (threaded through
|
|
3
|
+
* as `useVideoPlayback`'s 5th argument). See that prop's doc in
|
|
4
|
+
* `PreviewPlayer.tsx` for the full "why": moving the pointer across a grid of
|
|
5
|
+
* project-card hover previews must not play each project's audio in turn.
|
|
6
|
+
*
|
|
7
|
+
* Two DISTINCT audio paths this hook owns a GainNode for, both asserted here:
|
|
8
|
+
*
|
|
9
|
+
* 1. The video-slot GainNodes (`videoGainRef`, via `applyClipVolume` and the
|
|
10
|
+
* three transition sites) — same harness as
|
|
11
|
+
* `useVideoPlayback.trackAudio.test.ts` (seeded `__montajGain`, no real
|
|
12
|
+
* AudioContext needed).
|
|
13
|
+
* 2. The background audio-TRACK GainNodes (`gainNodesMap` — music/VO beds,
|
|
14
|
+
* `project.audio.tracks`) — a wholly separate `<audio>` element family
|
|
15
|
+
* the video-slot mute does nothing for. jsdom has no Web Audio at all, so
|
|
16
|
+
* this half stubs `window.__montajSharedCtx` directly (same technique as
|
|
17
|
+
* `latencyCompensation.test.tsx`) rather than seeding a cached node.
|
|
18
|
+
*
|
|
19
|
+
* Both must go to 0 when `muted` is true, REGARDLESS of what the clip/track's
|
|
20
|
+
* own volume/mute settings say — this is a master override on top of the
|
|
21
|
+
* existing fold, not a replacement for it. And the default (`muted` absent)
|
|
22
|
+
* must be byte-identical to every gain value `trackAudio.test.ts` already
|
|
23
|
+
* pins — that suite is untouched by this change and stays the proof.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
26
|
+
import { act, renderHook } from '@testing-library/react'
|
|
27
|
+
import { useVideoPlayback } from '../useVideoPlayback'
|
|
28
|
+
import type { EditorProject, VisualItem } from '../../../schema'
|
|
29
|
+
|
|
30
|
+
// jsdom implements neither `play()` nor `pause()`. Installed once, at module
|
|
31
|
+
// scope, rather than as a per-test spy: Testing Library's own `afterEach`
|
|
32
|
+
// cleanup unmounts the hook — which, once a test populates
|
|
33
|
+
// `project.audio.tracks`, runs the audio-lane teardown that calls
|
|
34
|
+
// `el.pause()` on every lane element — AFTER a per-test `restoreAllMocks()`
|
|
35
|
+
// would have put the unimplemented originals back (same hazard documented in
|
|
36
|
+
// `latencyCompensation.test.tsx`).
|
|
37
|
+
Object.defineProperty(HTMLMediaElement.prototype, 'paused', {
|
|
38
|
+
configurable: true,
|
|
39
|
+
get(this: HTMLMediaElement & { __paused?: boolean }) { return this.__paused !== false },
|
|
40
|
+
})
|
|
41
|
+
HTMLMediaElement.prototype.play = function (this: HTMLMediaElement & { __paused?: boolean }) {
|
|
42
|
+
this.__paused = false
|
|
43
|
+
return Promise.resolve()
|
|
44
|
+
}
|
|
45
|
+
HTMLMediaElement.prototype.pause = function (this: HTMLMediaElement & { __paused?: boolean }) {
|
|
46
|
+
this.__paused = true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Path 1: video-slot GainNodes ────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
interface FakeGain {
|
|
52
|
+
gain: { value: number }
|
|
53
|
+
writes: number[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fakeVideo(): HTMLVideoElement {
|
|
57
|
+
const el = document.createElement('video')
|
|
58
|
+
let t = 0
|
|
59
|
+
Object.defineProperty(el, 'currentTime', {
|
|
60
|
+
get: () => t,
|
|
61
|
+
set: (v: number) => { t = v },
|
|
62
|
+
configurable: true,
|
|
63
|
+
})
|
|
64
|
+
return el
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function attachGain(el: HTMLVideoElement): FakeGain {
|
|
68
|
+
const writes: number[] = []
|
|
69
|
+
let value = Number.NaN
|
|
70
|
+
const node: FakeGain = {
|
|
71
|
+
gain: {
|
|
72
|
+
get value() { return value },
|
|
73
|
+
set value(next: number) { value = next; writes.push(next) },
|
|
74
|
+
},
|
|
75
|
+
writes,
|
|
76
|
+
}
|
|
77
|
+
;(el as unknown as { __montajGain: FakeGain }).__montajGain = node
|
|
78
|
+
return node
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const clip = (over: Partial<VisualItem>): VisualItem =>
|
|
82
|
+
({ id: 'a', type: 'video', src: '/a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, ...over }) as VisualItem
|
|
83
|
+
|
|
84
|
+
function projectWith(items: VisualItem[]): EditorProject {
|
|
85
|
+
return {
|
|
86
|
+
id: 'muted-video',
|
|
87
|
+
status: 'draft',
|
|
88
|
+
settings: { resolution: [1080, 1920] },
|
|
89
|
+
tracks: [{ id: 'trk-0', items }],
|
|
90
|
+
} as EditorProject
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Same mount dance as `trackAudio.test.ts`, with `muted` threaded through. */
|
|
94
|
+
function mount(project: EditorProject, muted = false) {
|
|
95
|
+
const harness = renderHook(
|
|
96
|
+
({ p, m }: { p: EditorProject; m: boolean }) => useVideoPlayback(p, 0, () => {}, (path) => path, m),
|
|
97
|
+
{ initialProps: { p: project, m: muted } },
|
|
98
|
+
)
|
|
99
|
+
const v0 = fakeVideo()
|
|
100
|
+
const v1 = fakeVideo()
|
|
101
|
+
const g0 = attachGain(v0)
|
|
102
|
+
const g1 = attachGain(v1)
|
|
103
|
+
harness.result.current.video0Ref.current = v0
|
|
104
|
+
harness.result.current.video1Ref.current = v1
|
|
105
|
+
harness.rerender({ p: { ...project }, m: muted })
|
|
106
|
+
return { ...harness, v0, v1, g0, g1 }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe('useVideoPlayback — muted overrides the video-slot GainNodes', () => {
|
|
110
|
+
it('unset — the default path — is byte-identical to the clip volume', () => {
|
|
111
|
+
const { g0 } = mount(projectWith([clip({ volume: 0.8 })]))
|
|
112
|
+
expect(g0.gain.value).toBeCloseTo(0.8, 10)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('false — explicitly — is also byte-identical to the clip volume', () => {
|
|
116
|
+
const { g0 } = mount(projectWith([clip({ volume: 0.8 })]), false)
|
|
117
|
+
expect(g0.gain.value).toBeCloseTo(0.8, 10)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('zeroes the active slot even though the clip is loud and unmuted', () => {
|
|
121
|
+
const { g0 } = mount(projectWith([clip({ volume: 2 })]), true)
|
|
122
|
+
expect(g0.gain.value).toBe(0)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('reaches the live gain node immediately on an external toggle, not just at load', () => {
|
|
126
|
+
const items = [clip({ volume: 1 })]
|
|
127
|
+
const { rerender, g0 } = mount(projectWith(items), false)
|
|
128
|
+
expect(g0.gain.value).toBeCloseTo(1, 10)
|
|
129
|
+
|
|
130
|
+
act(() => { rerender({ p: projectWith(items), m: true }) })
|
|
131
|
+
expect(g0.gain.value).toBe(0)
|
|
132
|
+
|
|
133
|
+
// And un-muting brings the clip's own volume straight back — this is an
|
|
134
|
+
// override on TOP of the fold, not a one-way latch.
|
|
135
|
+
act(() => { rerender({ p: projectWith(items), m: false }) })
|
|
136
|
+
expect(g0.gain.value).toBeCloseTo(1, 10)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('zeroes the incoming clip at a contiguous cut too, not just the clip already on screen', () => {
|
|
140
|
+
// If muted only held for the active slot, every clip switch would un-mute
|
|
141
|
+
// the preview for one frame.
|
|
142
|
+
const a = clip({ id: 'a', volume: 1, start: 0, end: 5, outPoint: 5 })
|
|
143
|
+
const b = clip({ id: 'b', src: '/b.mp4', volume: 1, start: 5, end: 10 })
|
|
144
|
+
const { result, v0, g1 } = mount(projectWith([a, b]), true)
|
|
145
|
+
|
|
146
|
+
v0.currentTime = 5 // at a's outPoint, b starts exactly where a ends
|
|
147
|
+
act(() => { result.current.handleTimeUpdate() })
|
|
148
|
+
|
|
149
|
+
expect(g1.gain.value).toBe(0)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// ── Path 2: background audio-track (music/VO bed) GainNodes ────────────────
|
|
154
|
+
//
|
|
155
|
+
// A wholly separate `<audio>` element family — `project.audio.tracks` — with
|
|
156
|
+
// its own GainNode lifecycle (`gainNodesMap`, "Multi-track audio management"
|
|
157
|
+
// in useVideoPlayback.ts). The video-slot fixes above do nothing for this
|
|
158
|
+
// path; it has to be muted independently, which is exactly what a caller
|
|
159
|
+
// mounting a project with a music bed over its clips would otherwise miss.
|
|
160
|
+
|
|
161
|
+
interface FakeLaneGain {
|
|
162
|
+
gain: { value: number }
|
|
163
|
+
connect: () => void
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface StubCtx {
|
|
167
|
+
state: 'running'
|
|
168
|
+
outputLatency: number
|
|
169
|
+
baseLatency: number
|
|
170
|
+
resume: () => Promise<void>
|
|
171
|
+
destination: object
|
|
172
|
+
createMediaElementSource: () => { connect: () => void }
|
|
173
|
+
createGain: () => FakeLaneGain
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function stubSharedCtx(): { gains: FakeLaneGain[] } {
|
|
177
|
+
const gains: FakeLaneGain[] = []
|
|
178
|
+
const ctx: StubCtx = {
|
|
179
|
+
state: 'running',
|
|
180
|
+
outputLatency: 0,
|
|
181
|
+
baseLatency: 0,
|
|
182
|
+
resume: () => Promise.resolve(),
|
|
183
|
+
destination: {},
|
|
184
|
+
createMediaElementSource: () => ({ connect: () => {} }),
|
|
185
|
+
createGain: () => {
|
|
186
|
+
const node: FakeLaneGain = { gain: { value: 1 }, connect: () => {} }
|
|
187
|
+
gains.push(node)
|
|
188
|
+
return node
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
;(window as unknown as { __montajSharedCtx?: StubCtx }).__montajSharedCtx = ctx
|
|
192
|
+
return { gains }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function clearSharedCtx() {
|
|
196
|
+
delete (window as unknown as { __montajSharedCtx?: unknown }).__montajSharedCtx
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function projectWithMusicBed(): EditorProject {
|
|
200
|
+
return {
|
|
201
|
+
id: 'muted-lane',
|
|
202
|
+
status: 'draft',
|
|
203
|
+
settings: { resolution: [1080, 1920] },
|
|
204
|
+
tracks: [],
|
|
205
|
+
audio: { tracks: [{ id: 'music', src: '/music.mp3', start: 0, end: 5, volume: 1 }] },
|
|
206
|
+
} as EditorProject
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
describe('useVideoPlayback — muted overrides background audio-track GainNodes', () => {
|
|
210
|
+
afterEach(() => {
|
|
211
|
+
clearSharedCtx()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('unset — the default path — leaves the lane at its own volume', () => {
|
|
215
|
+
const { gains } = stubSharedCtx()
|
|
216
|
+
renderHook(() => useVideoPlayback(projectWithMusicBed(), 1, () => {}, (p) => p))
|
|
217
|
+
expect(gains).toHaveLength(1)
|
|
218
|
+
expect(gains[0].gain.value).toBe(1)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('zeroes the lane on mount, though the track itself is unmuted at volume 1', () => {
|
|
222
|
+
const { gains } = stubSharedCtx()
|
|
223
|
+
renderHook(() => useVideoPlayback(projectWithMusicBed(), 1, () => {}, (p) => p, true))
|
|
224
|
+
expect(gains).toHaveLength(1)
|
|
225
|
+
expect(gains[0].gain.value).toBe(0)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('reaches the lane immediately on an external toggle', () => {
|
|
229
|
+
const { gains } = stubSharedCtx()
|
|
230
|
+
const { rerender } = renderHook(
|
|
231
|
+
({ m }: { m: boolean }) => useVideoPlayback(projectWithMusicBed(), 1, () => {}, (p) => p, m),
|
|
232
|
+
{ initialProps: { m: false } },
|
|
233
|
+
)
|
|
234
|
+
expect(gains[0].gain.value).toBe(1)
|
|
235
|
+
|
|
236
|
+
act(() => { rerender({ m: true }) })
|
|
237
|
+
expect(gains[0].gain.value).toBe(0)
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -34,8 +34,16 @@
|
|
|
34
34
|
* - **Video-item volume, including >1.0.** The legacy hook routes each
|
|
35
35
|
* `<video>` slot through a GainNode to get amplification. The engine has no
|
|
36
36
|
* element to route: `createMasterClock` takes the item's `volume`/`muted`
|
|
37
|
-
* and
|
|
38
|
-
* `engine/
|
|
37
|
+
* and applies the level on a PER-SESSION output `GainNode` (one node → gain
|
|
38
|
+
* → destination chain per clip session, `engine/audio-clock.ts`), reached
|
|
39
|
+
* from `engine/index.ts`'s `SourceRequest` → `request.item.volume`. It is
|
|
40
|
+
* NOT scaled into the PCM at ring-enqueue time — that was the original
|
|
41
|
+
* design and it was replaced, so that a level change is heard immediately
|
|
42
|
+
* rather than only after the up-to-`RING_SECONDS` already in the ring
|
|
43
|
+
* drains. `audio-clock.ts` marks both ends of that change in place: the
|
|
44
|
+
* interleave helper now says "**Volume no longer rides here**" and is
|
|
45
|
+
* called with `volume === 1`, and the gain chain says "the clip's volume
|
|
46
|
+
* rides HERE, not in the PCM". Read those, not this paragraph. The TRACK's
|
|
39
47
|
* volume/mute ride the same path: the scheduler folds them into the request
|
|
40
48
|
* item (`withTrackAudio`) before the host ever sees it. Nothing to thread
|
|
41
49
|
* here either way; adding a second volume path would be the duplication the
|
|
@@ -180,7 +188,48 @@ export function useEnginePlayback(
|
|
|
180
188
|
currentTime: number,
|
|
181
189
|
onTimeUpdate: (t: number) => void,
|
|
182
190
|
fileUrl: (path: string) => string,
|
|
191
|
+
muted = false,
|
|
183
192
|
): EnginePlayback {
|
|
193
|
+
// Second-rider master mute (PreviewPlayer's `muted` prop) — see the
|
|
194
|
+
// matching ref in `useVideoPlayback.ts`, whose reasoning this mirrors.
|
|
195
|
+
//
|
|
196
|
+
// PARTIAL COVERAGE, DELIBERATELY: this only reaches the audio-LANE
|
|
197
|
+
// GainNodes below (`project.audio.tracks` — music/VO beds), the one audio
|
|
198
|
+
// path this hook owns an element/GainNode for (see "THE AUDIO LANES" in
|
|
199
|
+
// this file's header). Track-0 VIDEO-ITEM audio does NOT route through a
|
|
200
|
+
// GainNode *this hook* owns, so `muted` does not reach it today.
|
|
201
|
+
//
|
|
202
|
+
// Be precise about WHY, because the obvious guess is wrong and this file's
|
|
203
|
+
// own header asserted the wrong thing until 2026-09-20: that the item's
|
|
204
|
+
// volume is scaled into the PCM at ring-enqueue time. It is not, and
|
|
205
|
+
// `engine/audio-clock.ts` says so twice — "**Volume no longer rides here.**
|
|
206
|
+
// `createAudioClock` calls this with `volume === 1` and applies the clip's
|
|
207
|
+
// real level on a per-session output `GainNode` instead", and at the chain
|
|
208
|
+
// itself, "the clip's volume rides HERE, not in the PCM". Enqueue-time
|
|
209
|
+
// scaling was REPLACED precisely so a level change could be heard
|
|
210
|
+
// immediately, including in the up-to-`RING_SECONDS` already buffered.
|
|
211
|
+
//
|
|
212
|
+
// So closing this is cheap, and it is left undone only because it is out of
|
|
213
|
+
// this rider's scope — not because it is hard. `MasterClock.setVolume` is
|
|
214
|
+
// that live lever and it is already wired: `engine/index.ts` pushes a clip's
|
|
215
|
+
// volume change straight to the live clock rather than tearing the session
|
|
216
|
+
// down. Pushing 0 through that same path when `muted` is the whole change.
|
|
217
|
+
// It does NOT touch `SourceRequest`/session-build plumbing and it never goes
|
|
218
|
+
// near `session.muted` or the retain-drop test, because `muted` is the
|
|
219
|
+
// CONSTRUCTION-time decision (a muted clip runs on the wall clock and builds
|
|
220
|
+
// no audio graph at all) while `volume` is the live one — `audio-clock.ts`
|
|
221
|
+
// draws exactly that distinction on `muted`'s own doc comment.
|
|
222
|
+
//
|
|
223
|
+
// Flagged rather than shipped, and deliberately not described as expensive:
|
|
224
|
+
// a comment claiming a cheap fix is costly is how a hole stays open. In
|
|
225
|
+
// practice this is currently moot: every caller of
|
|
226
|
+
// `PreviewPlayer` that passes `muted` (the project-card hover preview)
|
|
227
|
+
// never sets `engine: {enabled: true}`, so this hook — and the gap — is
|
|
228
|
+
// unreached. If a future host DOES combine `engine.enabled` with `muted`,
|
|
229
|
+
// primary clip audio will still play; that combination needs its own task.
|
|
230
|
+
const mutedRef = useRef(muted)
|
|
231
|
+
useEffect(() => { mutedRef.current = muted }, [muted])
|
|
232
|
+
|
|
184
233
|
// ── Derived collections (the legacy memos, verbatim) ──────────────────────
|
|
185
234
|
// `track0VideoItems` IS the legacy `clips` memo, lifted into the scheduler so
|
|
186
235
|
// one definition serves both the engine's tick and this surface.
|
|
@@ -329,7 +378,7 @@ export function useEnginePlayback(
|
|
|
329
378
|
|
|
330
379
|
// `audioWindow.gain` is already `baseVolume * max(0, fadeMul)`.
|
|
331
380
|
const gain = gainNodesMap.current.get(track.id)
|
|
332
|
-
if (gain) gain.gain.value = win.gain
|
|
381
|
+
if (gain) gain.gain.value = mutedRef.current ? 0 : win.gain
|
|
333
382
|
}
|
|
334
383
|
}, [])
|
|
335
384
|
|
|
@@ -361,7 +410,7 @@ export function useEnginePlayback(
|
|
|
361
410
|
const ctx = getSharedAudioContext()
|
|
362
411
|
const source = ctx.createMediaElementSource(el)
|
|
363
412
|
const gain = ctx.createGain()
|
|
364
|
-
gain.gain.value = track.volume ?? 1
|
|
413
|
+
gain.gain.value = mutedRef.current ? 0 : (track.volume ?? 1)
|
|
365
414
|
source.connect(gain)
|
|
366
415
|
gain.connect(ctx.destination)
|
|
367
416
|
gains.set(track.id, gain)
|
|
@@ -371,7 +420,7 @@ export function useEnginePlayback(
|
|
|
371
420
|
srcMap.set(track.id, track.src!)
|
|
372
421
|
}
|
|
373
422
|
const gain = gains.get(track.id)
|
|
374
|
-
if (gain) gain.gain.value = track.volume ?? 1
|
|
423
|
+
if (gain) gain.gain.value = mutedRef.current ? 0 : (track.volume ?? 1)
|
|
375
424
|
}
|
|
376
425
|
|
|
377
426
|
// A lane added mid-session has to be placed at the current playhead
|
|
@@ -385,13 +434,14 @@ export function useEnginePlayback(
|
|
|
385
434
|
// changed, which is the render whose track set this effect is reconciling.
|
|
386
435
|
}, [audioTrackIdentity])
|
|
387
436
|
|
|
388
|
-
// Volume in place, no element churn.
|
|
437
|
+
// Volume in place, no element churn. `muted` is a dep so an external mute
|
|
438
|
+
// toggle reaches every lane immediately (mirrors the legacy hook).
|
|
389
439
|
useEffect(() => {
|
|
390
440
|
for (const track of unmutedAudioTracks) {
|
|
391
441
|
const gain = gainNodesMap.current.get(track.id)
|
|
392
|
-
if (gain) gain.gain.value = track.volume ?? 1
|
|
442
|
+
if (gain) gain.gain.value = mutedRef.current ? 0 : (track.volume ?? 1)
|
|
393
443
|
}
|
|
394
|
-
}, [unmutedAudioTracks])
|
|
444
|
+
}, [unmutedAudioTracks, muted])
|
|
395
445
|
|
|
396
446
|
// Unmount only. The shared AudioContext is window-scoped and never closed.
|
|
397
447
|
useEffect(() => {
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type MontajWindow,
|
|
15
15
|
} from './audio-context'
|
|
16
16
|
import type { EditorProject as Project, VisualItem, VisualTrack } from '../../schema'
|
|
17
|
-
import { effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../timeline/timeline-model'
|
|
17
|
+
import { audioEnd, effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../timeline/timeline-model'
|
|
18
18
|
|
|
19
19
|
// Typed extension for video elements that cache their GainNode
|
|
20
20
|
interface MontajVideoElement extends HTMLVideoElement {
|
|
@@ -122,6 +122,7 @@ export function useVideoPlayback(
|
|
|
122
122
|
currentTime: number,
|
|
123
123
|
onTimeUpdate: (t: number) => void,
|
|
124
124
|
fileUrl: (path: string) => string,
|
|
125
|
+
muted = false,
|
|
125
126
|
) {
|
|
126
127
|
// Double-buffer video elements for seamless clip transitions
|
|
127
128
|
const video0Ref = useRef<HTMLVideoElement>(null)
|
|
@@ -137,6 +138,20 @@ export function useVideoPlayback(
|
|
|
137
138
|
const loopOffsetRef = useRef(0)
|
|
138
139
|
const rafRef = useRef<number | null>(null)
|
|
139
140
|
const rafLastMs = useRef<number | null>(null)
|
|
141
|
+
// Second-rider master mute (PreviewPlayer's `muted` prop). Read through a
|
|
142
|
+
// ref — not the `muted` param directly — so every gain-setting site below,
|
|
143
|
+
// including the useCallback closures whose own dependency arrays this
|
|
144
|
+
// deliberately leaves untouched (`tickGap`, `handleTimeUpdate`,
|
|
145
|
+
// `syncAudioTracks`), always sees the current value without forcing a
|
|
146
|
+
// reload/re-wire of anything. Covers BOTH GainNode families this hook owns:
|
|
147
|
+
// the video-slot slots (`videoGainRef`, via `applyClipVolume`) and every
|
|
148
|
+
// background audio-track lane (`gainNodesMap`) — `<video muted>`/
|
|
149
|
+
// `el.muted` alone would silence neither, since both are routed through
|
|
150
|
+
// `MediaElementSource → GainNode → ctx.destination` (see `audio-context.ts`
|
|
151
|
+
// and `ensureVideoGain` below): once wired, the element's own mute/volume
|
|
152
|
+
// have no audible effect.
|
|
153
|
+
const mutedRef = useRef(muted)
|
|
154
|
+
useEffect(() => { mutedRef.current = muted }, [muted])
|
|
140
155
|
// rAF clock for VIDEO projects — drives clip-boundary detection at ~60Hz
|
|
141
156
|
// instead of the <video> element's coarse `timeupdate` event (~4Hz). See the
|
|
142
157
|
// effect below for why.
|
|
@@ -309,18 +324,21 @@ export function useVideoPlayback(
|
|
|
309
324
|
function applyClipVolume(clip: { muted?: boolean; volume?: number }) {
|
|
310
325
|
const slot = activeSlotRef.current
|
|
311
326
|
const gain = getVideoGain(slot)
|
|
312
|
-
if (gain) gain.gain.value = clipGain(videoTrack, clip)
|
|
327
|
+
if (gain) gain.gain.value = mutedRef.current ? 0 : clipGain(videoTrack, clip)
|
|
313
328
|
}
|
|
314
329
|
|
|
315
330
|
// Apply video clip volume via Web Audio GainNode (supports > 1.0 amplification).
|
|
316
331
|
// `videoTrack` is a dep in its own right: pulling the TRACK's fader while the
|
|
317
|
-
// clips themselves are untouched has to reach the live gain node too.
|
|
332
|
+
// clips themselves are untouched has to reach the live gain node too. `muted`
|
|
333
|
+
// is a dep for the same reason: an external mute toggle on an already-loaded
|
|
334
|
+
// slot must reach the live node immediately, not wait for the next natural
|
|
335
|
+
// clip switch (which is the only other place `mutedRef` gets re-read).
|
|
318
336
|
useEffect(() => {
|
|
319
337
|
const idx = activeIdxRef.current
|
|
320
338
|
const clip = clips[idx]
|
|
321
339
|
if (!clip) return
|
|
322
340
|
applyClipVolume(clip)
|
|
323
|
-
}, [clips, videoTrack, activeSlot])
|
|
341
|
+
}, [clips, videoTrack, activeSlot, muted])
|
|
324
342
|
|
|
325
343
|
// maxEnd for the canvas rAF clock — the furthest visual/caption end. Kept in
|
|
326
344
|
// a ref, updated by its own cheap effect, so the rAF effect below doesn't tear
|
|
@@ -339,15 +357,26 @@ export function useVideoPlayback(
|
|
|
339
357
|
// clock's ceiling and what's on screen in agreement. Mirrored in the engine
|
|
340
358
|
// path's `transportEndFor` (engine/scheduler.ts) — change both together.
|
|
341
359
|
//
|
|
342
|
-
// Audio stays OUT
|
|
343
|
-
// is documented in timeline-core's
|
|
360
|
+
// Audio stays OUT of the ceiling whenever anything VISUAL sets one: the
|
|
361
|
+
// canvas/video divergence over the audio tail is documented in timeline-core's
|
|
362
|
+
// `durations.js` and is deliberate.
|
|
363
|
+
//
|
|
364
|
+
// It cannot stay out when nothing visual sets one at all, though. An
|
|
365
|
+
// audio-only timeline — an animations-workflow project whose music is wired
|
|
366
|
+
// before any overlay exists — left this at 0, so the first tick clamped to 0
|
|
367
|
+
// and immediately called `setIsPlaying(false)`: play/space did nothing
|
|
368
|
+
// whatsoever, with no feedback saying why. The audio end is the last-resort
|
|
369
|
+
// ceiling for exactly that case and changes nothing for any project that has
|
|
370
|
+
// visual content. Mirrored in `transportEndFor` (engine/scheduler.ts) — change
|
|
371
|
+
// both together.
|
|
344
372
|
const canvasMaxEndRef = useRef(0)
|
|
345
373
|
useEffect(() => {
|
|
346
374
|
const captionEnd = (project.captions?.segments ?? []).reduce((m: number, s) => Math.max(m, s.end), 0)
|
|
347
|
-
|
|
375
|
+
const visualCeiling = Math.max(
|
|
348
376
|
enabledTrackItems(project).flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
|
|
349
377
|
captionEnd,
|
|
350
378
|
)
|
|
379
|
+
canvasMaxEndRef.current = visualCeiling > 0 ? visualCeiling : audioEnd(project)
|
|
351
380
|
}, [project])
|
|
352
381
|
|
|
353
382
|
useEffect(() => {
|
|
@@ -431,7 +460,7 @@ export function useVideoPlayback(
|
|
|
431
460
|
const ctx = getSharedAudioContext()
|
|
432
461
|
const source = ctx.createMediaElementSource(el)
|
|
433
462
|
const gain = ctx.createGain()
|
|
434
|
-
gain.gain.value = track.volume ?? 1
|
|
463
|
+
gain.gain.value = mutedRef.current ? 0 : (track.volume ?? 1)
|
|
435
464
|
source.connect(gain)
|
|
436
465
|
gain.connect(ctx.destination)
|
|
437
466
|
gains.set(track.id, gain)
|
|
@@ -442,18 +471,21 @@ export function useVideoPlayback(
|
|
|
442
471
|
}
|
|
443
472
|
// Volume is controlled via GainNode, not el.volume
|
|
444
473
|
const gain = gains.get(track.id)
|
|
445
|
-
if (gain) gain.gain.value = track.volume ?? 1
|
|
474
|
+
if (gain) gain.gain.value = mutedRef.current ? 0 : (track.volume ?? 1)
|
|
446
475
|
}
|
|
447
476
|
// Keyed on identity string — only fires when tracks are added/removed/src changes
|
|
448
477
|
}, [audioTrackIdentity])
|
|
449
478
|
|
|
450
|
-
// Update volume in-place on every render via GainNode — cheap, no element churn
|
|
479
|
+
// Update volume in-place on every render via GainNode — cheap, no element churn.
|
|
480
|
+
// `muted` is a dep (not just read via `mutedRef`) so an external mute toggle
|
|
481
|
+
// reaches every lane immediately rather than waiting for the next track-set
|
|
482
|
+
// change or `syncAudioTracks` tick.
|
|
451
483
|
useEffect(() => {
|
|
452
484
|
for (const track of unmutedAudioTracks) {
|
|
453
485
|
const gain = gainNodesMap.current.get(track.id)
|
|
454
|
-
if (gain) gain.gain.value = track.volume ?? 1
|
|
486
|
+
if (gain) gain.gain.value = mutedRef.current ? 0 : (track.volume ?? 1)
|
|
455
487
|
}
|
|
456
|
-
}, [unmutedAudioTracks])
|
|
488
|
+
}, [unmutedAudioTracks, muted])
|
|
457
489
|
|
|
458
490
|
// Cleanup on unmount only. The shared AudioContext (window.__montajSharedCtx)
|
|
459
491
|
// is intentionally NOT closed — it's window-scoped and reused across remounts
|
|
@@ -504,7 +536,7 @@ export function useVideoPlayback(
|
|
|
504
536
|
|
|
505
537
|
// `audioWindow.gain` is already `baseVolume * max(0, fadeMul)`.
|
|
506
538
|
const gain = gainNodesMap.current.get(track.id)
|
|
507
|
-
if (gain) gain.gain.value = win.gain
|
|
539
|
+
if (gain) gain.gain.value = mutedRef.current ? 0 : win.gain
|
|
508
540
|
}
|
|
509
541
|
}, [])
|
|
510
542
|
|
|
@@ -640,7 +672,7 @@ export function useVideoPlayback(
|
|
|
640
672
|
const src = fileUrlRef.current(playbackSrcFor(nc))
|
|
641
673
|
if (preloadSrcRef.current !== src) { nv.src = src; nv.currentTime = effectiveInPoint(nc) }
|
|
642
674
|
const gain = ensureVideoGain(ns)
|
|
643
|
-
if (gain) gain.gain.value = clipGain(videoTrack, nc)
|
|
675
|
+
if (gain) gain.gain.value = mutedRef.current ? 0 : clipGain(videoTrack, nc)
|
|
644
676
|
playSoon(nv)
|
|
645
677
|
}
|
|
646
678
|
void (activeSlotRef.current === 0 ? video0Ref.current : video1Ref.current)?.pause()
|
|
@@ -758,7 +790,7 @@ export function useVideoPlayback(
|
|
|
758
790
|
inactiveVideo.currentTime = effectiveInPoint(clips[nextIdx])
|
|
759
791
|
const inactiveSlot = (1 - slot) as 0 | 1
|
|
760
792
|
const nextGain = ensureVideoGain(inactiveSlot)
|
|
761
|
-
if (nextGain) nextGain.gain.value = clipGain(videoTrack, clips[nextIdx])
|
|
793
|
+
if (nextGain) nextGain.gain.value = mutedRef.current ? 0 : clipGain(videoTrack, clips[nextIdx])
|
|
762
794
|
}
|
|
763
795
|
}
|
|
764
796
|
|
|
@@ -813,7 +845,7 @@ export function useVideoPlayback(
|
|
|
813
845
|
nextVideo.currentTime = effectiveInPoint(next)
|
|
814
846
|
}
|
|
815
847
|
const nextGain = ensureVideoGain(nextSlot)
|
|
816
|
-
if (nextGain) nextGain.gain.value = clipGain(videoTrack, next)
|
|
848
|
+
if (nextGain) nextGain.gain.value = mutedRef.current ? 0 : clipGain(videoTrack, next)
|
|
817
849
|
playSoon(nextVideo)
|
|
818
850
|
}
|
|
819
851
|
|
|
@@ -1016,12 +1016,14 @@ export default function TimelineCanvas({
|
|
|
1016
1016
|
//
|
|
1017
1017
|
// Standard NLE behaviour: drag an item/handle past the visible edge and the
|
|
1018
1018
|
// view pans to follow, rather than trapping the gesture at whatever was on
|
|
1019
|
-
// screen when the drag started.
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1019
|
+
// screen when the drag started. Every `dragging` state qualifies, including
|
|
1020
|
+
// `scrub` — dragging the playhead to the edge should extend the visible
|
|
1021
|
+
// range the same way dragging a clip does, rather than capping the seek at
|
|
1022
|
+
// whatever was on screen when the scrub started. `applyScrub` resolves an
|
|
1023
|
+
// absolute time from the screen point each call, so re-feeding the same
|
|
1024
|
+
// point after a pan naturally advances the seek. Marquee selection is
|
|
1025
|
+
// included too: dragging the box out past the edge to catch items further
|
|
1026
|
+
// along the timeline is the same affordance.
|
|
1025
1027
|
|
|
1026
1028
|
function dispatchPointerMove(point: Point, modifiers: Modifiers) {
|
|
1027
1029
|
runEffects(machine.dispatch({ type: 'pointerMove', point, modifiers, ctx: buildContext() }))
|
|
@@ -1043,7 +1045,7 @@ export default function TimelineCanvas({
|
|
|
1043
1045
|
edgeScrollFrameRef.current = null
|
|
1044
1046
|
|
|
1045
1047
|
const state = machine.state
|
|
1046
|
-
if (state.kind !== 'dragging'
|
|
1048
|
+
if (state.kind !== 'dragging') { stopEdgeAutoScroll(); return }
|
|
1047
1049
|
const drag = lastDragPointRef.current
|
|
1048
1050
|
const rect = gestureRectRef.current
|
|
1049
1051
|
if (!drag || !rect || rect.width <= 0) { stopEdgeAutoScroll(); return }
|
|
@@ -1088,7 +1090,7 @@ export default function TimelineCanvas({
|
|
|
1088
1090
|
* that leaves the zone is caught on the loop's own next tick). */
|
|
1089
1091
|
function updateEdgeAutoScroll() {
|
|
1090
1092
|
const state = machine.state
|
|
1091
|
-
if (state.kind !== 'dragging'
|
|
1093
|
+
if (state.kind !== 'dragging') { stopEdgeAutoScroll(); return }
|
|
1092
1094
|
const drag = lastDragPointRef.current
|
|
1093
1095
|
const rect = gestureRectRef.current
|
|
1094
1096
|
if (!drag || !rect || rect.width <= 0 || !inEdgeZone(drag.point.x, rect.width)) return
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* The ramp/clamp MATH is `edgeScrollDelta` in viewport.ts, covered exhaustively
|
|
9
9
|
* as pure data in viewport.test.ts. What can only be shown with a mounted
|
|
10
10
|
* component is here: that a real drag actually starts the loop, that panning
|
|
11
|
-
* re-feeds the pointer machine so the dragged item
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* re-feeds the pointer machine so the dragged item (or, for a ruler scrub,
|
|
12
|
+
* the playhead) keeps tracking, that it clamps and stops at the legal scroll
|
|
13
|
+
* range, and that it stands down when the pointer leaves the zone or the
|
|
14
|
+
* drag ends.
|
|
14
15
|
*
|
|
15
16
|
* jsdom's `performance.now()` is NOT tied to Vitest's fake timers (verified:
|
|
16
17
|
* advancing the fake clock by 16ms moves it by a fraction of a millisecond of
|
|
@@ -306,7 +307,7 @@ describe('TimelineCanvas — edge auto-scroll', () => {
|
|
|
306
307
|
}
|
|
307
308
|
})
|
|
308
309
|
|
|
309
|
-
it('
|
|
310
|
+
it('auto-scrolls for a ruler scrub held at the edge, same as any other drag', () => {
|
|
310
311
|
const perf = stubPerfNow()
|
|
311
312
|
try {
|
|
312
313
|
const { surface, store, clock } = mount()
|
|
@@ -316,13 +317,43 @@ describe('TimelineCanvas — edge auto-scroll', () => {
|
|
|
316
317
|
expect(clock.get()).toBeCloseTo(9.9)
|
|
317
318
|
act(() => { document.dispatchEvent(mouse('mousemove', 990, RULER_Y)) })
|
|
318
319
|
|
|
320
|
+
act(() => { vi.advanceTimersByTime(20) }) // seed
|
|
319
321
|
perf.advance(1000)
|
|
320
|
-
act(() => { vi.advanceTimersByTime(
|
|
322
|
+
act(() => { vi.advanceTimersByTime(20) }) // one real pan
|
|
323
|
+
|
|
324
|
+
// The view panned to follow the scrub, same as it would for a clip
|
|
325
|
+
// drag, and the playhead kept tracking the held screen point.
|
|
326
|
+
expect(store.get().scrollSeconds).toBeGreaterThan(0)
|
|
327
|
+
expect(clock.get()).toBeGreaterThan(9.9)
|
|
328
|
+
|
|
329
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 990, RULER_Y)) })
|
|
330
|
+
|
|
331
|
+
const pannedTo = store.get().scrollSeconds
|
|
321
332
|
perf.advance(5000)
|
|
322
333
|
act(() => { vi.advanceTimersByTime(200) })
|
|
334
|
+
expect(store.get().scrollSeconds).toBe(pannedTo)
|
|
335
|
+
} finally {
|
|
336
|
+
perf.restore()
|
|
337
|
+
}
|
|
338
|
+
})
|
|
323
339
|
|
|
324
|
-
|
|
325
|
-
|
|
340
|
+
it('scrub auto-scroll clamps at the rightmost legal scroll and stops panning', () => {
|
|
341
|
+
const perf = stubPerfNow()
|
|
342
|
+
try {
|
|
343
|
+
const { surface, store } = mount()
|
|
344
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 990, RULER_Y)) })
|
|
345
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 990, RULER_Y)) })
|
|
346
|
+
|
|
347
|
+
// x=990 pans at ≈0.393s/tick at this scale (see the equivalent
|
|
348
|
+
// non-scrub clamp test above) — 140 ticks clears RIGHTMOST_SCROLL
|
|
349
|
+
// (52.5s) and then some, to prove it holds there rather than merely
|
|
350
|
+
// arriving at it.
|
|
351
|
+
for (let i = 0; i < 140; i++) {
|
|
352
|
+
perf.advance(1000)
|
|
353
|
+
act(() => { vi.advanceTimersByTime(20) })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
expect(store.get().scrollSeconds).toBeCloseTo(RIGHTMOST_SCROLL, 5)
|
|
326
357
|
|
|
327
358
|
act(() => { document.dispatchEvent(mouse('mouseup', 990, RULER_Y)) })
|
|
328
359
|
} finally {
|