@bycrux/editor 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/ControlsInfoModal.tsx +1 -0
  3. package/src/engine/__tests__/scheduler.test.ts +56 -1
  4. package/src/engine/scheduler.ts +23 -5
  5. package/src/index.ts +36 -0
  6. package/src/lib/__tests__/font-faces.test.ts +244 -0
  7. package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
  8. package/src/lib/__tests__/google-fonts.test.ts +319 -2
  9. package/src/lib/font-families.ts +286 -0
  10. package/src/lib/google-fonts.ts +132 -10
  11. package/src/schema.ts +21 -0
  12. package/src/text/FontPicker.tsx +82 -11
  13. package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
  14. package/src/video/VersionPanel.tsx +1 -1
  15. package/src/video/VideoEditor.tsx +98 -4
  16. package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
  17. package/src/video/__tests__/cuts.test.ts +84 -0
  18. package/src/video/__tests__/exportDurationSec.test.ts +60 -0
  19. package/src/video/__tests__/markerDropTime.test.ts +25 -0
  20. package/src/video/cuts.ts +30 -2
  21. package/src/video/preview/PreviewPlayer.tsx +52 -2
  22. package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
  23. package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
  24. package/src/video/preview/useEnginePlayback.ts +58 -8
  25. package/src/video/preview/useVideoPlayback.ts +64 -17
  26. package/src/video/timeline/Timeline.tsx +6 -0
  27. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
  28. package/src/video/timeline/__tests__/markers.test.ts +125 -0
  29. package/src/video/timeline/canvas/TimelineCanvas.tsx +110 -12
  30. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
  31. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
  32. package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
  33. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
  34. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +100 -1
  35. package/src/video/timeline/canvas/draw.ts +182 -8
  36. package/src/video/timeline/canvas/hit-test.ts +72 -1
  37. package/src/video/timeline/canvas/pointer-machine.ts +83 -4
  38. package/src/video/timeline/markers.ts +109 -0
  39. package/src/video/timeline/timeline-model.ts +19 -0
@@ -112,6 +112,47 @@ interface PreviewPlayerProps {
112
112
  * note at its render site below.
113
113
  */
114
114
  socialPreview?: SocialPreviewPlatform | string | null
115
+ /**
116
+ * A second rider on this same pin bump (operator decision, 2026-09-20) —
117
+ * not font work, riding because it is the same package and the same
118
+ * release. Silences every audio path this component owns. Default `false`
119
+ * — today's behavior, unchanged, for every existing host.
120
+ *
121
+ * Exists for hosts that mount a live timeline preview somewhere audio isn't
122
+ * wanted (the project-card hover preview: moving the pointer across a grid
123
+ * must not play each project's audio in turn). `<video muted>` is NOT
124
+ * sufficient on its own — see `ensureVideoGain` and the "Multi-track audio
125
+ * management" section in `useVideoPlayback.ts`: once a slot or a lane is
126
+ * wired `MediaElementSource → GainNode → ctx.destination`, the element's
127
+ * own `muted`/`volume` stop having any audible effect and the GainNode is
128
+ * the only real lever. This prop zeroes every such GainNode (video slots
129
+ * AND background audio-track lanes) via `mutedRef` in `useVideoPlayback.ts`
130
+ * — the `muted` attribute set on the `<video>` slots below is defense in
131
+ * depth for the brief pre-wire window, not the mechanism.
132
+ *
133
+ * Read the scope of that precisely: this silences every audio path these
134
+ * hooks INSTANTIATE, which is not the same as every path reachable through
135
+ * a seam this component EXPOSES. The audible drag-scrub source
136
+ * (`engine/scrub-source.ts`) owns its own gain → destination chain on the
137
+ * same shared AudioContext, and `muted` does not touch it — it is
138
+ * constructed and driven by `VideoEditor`, and only reached here via the
139
+ * `ScrubHandle` seam below. That is sound today because a scrub needs a
140
+ * timeline-drag gesture that a thumbnail-hover mount has no UI for, so a
141
+ * silent host cannot reach it. A host that both passes `muted` and wires
142
+ * scrubbing would hear it, and would be right to call that a bug.
143
+ *
144
+ * Engine-mode (`engine.enabled`) coverage is PARTIAL: `useEnginePlayback.ts`
145
+ * zeroes the same background-lane GainNodes, but track-0 video-item audio
146
+ * is not reached, because this rider did not extend to the engine's own
147
+ * clock. It is a scope boundary, NOT a hard one — the clip's level already
148
+ * rides a per-session output `GainNode` with a live `MasterClock.setVolume`
149
+ * lever wired through `engine/index.ts`, so closing it means pushing 0 down
150
+ * that existing path. See that hook's own comment, which spells out why and
151
+ * corrects a stale claim in its file header about PCM-level scaling. No
152
+ * current host combines `engine.enabled` with `muted`, so this is a
153
+ * documented gap, not a live bug.
154
+ */
155
+ muted?: boolean
115
156
  }
