@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.
- package/package.json +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- package/src/engine/__tests__/scheduler.test.ts +56 -1
- package/src/engine/scheduler.ts +23 -5
- package/src/index.ts +36 -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/schema.ts +21 -0
- package/src/text/FontPicker.tsx +82 -11
- package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
- package/src/video/VersionPanel.tsx +1 -1
- package/src/video/VideoEditor.tsx +98 -4
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
- package/src/video/__tests__/cuts.test.ts +84 -0
- package/src/video/__tests__/exportDurationSec.test.ts +60 -0
- package/src/video/__tests__/markerDropTime.test.ts +25 -0
- package/src/video/cuts.ts +30 -2
- package/src/video/preview/PreviewPlayer.tsx +52 -2
- package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- 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 +64 -17
- package/src/video/timeline/Timeline.tsx +6 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
- package/src/video/timeline/__tests__/markers.test.ts +125 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +110 -12
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
- package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +100 -1
- package/src/video/timeline/canvas/draw.ts +182 -8
- package/src/video/timeline/canvas/hit-test.ts +72 -1
- package/src/video/timeline/canvas/pointer-machine.ts +83 -4
- package/src/video/timeline/markers.ts +109 -0
- 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
|
|
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(() => {
|