@bycrux/editor 0.11.2 → 1.0.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 +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T6 — the clock bridge, the gesture anchors and the audio-lane slaving.
|
|
3
|
+
*
|
|
4
|
+
* The engine itself is mocked: everything under `src/engine/` is decode,
|
|
5
|
+
* WebCodecs and rAF, none of which exist in jsdom, and none of which this file
|
|
6
|
+
* is about. What IS under test is the seam — who seeks whom, when, and exactly
|
|
7
|
+
* once — so the fake engine records calls and lets each test drive `onTime` /
|
|
8
|
+
* `onStatusChange` by hand at the moment the real engine would.
|
|
9
|
+
*
|
|
10
|
+
* `currentTime` is the prop `PreviewPlayer` feeds from `usePlaybackTime(clock)`;
|
|
11
|
+
* a `rerender` with a new value is exactly what a Scrubber/Timeline/track-row
|
|
12
|
+
* `clock.set(t)` looks like from inside this hook.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
15
|
+
import { renderHook, act } from '@testing-library/react'
|
|
16
|
+
// jsdom implements neither `play()` nor `pause()`. Installed once, at module
|
|
17
|
+
// scope, rather than as a per-test spy: Testing Library's own `afterEach`
|
|
18
|
+
// cleanup unmounts the hook — and therefore runs the audio-lane teardown, which
|
|
19
|
+
// pauses every element — AFTER a `restoreAllMocks()` in this file would have
|
|
20
|
+
// put the unimplemented originals back.
|
|
21
|
+
Object.defineProperty(HTMLMediaElement.prototype, 'paused', {
|
|
22
|
+
configurable: true,
|
|
23
|
+
get(this: HTMLMediaElement & { __paused?: boolean }) { return this.__paused !== false },
|
|
24
|
+
})
|
|
25
|
+
HTMLMediaElement.prototype.play = function (this: HTMLMediaElement & { __paused?: boolean }) {
|
|
26
|
+
this.__paused = false
|
|
27
|
+
return Promise.resolve()
|
|
28
|
+
}
|
|
29
|
+
HTMLMediaElement.prototype.pause = function (this: HTMLMediaElement & { __paused?: boolean }) {
|
|
30
|
+
this.__paused = true
|
|
31
|
+
}
|
|
32
|
+
// jsdom has no `AudioContext` at all. Fix 5 makes `togglePlay` call
|
|
33
|
+
// `getSharedAudioContext()` unconditionally (every real browser has one), so
|
|
34
|
+
// without a stub ANY `togglePlay()`/Space call in this file would throw a
|
|
35
|
+
// `ReferenceError` the moment it tries `new AudioContext()` — including tests
|
|
36
|
+
// that have nothing to do with audio and never call `stubSuspendedContext()`.
|
|
37
|
+
// A minimal, harmless default at module scope; tests that care about
|
|
38
|
+
// suspend/resume behavior still control `state`/`resume` themselves (see
|
|
39
|
+
// `stubSuspendedContext` and the 'audio lanes' describe block's own richer
|
|
40
|
+
// stub, both of which override this per-test).
|
|
41
|
+
;(window as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
42
|
+
state = 'running'
|
|
43
|
+
destination = {}
|
|
44
|
+
resume() {
|
|
45
|
+
return Promise.resolve()
|
|
46
|
+
}
|
|
47
|
+
createMediaElementSource() {
|
|
48
|
+
return { connect() {} }
|
|
49
|
+
}
|
|
50
|
+
createGain() {
|
|
51
|
+
return { gain: { value: 1 }, connect() {} }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
import type { EditorProject as Project } from '../../../schema'
|
|
55
|
+
import type { EngineStats, EngineStatus } from '../../../engine'
|
|
56
|
+
|
|
57
|
+
interface FakeEngine {
|
|
58
|
+
deps: {
|
|
59
|
+
onTime?: (t: number) => void
|
|
60
|
+
onStatusChange?: (s: EngineStatus) => void
|
|
61
|
+
onError?: (m: string) => void
|
|
62
|
+
startProjectS?: number
|
|
63
|
+
fileUrl: (p: string) => string
|
|
64
|
+
}
|
|
65
|
+
project: Project
|
|
66
|
+
transport: EngineStatus['transport']
|
|
67
|
+
now: number
|
|
68
|
+
seeks: number[]
|
|
69
|
+
rates: number[]
|
|
70
|
+
plays: number
|
|
71
|
+
pauses: number
|
|
72
|
+
disposes: number
|
|
73
|
+
updates: Project[]
|
|
74
|
+
attaches: Array<HTMLCanvasElement | null>
|
|
75
|
+
statsCalls: number
|
|
76
|
+
/** Emit a playhead the way a real tick would. */
|
|
77
|
+
emit(t: number): void
|
|
78
|
+
/** Move the transport and publish, the way `play()`/`pause()` do. */
|
|
79
|
+
setTransport(t: EngineStatus['transport']): void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const engines = vi.hoisted(() => [] as unknown[])
|
|
83
|
+
|
|
84
|
+
vi.mock('../../../engine', async (importOriginal) => {
|
|
85
|
+
const actual = await importOriginal<typeof import('../../../engine')>()
|
|
86
|
+
return {
|
|
87
|
+
...actual,
|
|
88
|
+
createEngine: (project: Project, deps: FakeEngine['deps']) => {
|
|
89
|
+
const status = (): EngineStatus => ({
|
|
90
|
+
transport: engine.transport,
|
|
91
|
+
picture: 'video',
|
|
92
|
+
clipId: null,
|
|
93
|
+
seeking: false,
|
|
94
|
+
clock: 'fallback',
|
|
95
|
+
})
|
|
96
|
+
const engine: FakeEngine & {
|
|
97
|
+
attach: (c: HTMLCanvasElement | null) => void
|
|
98
|
+
play: () => void
|
|
99
|
+
pause: () => void
|
|
100
|
+
seek: (t: number) => void
|
|
101
|
+
setRate: (r: number) => void
|
|
102
|
+
updateProject: (p: Project) => void
|
|
103
|
+
status: () => EngineStatus
|
|
104
|
+
clock: { now: () => number; playing: boolean; kind: 'audio' | 'fallback' }
|
|
105
|
+
stats: () => EngineStats
|
|
106
|
+
dispose: () => void
|
|
107
|
+
} = {
|
|
108
|
+
deps,
|
|
109
|
+
project,
|
|
110
|
+
transport: 'paused',
|
|
111
|
+
now: deps.startProjectS ?? 0,
|
|
112
|
+
seeks: [],
|
|
113
|
+
rates: [],
|
|
114
|
+
plays: 0,
|
|
115
|
+
pauses: 0,
|
|
116
|
+
disposes: 0,
|
|
117
|
+
updates: [],
|
|
118
|
+
attaches: [],
|
|
119
|
+
statsCalls: 0,
|
|
120
|
+
emit(t: number) {
|
|
121
|
+
engine.now = t
|
|
122
|
+
deps.onTime?.(t)
|
|
123
|
+
},
|
|
124
|
+
setTransport(t) {
|
|
125
|
+
engine.transport = t
|
|
126
|
+
deps.onStatusChange?.(status())
|
|
127
|
+
},
|
|
128
|
+
attach: (c) => { engine.attaches.push(c) },
|
|
129
|
+
play: () => { engine.plays++; engine.transport = 'playing'; deps.onStatusChange?.(status()) },
|
|
130
|
+
pause: () => { engine.pauses++; engine.transport = 'paused'; deps.onStatusChange?.(status()) },
|
|
131
|
+
seek: (t: number) => { engine.seeks.push(t); engine.now = t },
|
|
132
|
+
setRate: (r: number) => { engine.rates.push(r) },
|
|
133
|
+
updateProject: (p: Project) => { engine.updates.push(p) },
|
|
134
|
+
status,
|
|
135
|
+
clock: {
|
|
136
|
+
get now() { return () => engine.now },
|
|
137
|
+
get playing() { return engine.transport === 'playing' },
|
|
138
|
+
kind: 'fallback' as const,
|
|
139
|
+
} as unknown as { now: () => number; playing: boolean; kind: 'audio' | 'fallback' },
|
|
140
|
+
stats: () => {
|
|
141
|
+
engine.statsCalls++
|
|
142
|
+
return { fps: 24, dropped: 1, buffered: 2, clock: 'fallback' }
|
|
143
|
+
},
|
|
144
|
+
dispose: () => { engine.disposes++ },
|
|
145
|
+
}
|
|
146
|
+
engines.push(engine)
|
|
147
|
+
return engine
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Spy-wrapped, not replaced: `togglePlay` must reach the REAL
|
|
153
|
+
// `resumeAudioContextFromGesture` (its own suspended-context behavior is
|
|
154
|
+
// exercised via `window.__montajSharedCtx` below, unchanged), but Fix 5
|
|
155
|
+
// needs to assert `getSharedAudioContext` is actually called — it is the
|
|
156
|
+
// call that CREATES the context, which `resumeAudioContextFromGesture`
|
|
157
|
+
// deliberately never does (see that function's own doc).
|
|
158
|
+
vi.mock('../audio-context', async (importOriginal) => {
|
|
159
|
+
const actual = await importOriginal<typeof import('../audio-context')>()
|
|
160
|
+
return {
|
|
161
|
+
...actual,
|
|
162
|
+
getSharedAudioContext: vi.fn(actual.getSharedAudioContext),
|
|
163
|
+
resumeAudioContextFromGesture: vi.fn(actual.resumeAudioContextFromGesture),
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// Imported AFTER the mock declaration for readability only — vi.mock is hoisted.
|
|
168
|
+
import { useEnginePlayback, SCRUB_DEAD_ZONE_S } from '../useEnginePlayback'
|
|
169
|
+
import { getSharedAudioContext, resumeAudioContextFromGesture } from '../audio-context'
|
|
170
|
+
|
|
171
|
+
function makeProject(overrides: Partial<Project> = {}): Project {
|
|
172
|
+
return {
|
|
173
|
+
id: 'engine-project',
|
|
174
|
+
status: 'draft',
|
|
175
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
176
|
+
tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', proxySrc: 'a_proxy.mp4', start: 0, end: 10 }] }],
|
|
177
|
+
...overrides,
|
|
178
|
+
} as Project
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function setup(project: Project = makeProject(), initialTime = 0) {
|
|
182
|
+
const onTimeUpdate = vi.fn<(t: number) => void>()
|
|
183
|
+
const view = renderHook(
|
|
184
|
+
({ t }: { t: number }) => useEnginePlayback(project, t, onTimeUpdate, (p) => p),
|
|
185
|
+
{ initialProps: { t: initialTime } },
|
|
186
|
+
)
|
|
187
|
+
const engine = engines[engines.length - 1] as FakeEngine
|
|
188
|
+
return { view, engine, onTimeUpdate }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
beforeEach(() => {
|
|
192
|
+
engines.length = 0
|
|
193
|
+
vi.mocked(getSharedAudioContext).mockClear()
|
|
194
|
+
vi.mocked(resumeAudioContextFromGesture).mockClear()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// ── The clock bridge ─────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
describe('useEnginePlayback clock bridge', () => {
|
|
200
|
+
it('engine echo → no seek', () => {
|
|
201
|
+
const { view, engine, onTimeUpdate } = setup()
|
|
202
|
+
|
|
203
|
+
act(() => { engine.emit(1.234) })
|
|
204
|
+
// Mirror-then-forward: the editor clock got the value…
|
|
205
|
+
expect(onTimeUpdate).toHaveBeenCalledWith(1.234)
|
|
206
|
+
|
|
207
|
+
// …and the store coming back around as a new `currentTime` is recognized
|
|
208
|
+
// as this hook's own echo, not as somebody asking to go to 1.234.
|
|
209
|
+
act(() => { view.rerender({ t: 1.234 }) })
|
|
210
|
+
expect(engine.seeks).toEqual([])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('engine echo → no seek even when React commits a STALE snapshot', () => {
|
|
214
|
+
// The dead zone alone cannot cover this: by the time the render carrying
|
|
215
|
+
// 1.0 commits, the engine has ticked on to 1.30, which is 0.30s away — six
|
|
216
|
+
// times the dead zone. Answering it with a seek would rewind the picture to
|
|
217
|
+
// a frame it had already left (and restart the decode stream doing it).
|
|
218
|
+
const { view, engine } = setup()
|
|
219
|
+
act(() => { engine.setTransport('playing') })
|
|
220
|
+
act(() => { for (const t of [1.0, 1.1, 1.2, 1.3]) engine.emit(t) })
|
|
221
|
+
|
|
222
|
+
act(() => { view.rerender({ t: 1.0 }) })
|
|
223
|
+
expect(engine.seeks).toEqual([])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('external set → exactly one seek', () => {
|
|
227
|
+
const { view, engine } = setup()
|
|
228
|
+
act(() => { engine.emit(0.5) })
|
|
229
|
+
|
|
230
|
+
act(() => { view.rerender({ t: 4 }) })
|
|
231
|
+
expect(engine.seeks).toEqual([4])
|
|
232
|
+
|
|
233
|
+
// The engine answers a seek by emitting the position it landed on. That
|
|
234
|
+
// echo must not produce a SECOND seek for the same gesture.
|
|
235
|
+
act(() => { engine.emit(4) })
|
|
236
|
+
act(() => { view.rerender({ t: 4 }) })
|
|
237
|
+
expect(engine.seeks).toEqual([4])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('ignores a store nudge inside the legacy dead zone', () => {
|
|
241
|
+
const { view, engine } = setup()
|
|
242
|
+
act(() => { engine.emit(2) })
|
|
243
|
+
act(() => { view.rerender({ t: 2 + SCRUB_DEAD_ZONE_S / 2 }) })
|
|
244
|
+
expect(engine.seeks).toEqual([])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('scrub during playback → seek + playback continues from the target', () => {
|
|
248
|
+
const { view, engine } = setup()
|
|
249
|
+
act(() => { engine.setTransport('playing') })
|
|
250
|
+
act(() => { for (const t of [0.1, 0.2, 0.3]) engine.emit(t) })
|
|
251
|
+
expect(engine.transport).toBe('playing')
|
|
252
|
+
|
|
253
|
+
act(() => { view.rerender({ t: 7 }) })
|
|
254
|
+
|
|
255
|
+
expect(engine.seeks).toEqual([7])
|
|
256
|
+
// The bridge never pauses to scrub — `engine.seek` keeps the transport, and
|
|
257
|
+
// the very next tick reports project time from the new position.
|
|
258
|
+
expect(engine.pauses).toBe(0)
|
|
259
|
+
expect(engine.transport).toBe('playing')
|
|
260
|
+
|
|
261
|
+
act(() => { engine.emit(7.05) })
|
|
262
|
+
expect(view.result.current.isPlaying).toBe(true)
|
|
263
|
+
act(() => { view.rerender({ t: 7.05 }) })
|
|
264
|
+
expect(engine.seeks).toEqual([7])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('never seeks a disposed engine', () => {
|
|
268
|
+
// Between project identities (and on unmount) the engine is torn down. The
|
|
269
|
+
// bridge holds the store as its mirror while there is nothing to drive, so
|
|
270
|
+
// a scrub arriving in that window is never replayed as a stale seek.
|
|
271
|
+
const { view, engine } = setup()
|
|
272
|
+
act(() => { view.unmount() })
|
|
273
|
+
expect(engine.disposes).toBe(1)
|
|
274
|
+
expect(engine.seeks).toEqual([])
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// ── Engine lifecycle ─────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
describe('useEnginePlayback engine lifecycle', () => {
|
|
281
|
+
it('builds one engine per project identity and forwards edits, not rebuilds', () => {
|
|
282
|
+
const project = makeProject()
|
|
283
|
+
const onTimeUpdate = vi.fn()
|
|
284
|
+
const view = renderHook(
|
|
285
|
+
({ p }: { p: Project }) => useEnginePlayback(p, 0, onTimeUpdate, (x) => x),
|
|
286
|
+
{ initialProps: { p: project } },
|
|
287
|
+
)
|
|
288
|
+
const engine = engines[0] as FakeEngine
|
|
289
|
+
expect(engines).toHaveLength(1)
|
|
290
|
+
// The project the engine was constructed with is already applied.
|
|
291
|
+
expect(engine.updates).toEqual([])
|
|
292
|
+
|
|
293
|
+
const edited = { ...project, tracks: [{ id: 'trk-0', items: [...project.tracks![0].items, { id: 'c1', type: 'video', src: 'b.mp4', start: 10, end: 12 }] }] } as Project
|
|
294
|
+
act(() => { view.rerender({ p: edited }) })
|
|
295
|
+
expect(engines).toHaveLength(1)
|
|
296
|
+
expect(engine.updates).toEqual([edited])
|
|
297
|
+
|
|
298
|
+
// A different project id IS a teardown.
|
|
299
|
+
act(() => { view.rerender({ p: makeProject({ id: 'other' }) }) })
|
|
300
|
+
expect(engines).toHaveLength(2)
|
|
301
|
+
expect(engine.disposes).toBe(1)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('exposes the legacy-shaped derived collections', () => {
|
|
305
|
+
const project = makeProject({
|
|
306
|
+
tracks: [
|
|
307
|
+
{
|
|
308
|
+
id: 'trk-0',
|
|
309
|
+
items: [
|
|
310
|
+
{ id: 'later', type: 'video', src: 'b.mp4', start: 5, end: 8 },
|
|
311
|
+
{ id: 'first', type: 'video', src: 'a.mp4', start: 0, end: 5 },
|
|
312
|
+
{ id: 'img', type: 'image', src: 'i.png', start: 0, end: 2 },
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
{ id: 'trk-1', items: [{ id: 'ov', type: 'overlay', src: 'o.jsx', start: 0, end: 3 }] },
|
|
316
|
+
],
|
|
317
|
+
} as Partial<Project>)
|
|
318
|
+
const { view } = setup(project)
|
|
319
|
+
expect(view.result.current.clips.map((c) => c.id)).toEqual(['first', 'later'])
|
|
320
|
+
expect(view.result.current.tracks0NonVideo.map((c) => c.id)).toEqual(['img'])
|
|
321
|
+
expect(view.result.current.overlayTracks.map((tr) => tr.map((i) => i.id))).toEqual([['ov']])
|
|
322
|
+
expect(view.result.current.isCanvasProject).toBe(false)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('reports a canvas project the way the legacy hook does', () => {
|
|
326
|
+
const { view } = setup(makeProject({ tracks: [{ id: 'trk-0', items: [{ id: 'img', type: 'image', src: 'i.png', start: 0, end: 2 }] }] } as Partial<Project>))
|
|
327
|
+
expect(view.result.current.isCanvasProject).toBe(true)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('getStats reads through to the live engine, and is null once there is none', () => {
|
|
331
|
+
const { view, engine } = setup()
|
|
332
|
+
expect(engine.statsCalls).toBe(0)
|
|
333
|
+
|
|
334
|
+
expect(view.result.current.getStats()).toEqual({ fps: 24, dropped: 1, buffered: 2, clock: 'fallback' })
|
|
335
|
+
expect(engine.statsCalls).toBe(1)
|
|
336
|
+
|
|
337
|
+
act(() => { view.unmount() })
|
|
338
|
+
expect(view.result.current.getStats()).toBeNull()
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('mirrors the picture state into the showVideo analog', () => {
|
|
342
|
+
const { view, engine } = setup()
|
|
343
|
+
expect(view.result.current.showVideo).toBe(false)
|
|
344
|
+
act(() => {
|
|
345
|
+
engine.deps.onStatusChange?.({ transport: 'paused', picture: 'video', clipId: 'c0', seeking: false, clock: 'audio' })
|
|
346
|
+
})
|
|
347
|
+
expect(view.result.current.showVideo).toBe(true)
|
|
348
|
+
act(() => {
|
|
349
|
+
engine.deps.onStatusChange?.({ transport: 'paused', picture: 'preparing', clipId: 'c0', reason: 'no editing proxy yet', seeking: false, clock: 'fallback' })
|
|
350
|
+
})
|
|
351
|
+
expect(view.result.current.showVideo).toBe(false)
|
|
352
|
+
expect(view.result.current.status.reason).toBe('no editing proxy yet')
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
// ── Gesture ownership ────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
describe('useEnginePlayback gesture anchors', () => {
|
|
359
|
+
function stubSuspendedContext() {
|
|
360
|
+
const resume = vi.fn(() => Promise.resolve())
|
|
361
|
+
;(window as unknown as { __montajSharedCtx?: unknown }).__montajSharedCtx = { state: 'suspended', resume }
|
|
362
|
+
return resume
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
afterEach(() => {
|
|
366
|
+
delete (window as unknown as { __montajSharedCtx?: unknown }).__montajSharedCtx
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('togglePlay resumes the shared AudioContext from the gesture, then plays', () => {
|
|
370
|
+
const resume = stubSuspendedContext()
|
|
371
|
+
const { view, engine } = setup()
|
|
372
|
+
act(() => { view.result.current.togglePlay() })
|
|
373
|
+
expect(resume).toHaveBeenCalledTimes(1)
|
|
374
|
+
expect(engine.plays).toBe(1)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('togglePlay creates the shared AudioContext BEFORE resuming it from the gesture', () => {
|
|
378
|
+
// `resumeAudioContextFromGesture` deliberately never creates the context
|
|
379
|
+
// (see its own doc) — on a project with no audio tracks and a video clip
|
|
380
|
+
// whose context is built inside `createMasterClock`, off the gesture
|
|
381
|
+
// stack entirely, nothing upstream of `togglePlay` has created it yet.
|
|
382
|
+
// Without `getSharedAudioContext()` running first, inside the gesture,
|
|
383
|
+
// the very first play on such a project would resume nothing.
|
|
384
|
+
stubSuspendedContext()
|
|
385
|
+
const { view } = setup()
|
|
386
|
+
act(() => { view.result.current.togglePlay() })
|
|
387
|
+
expect(getSharedAudioContext).toHaveBeenCalledTimes(1)
|
|
388
|
+
expect(resumeAudioContextFromGesture).toHaveBeenCalledTimes(1)
|
|
389
|
+
const getOrder = vi.mocked(getSharedAudioContext).mock.invocationCallOrder[0]
|
|
390
|
+
const resumeOrder = vi.mocked(resumeAudioContextFromGesture).mock.invocationCallOrder[0]
|
|
391
|
+
expect(getOrder).toBeLessThan(resumeOrder)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('togglePlay pauses a playing transport and pushes the stop position to the clock', () => {
|
|
395
|
+
const { view, engine, onTimeUpdate } = setup()
|
|
396
|
+
act(() => { engine.setTransport('playing') })
|
|
397
|
+
act(() => { engine.emit(3.5) })
|
|
398
|
+
onTimeUpdate.mockClear()
|
|
399
|
+
|
|
400
|
+
act(() => { view.result.current.togglePlay() })
|
|
401
|
+
expect(engine.pauses).toBe(1)
|
|
402
|
+
// `pause()` never ticks, so nothing but this would settle the playhead.
|
|
403
|
+
expect(onTimeUpdate).toHaveBeenCalledWith(3.5)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('Space toggles playback, with the legacy typing-surface guards', () => {
|
|
407
|
+
const resume = stubSuspendedContext()
|
|
408
|
+
setup()
|
|
409
|
+
const engine = engines[0] as FakeEngine
|
|
410
|
+
|
|
411
|
+
act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', bubbles: true })) })
|
|
412
|
+
expect(engine.plays).toBe(1)
|
|
413
|
+
expect(resume).toHaveBeenCalledTimes(1)
|
|
414
|
+
|
|
415
|
+
act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', bubbles: true })) })
|
|
416
|
+
expect(engine.pauses).toBe(1)
|
|
417
|
+
|
|
418
|
+
// Typing a space in a text field must not toggle playback. (The guard's
|
|
419
|
+
// third arm, `isContentEditable`, is not testable here — jsdom does not
|
|
420
|
+
// implement the property and reports `false` for every element.)
|
|
421
|
+
const input = document.createElement('input')
|
|
422
|
+
const textarea = document.createElement('textarea')
|
|
423
|
+
document.body.append(input, textarea)
|
|
424
|
+
act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', bubbles: true })) })
|
|
425
|
+
act(() => { textarea.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', bubbles: true })) })
|
|
426
|
+
expect(engine.plays).toBe(1)
|
|
427
|
+
|
|
428
|
+
// Any other key is none of this hook's business.
|
|
429
|
+
act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK', bubbles: true })) })
|
|
430
|
+
expect(engine.plays).toBe(1)
|
|
431
|
+
expect(engine.pauses).toBe(1)
|
|
432
|
+
|
|
433
|
+
input.remove()
|
|
434
|
+
textarea.remove()
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('drops the keydown listener on unmount', () => {
|
|
438
|
+
const { view } = setup()
|
|
439
|
+
const engine = engines[0] as FakeEngine
|
|
440
|
+
act(() => { view.unmount() })
|
|
441
|
+
act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', bubbles: true })) })
|
|
442
|
+
expect(engine.plays).toBe(0)
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
// ── Audio lanes over timeline-core's audioWindow ─────────────────────────────
|
|
447
|
+
|
|
448
|
+
describe('useEnginePlayback audio lanes', () => {
|
|
449
|
+
const gains: Array<{ gain: { value: number } }> = []
|
|
450
|
+
// Lane elements are created programmatically (`new Audio()`, never mounted in
|
|
451
|
+
// the tree), so wrap the constructor to capture them for the rate assertions.
|
|
452
|
+
const audioEls: HTMLAudioElement[] = []
|
|
453
|
+
let RealAudio: typeof Audio
|
|
454
|
+
|
|
455
|
+
beforeEach(() => {
|
|
456
|
+
gains.length = 0
|
|
457
|
+
audioEls.length = 0
|
|
458
|
+
RealAudio = window.Audio
|
|
459
|
+
;(window as unknown as { Audio: unknown }).Audio = class extends RealAudio {
|
|
460
|
+
constructor(src?: string) { super(src); audioEls.push(this as unknown as HTMLAudioElement) }
|
|
461
|
+
}
|
|
462
|
+
;(window as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
463
|
+
destination = {}
|
|
464
|
+
createMediaElementSource() { return { connect() {} } }
|
|
465
|
+
createGain() { const g = { gain: { value: 1 }, connect() {} }; gains.push(g); return g }
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
afterEach(() => {
|
|
470
|
+
;(window as unknown as { Audio: unknown }).Audio = RealAudio
|
|
471
|
+
delete (window as unknown as { __montajSharedCtx?: unknown }).__montajSharedCtx
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
const withAudio = () =>
|
|
475
|
+
makeProject({
|
|
476
|
+
audio: {
|
|
477
|
+
tracks: [
|
|
478
|
+
{ id: 'music', src: 'music.mp3', start: 2, end: 8, inPoint: 1, volume: 0.5, fadeIn: 2 } as never,
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
} as Partial<Project>)
|
|
482
|
+
|
|
483
|
+
it('places, gains and starts a lane from the engine tick', () => {
|
|
484
|
+
const { view, engine } = setup(withAudio())
|
|
485
|
+
const el = document.querySelector('audio') ?? null
|
|
486
|
+
// The lane element is created programmatically (never mounted in the tree),
|
|
487
|
+
// so reach it through the effect's own behavior instead: the gain node it
|
|
488
|
+
// wired is the observable.
|
|
489
|
+
expect(gains).toHaveLength(1)
|
|
490
|
+
expect(el).toBeNull()
|
|
491
|
+
|
|
492
|
+
act(() => { engine.setTransport('playing') })
|
|
493
|
+
// t = 3 → 1s into the track, halfway through a 2s fade-in, at volume 0.5.
|
|
494
|
+
act(() => { engine.emit(3) })
|
|
495
|
+
expect(gains[0].gain.value).toBeCloseTo(0.25, 6)
|
|
496
|
+
expect(view.result.current.isPlaying).toBe(true)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('setRate speeds every lane up, pitch-corrected, and drives the engine rate', () => {
|
|
500
|
+
const { view, engine } = setup(withAudio())
|
|
501
|
+
expect(audioEls).toHaveLength(1)
|
|
502
|
+
const el = audioEls[0]
|
|
503
|
+
|
|
504
|
+
act(() => { view.result.current.setRate(2) })
|
|
505
|
+
expect(engine.rates).toContain(2)
|
|
506
|
+
expect(el.playbackRate).toBe(2)
|
|
507
|
+
expect(el.preservesPitch).toBe(true)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('a lane re-synced from the engine tick inherits the current transport rate', () => {
|
|
511
|
+
const { view, engine } = setup(withAudio())
|
|
512
|
+
const el = audioEls[0]
|
|
513
|
+
act(() => { view.result.current.setRate(4) })
|
|
514
|
+
act(() => { engine.setTransport('playing') })
|
|
515
|
+
// t = 3 is inside the track window, so the tick's syncAudioTracks re-applies
|
|
516
|
+
// the stored rate to the (active) lane.
|
|
517
|
+
act(() => { engine.emit(3) })
|
|
518
|
+
expect(el.playbackRate).toBe(4)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('leaves the gain alone outside the track window', () => {
|
|
522
|
+
const { engine } = setup(withAudio())
|
|
523
|
+
act(() => { engine.emit(3) })
|
|
524
|
+
const inside = gains[0].gain.value
|
|
525
|
+
act(() => { engine.emit(9) })
|
|
526
|
+
// Outside the window the lane is paused and the envelope is not applied —
|
|
527
|
+
// exactly the legacy `continue`.
|
|
528
|
+
expect(gains[0].gain.value).toBe(inside)
|
|
529
|
+
})
|
|
530
|
+
})
|