116
157
 
117
158
  export default function PreviewPlayer(props: PreviewPlayerProps) {
@@ -245,12 +286,12 @@ type SurfaceProps = PreviewPlayerProps & {
245
286
  }
246
287
 
247
288
  function LegacyPreview(props: SurfaceProps) {
248
- const playback = useVideoPlayback(props.project, props.currentTime, props.timeSink, props.fileUrl)
289
+ const playback = useVideoPlayback(props.project, props.currentTime, props.timeSink, props.fileUrl, !!props.muted)
249
290
  return <PreviewSurface {...props} playback={{ mode: 'legacy', ...playback }} />
250
291
  }
251
292
 
252
293
  function EnginePreview(props: SurfaceProps) {
253
- const playback = useEnginePlayback(props.project, props.currentTime, props.timeSink, props.fileUrl)
294
+ const playback = useEnginePlayback(props.project, props.currentTime, props.timeSink, props.fileUrl, !!props.muted)
254
295
  return <PreviewSurface {...props} playback={{ mode: 'engine', ...playback }} />
255
296
  }
256
297
 
@@ -275,6 +316,7 @@ function PreviewSurface({
275
316
  transportRef,
276
317
  scrubHandleRef,
277
318
  socialPreview,
319
+ muted,
278
320
  }: SurfaceProps & { playback: PlaybackBinding }) {
279
321
  const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
280
322
 
@@ -497,6 +539,12 @@ function PreviewSurface({
497
539
  onPlay={() => { if (playback.activeSlotRef.current === 0) playback.setIsPlaying(true) }}
498
540
  onPause={() => { if (playback.activeSlotRef.current === 0) playback.handlePause() }}
499
541
  playsInline
542
+ // Defense in depth, not the mechanism — see the `muted` prop
543
+ // doc above. Once `ensureVideoGain` wires this element through
544
+ // Web Audio (on first play), this attribute stops having any
545
+ // audible effect; the GainNode zeroed via `mutedRef` in
546
+ // `useVideoPlayback.ts` is what actually silences it.
547
+ muted={!!muted}
500
548
  style={{ ...baseVideoStyle, opacity: showVideo && playback.activeSlot === 0 ? 1 : 0, pointerEvents: playback.activeSlot === 0 ? 'auto' : 'none', zIndex: playback.activeSlot === 0 ? 1 : 0 }}
501
549
  />
502
550
  {/* Slot 1 */}
@@ -513,6 +561,8 @@ function PreviewSurface({
513
561
  onPlay={() => { if (playback.activeSlotRef.current === 1) playback.setIsPlaying(true) }}
514
562
  onPause={() => { if (playback.activeSlotRef.current === 1) playback.handlePause() }}
515
563
  playsInline
564
+ // See slot 0.
565
+ muted={!!muted}
516
566
  style={{ ...baseVideoStyle, opacity: showVideo && playback.activeSlot === 1 ? 1 : 0, pointerEvents: playback.activeSlot === 1 ? 'auto' : 'none', zIndex: playback.activeSlot === 1 ? 1 : 0 }}
517
567
  />
518
568
  </>
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The canvas rAF clock's CEILING, driven through the real effect.
3
+ *
4
+ * `canvasMaxEndRef` is the only thing standing between "space plays" and "space
5
+ * does nothing" on a project with no track-0 video. It used to read
6
+ * `overlayTracks` (`tracks.slice(1)`), which silently skips track 0 — and track
7
+ * 0 in canvas mode is not the primary footage track, it is content: the
8
+ * background images, and on an agent-authored project frequently the overlays
9
+ * themselves. A project that is ONE track of nothing but overlays (what the
10
+ * animations workflow emits) therefore got a ceiling of 0, and the first tick
11
+ * clamped the playhead to 0 and called `setIsPlaying(false)` in the same frame.
12
+ *
13
+ * These drive the hook's own rAF effect rather than asserting on a helper: the
14
+ * ceiling lives in a ref that nothing exports, so the only honest way to pin it
15
+ * is to run the clock and watch where time goes. Each case fails on the
16
+ * pre-fix `overlayTracks` read.
17
+ */
18
+ import { describe, it, expect, vi, afterEach } from 'vitest'
19
+ import { act, renderHook } from '@testing-library/react'
20
+ import { useVideoPlayback } from '../useVideoPlayback'
21
+ import type { EditorProject, VisualItem } from '../../../schema'
22
+
23
+ const overlay = (id: string, start: number, end: number): VisualItem =>
24
+ ({ id, type: 'overlay', src: `/overlays/${id}.jsx`, start, end }) as VisualItem
25
+
26
+ const image = (id: string, start: number, end: number): VisualItem =>
27
+ ({ id, type: 'image', src: `/img/${id}.png`, start, end }) as VisualItem
28
+
29
+ /** The last emission. Not `.at(-1)` — this package targets ES2020. */
30
+ const last = (xs: number[]): number | undefined => xs[xs.length - 1]
31
+
32
+ function projectOf(...tracks: VisualItem[][]): EditorProject {
33
+ return {
34
+ id: 'canvas-clock',
35
+ status: 'draft',
36
+ settings: { resolution: [1920, 1080] },
37
+ tracks: tracks.map((items, i) => ({ id: `trk-${i}`, items })),
38
+ } as EditorProject
39
+ }
40
+
41
+ /**
42
+ * Mount the hook on a canvas project and run the clock for one second of wall
43
+ * time, returning every playhead value it emitted.
44
+ *
45
+ * Two ticks, because the first only anchors `rafLastMs` — it has no `dt` yet
46
+ * and emits nothing. The second is the first that can move the playhead, and
47
+ * the one the collapsed ceiling used to kill.
48
+ */
49
+ function runClock(project: EditorProject) {
50
+ const times: number[] = []
51
+ const scheduled: FrameRequestCallback[] = []
52
+ vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
53
+ scheduled.push(cb)
54
+ return scheduled.length
55
+ })
56
+
57
+ const view = renderHook(() => useVideoPlayback(project, 0, (t) => times.push(t), (p) => p))
58
+ expect(view.result.current.isCanvasProject).toBe(true)
59
+
60
+ scheduled.length = 0
61
+ act(() => { view.result.current.setIsPlaying(true) })
62
+ expect(scheduled.length).toBeGreaterThan(0)
63
+
64
+ act(() => { scheduled[0](0) }) // anchor only
65
+ act(() => { scheduled[scheduled.length - 1](1000) }) // +1s of wall clock
66
+
67
+ return { times, isPlaying: view.result.current.isPlaying }
68
+ }
69
+
70
+ describe('useVideoPlayback — the canvas clock counts track 0', () => {
71
+ afterEach(() => { vi.restoreAllMocks() })
72
+
73
+ it('advances on a single track holding nothing but overlays', () => {
74
+ // The Daubert-demo shape: one track, 14 overlays, no captions, no video.
75
+ const { times, isPlaying } = runClock(projectOf([overlay('o1', 0, 5), overlay('o2', 5, 12)]))
76
+ expect(last(times)).toBeCloseTo(1, 6)
77
+ expect(isPlaying).toBe(true)
78
+ })
79
+
80
+ it('advances on a track-0 image that outlasts every overlay track', () => {
81
+ // Same defect, other content kind — the background image was invisible to
82
+ // the ceiling, so a slideshow stopped at its last overlay.
83
+ //
84
+ // The overlay track has to end INSIDE the second this clock runs (0.4 < 1),
85
+ // or `overlayTracks` alone already carries the playhead to 1 and the case
86
+ // passes on the broken code without ever reading track 0.
87
+ const { times } = runClock(projectOf([image('bg', 0, 20)], [overlay('o', 0, 0.4)]))
88
+ expect(last(times)).toBeCloseTo(1, 6)
89
+ })
90
+
91
+ it('still stops at the ceiling rather than running past the content', () => {
92
+ // The clamp itself is not what was wrong, and must survive: one second of
93
+ // wall clock against a half-second project lands on the end, not past it,
94
+ // and playback stops there.
95
+ const { times, isPlaying } = runClock(projectOf([overlay('o', 0, 0.5)]))
96
+ expect(last(times)).toBeCloseTo(0.5, 6)
97
+ expect(isPlaying).toBe(false)
98
+ })
99
+ })
@@ -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(() => {