@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.
@@ -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 scales the PCM at ring-enqueue time (T4), reached from
38
- * `engine/index.ts`'s `SourceRequest` → `request.item.volume`. The TRACK's
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, unchanged: the canvas/video divergence over the audio tail
343
- // is documented in timeline-core's `durations.js` and is not this fix.
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
- canvasMaxEndRef.current = Math.max(
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. Only gestures where "the pointer is
1020
- // captured and following makes sense" qualify — every `dragging` state
1021
- // EXCEPT `scrub` (the ruler already owns the playhead directly; panning
1022
- // underneath it while it drags would fight the seek instead of extending
1023
- // it). Marquee selection is included: dragging the box out past the edge to
1024
- // catch items further along the timeline is the same affordance.
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' || state.gesture === 'scrub') { stopEdgeAutoScroll(); return }
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' || state.gesture === 'scrub') { stopEdgeAutoScroll(); return }
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 keeps tracking, that it
12
- * clamps and stops at the legal scroll range, and that it stands down when the
13
- * pointer leaves the zone, the drag ends, or the gesture is a ruler scrub.
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('does not auto-scroll for a ruler scrub, even with the pointer held at the edge', () => {
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(200) })
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
- // The playhead moved (that's what a scrub does); the VIEWPORT did not.
325
- expect(store.get().scrollSeconds).toBe(0)
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 {