@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,1310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T5 — every transport transition, over fakes.
|
|
3
|
+
*
|
|
4
|
+
* The scheduler is a synchronous state machine over three injected surfaces
|
|
5
|
+
* (`SourceHost`, `Painter`, `MasterClock`), so all of it is reachable in jsdom
|
|
6
|
+
* with no WebCodecs, no `Worker` and no canvas. What these tests CAN prove is
|
|
7
|
+
* every transition the plan names — play into a gap, scrub into a gap, scrub
|
|
8
|
+
* past the end, loop wrap, boundary swap, opaque toggle mid-play, decode
|
|
9
|
+
* failure mid-clip — plus the `VideoFrame` ownership contract on each path.
|
|
10
|
+
* What they cannot prove (that a real decoder returns real frames) is what T9's
|
|
11
|
+
* operator parity checklist is for; there are no pretend-integration tests
|
|
12
|
+
* against a mock of the whole browser here.
|
|
13
|
+
*
|
|
14
|
+
* The fakes are deliberately dumb: the clock is a settable number, the frame
|
|
15
|
+
* server records calls, the host is a map. Every test drives time explicitly
|
|
16
|
+
* (`host.setTime(t); scheduler.tick()`) so nothing depends on rAF or timers.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
19
|
+
import type { EditorProject as Project, VisualItem } from '../../schema'
|
|
20
|
+
import type { MasterClock } from '../audio-clock'
|
|
21
|
+
import type { ChunkSource } from '../demux'
|
|
22
|
+
import type { FrameServer, FrameServerStats } from '../frame-server'
|
|
23
|
+
import {
|
|
24
|
+
createScheduler,
|
|
25
|
+
drawPlanFor,
|
|
26
|
+
endsOnLoopBoundary,
|
|
27
|
+
engineSrcFor,
|
|
28
|
+
placeInSource,
|
|
29
|
+
planTick,
|
|
30
|
+
track0VideoItems,
|
|
31
|
+
transportEndFor,
|
|
32
|
+
type ClipSource,
|
|
33
|
+
type DrawPlan,
|
|
34
|
+
type EngineStatus,
|
|
35
|
+
type Painter,
|
|
36
|
+
type Scheduler,
|
|
37
|
+
type SourceHost,
|
|
38
|
+
type SourceRequest,
|
|
39
|
+
type SourceState,
|
|
40
|
+
} from '../scheduler'
|
|
41
|
+
|
|
42
|
+
/** `Array.prototype.at` is ES2022; this package targets ES2020. */
|
|
43
|
+
const last = <T>(items: readonly T[]): T | undefined => items[items.length - 1]
|
|
44
|
+
|
|
45
|
+
// ── Fakes ───────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
class FakeClock implements MasterClock {
|
|
48
|
+
playing = false
|
|
49
|
+
disposed = false
|
|
50
|
+
t: number
|
|
51
|
+
readonly seeks: number[] = []
|
|
52
|
+
/** Second arg of every `seek` call, aligned index-for-index with `seeks` — `undefined` when omitted. */
|
|
53
|
+
readonly seekMediaS: Array<number | undefined> = []
|
|
54
|
+
/** Every `setTransportRate` call, in order — the propagation test reads this. */
|
|
55
|
+
readonly rates: number[] = []
|
|
56
|
+
constructor(
|
|
57
|
+
readonly kind: 'audio' | 'fallback',
|
|
58
|
+
start: number,
|
|
59
|
+
) {
|
|
60
|
+
this.t = start
|
|
61
|
+
}
|
|
62
|
+
now() {
|
|
63
|
+
return this.t
|
|
64
|
+
}
|
|
65
|
+
play() {
|
|
66
|
+
this.playing = true
|
|
67
|
+
}
|
|
68
|
+
pause() {
|
|
69
|
+
this.playing = false
|
|
70
|
+
}
|
|
71
|
+
seek(projectS: number, mediaS?: number) {
|
|
72
|
+
this.seeks.push(projectS)
|
|
73
|
+
this.seekMediaS.push(mediaS)
|
|
74
|
+
this.t = projectS
|
|
75
|
+
}
|
|
76
|
+
setVolume() {}
|
|
77
|
+
setTransportRate(rate: number) {
|
|
78
|
+
this.rates.push(rate)
|
|
79
|
+
}
|
|
80
|
+
stats() {
|
|
81
|
+
return {
|
|
82
|
+
kind: this.kind,
|
|
83
|
+
playing: this.playing,
|
|
84
|
+
samplesConsumed: 0,
|
|
85
|
+
underrunFrames: 0,
|
|
86
|
+
queuedFrames: 0,
|
|
87
|
+
queuedSeconds: 0,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
dispose() {
|
|
91
|
+
this.disposed = true
|
|
92
|
+
this.playing = false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface FakeFrame {
|
|
97
|
+
timestamp: number
|
|
98
|
+
displayWidth: number
|
|
99
|
+
displayHeight: number
|
|
100
|
+
closed: boolean
|
|
101
|
+
close(): void
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fakeFrame(timestamp: number, w = 1280, h = 720): FakeFrame {
|
|
105
|
+
return {
|
|
106
|
+
timestamp,
|
|
107
|
+
displayWidth: w,
|
|
108
|
+
displayHeight: h,
|
|
109
|
+
closed: false,
|
|
110
|
+
close() {
|
|
111
|
+
this.closed = true
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const asFrame = (f: FakeFrame) => f as unknown as VideoFrame
|
|
117
|
+
|
|
118
|
+
function chunkSource(firstPresentationTsUs = 0): ChunkSource {
|
|
119
|
+
return {
|
|
120
|
+
kind: 'video',
|
|
121
|
+
codec: 'av01.0.05M.08',
|
|
122
|
+
fps: 30,
|
|
123
|
+
durationS: 10,
|
|
124
|
+
coded: { width: 1280, height: 720 },
|
|
125
|
+
samples: [],
|
|
126
|
+
presIndex: [],
|
|
127
|
+
firstPresentationTsUs,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
class FakeFrameServer implements FrameServer {
|
|
132
|
+
decodeAheadFrames = 8
|
|
133
|
+
readonly video: ChunkSource
|
|
134
|
+
readonly starts: number[] = []
|
|
135
|
+
readonly seekTargets: number[] = []
|
|
136
|
+
readonly pulls: number[] = []
|
|
137
|
+
stops = 0
|
|
138
|
+
disposed = false
|
|
139
|
+
/** What `nextFrameFor` hands back, if anything. */
|
|
140
|
+
supply: ((clockUs: number) => FakeFrame | null) | null = null
|
|
141
|
+
/** Resolvers for outstanding `seek()` promises, newest last. */
|
|
142
|
+
readonly pendingSeeks: Array<(f: VideoFrame | null) => void> = []
|
|
143
|
+
|
|
144
|
+
constructor(
|
|
145
|
+
readonly src: string,
|
|
146
|
+
firstPresentationTsUs = 0,
|
|
147
|
+
) {
|
|
148
|
+
this.video = chunkSource(firstPresentationTsUs)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
seek(targetTsUs: number) {
|
|
152
|
+
this.seekTargets.push(targetTsUs)
|
|
153
|
+
let resolve: (f: VideoFrame | null) => void = () => {}
|
|
154
|
+
const frame = new Promise<VideoFrame | null>((r) => {
|
|
155
|
+
resolve = r
|
|
156
|
+
})
|
|
157
|
+
this.pendingSeeks.push(resolve)
|
|
158
|
+
return { reqId: this.seekTargets.length, frame }
|
|
159
|
+
}
|
|
160
|
+
startStream(targetTsUs: number) {
|
|
161
|
+
this.starts.push(targetTsUs)
|
|
162
|
+
return this.starts.length
|
|
163
|
+
}
|
|
164
|
+
stopStream() {
|
|
165
|
+
this.stops++
|
|
166
|
+
}
|
|
167
|
+
nextFrameFor(clockUs: number) {
|
|
168
|
+
this.pulls.push(clockUs)
|
|
169
|
+
const f = this.supply?.(clockUs) ?? null
|
|
170
|
+
return { frame: f ? asFrame(f) : null, dropped: 0 }
|
|
171
|
+
}
|
|
172
|
+
stats(): FrameServerStats {
|
|
173
|
+
return {
|
|
174
|
+
buffered: 0,
|
|
175
|
+
inFlightFrames: 0,
|
|
176
|
+
inFlightBatches: 0,
|
|
177
|
+
received: 0,
|
|
178
|
+
dropped: 0,
|
|
179
|
+
atEndOfSource: false,
|
|
180
|
+
drained: false,
|
|
181
|
+
lastError: null,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
dispose() {
|
|
185
|
+
this.disposed = true
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface FakeSession {
|
|
190
|
+
src: string
|
|
191
|
+
item: VisualItem
|
|
192
|
+
server: FakeFrameServer
|
|
193
|
+
clock: FakeClock
|
|
194
|
+
ready: boolean
|
|
195
|
+
failed?: string
|
|
196
|
+
/**
|
|
197
|
+
* Cached, exactly as the real host caches it on its `Session`. Handing back a
|
|
198
|
+
* fresh object each call would read as "the session was rebuilt" and make the
|
|
199
|
+
* scheduler respawn the decoder on every tick.
|
|
200
|
+
*/
|
|
201
|
+
source?: ClipSource
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
class FakeHost implements SourceHost {
|
|
205
|
+
readonly sessions = new Map<string, FakeSession>()
|
|
206
|
+
readonly retainLog: SourceRequest[][] = []
|
|
207
|
+
readonly droppedClips: string[] = []
|
|
208
|
+
readonly fallbacks: FakeClock[] = []
|
|
209
|
+
/** When false, a retained clip stays `loading` until `ready()` is called. */
|
|
210
|
+
autoReady = true
|
|
211
|
+
scheduler: Scheduler | null = null
|
|
212
|
+
private time = 0
|
|
213
|
+
private readonly clocks: FakeClock[] = []
|
|
214
|
+
|
|
215
|
+
retain(requests: readonly SourceRequest[]): void {
|
|
216
|
+
this.retainLog.push([...requests])
|
|
217
|
+
for (const [clipId, session] of [...this.sessions]) {
|
|
218
|
+
if (requests.some((r) => r.clipId === clipId && r.src === session.src)) continue
|
|
219
|
+
this.sessions.delete(clipId)
|
|
220
|
+
this.droppedClips.push(clipId)
|
|
221
|
+
session.server.dispose()
|
|
222
|
+
session.clock.dispose()
|
|
223
|
+
}
|
|
224
|
+
for (const request of requests) {
|
|
225
|
+
if (this.sessions.has(request.clipId)) continue
|
|
226
|
+
const clock = new FakeClock('audio', request.anchorProjectS)
|
|
227
|
+
clock.t = this.time
|
|
228
|
+
this.clocks.push(clock)
|
|
229
|
+
this.sessions.set(request.clipId, {
|
|
230
|
+
src: request.src,
|
|
231
|
+
item: request.item,
|
|
232
|
+
server: new FakeFrameServer(request.src),
|
|
233
|
+
clock,
|
|
234
|
+
ready: this.autoReady,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
state(clipId: string): SourceState {
|
|
240
|
+
const session = this.sessions.get(clipId)
|
|
241
|
+
if (!session) return { status: 'idle' }
|
|
242
|
+
if (session.failed) return { status: 'failed', reason: session.failed }
|
|
243
|
+
if (!session.ready) return { status: 'loading' }
|
|
244
|
+
session.source ??= {
|
|
245
|
+
clipId,
|
|
246
|
+
src: session.src,
|
|
247
|
+
frameServer: session.server,
|
|
248
|
+
clock: session.clock,
|
|
249
|
+
timebase: {
|
|
250
|
+
start: session.item.start,
|
|
251
|
+
inPoint: session.item.inPoint ?? 0,
|
|
252
|
+
// The AUDIO origin. The scheduler must read the VIDEO one off the frame
|
|
253
|
+
// server; a non-zero value here proves it does not use this.
|
|
254
|
+
firstPresentationTsUs: 999_000,
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
return { status: 'ready', source: session.source }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fallbackClock(startProjectS: number): MasterClock {
|
|
261
|
+
const clock = new FakeClock('fallback', startProjectS)
|
|
262
|
+
clock.t = this.time
|
|
263
|
+
this.clocks.push(clock)
|
|
264
|
+
this.fallbacks.push(clock)
|
|
265
|
+
return clock
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── test controls ───────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/** Move every clock the scheduler could be holding to `t`. */
|
|
271
|
+
setTime(t: number): void {
|
|
272
|
+
this.time = t
|
|
273
|
+
for (const clock of this.clocks) clock.t = t
|
|
274
|
+
}
|
|
275
|
+
ready(clipId: string): void {
|
|
276
|
+
const session = this.sessions.get(clipId)
|
|
277
|
+
if (!session) throw new Error(`no session for ${clipId}`)
|
|
278
|
+
session.ready = true
|
|
279
|
+
session.failed = undefined
|
|
280
|
+
this.scheduler?.sourceChanged(clipId)
|
|
281
|
+
}
|
|
282
|
+
fail(clipId: string, reason: string): void {
|
|
283
|
+
const session = this.sessions.get(clipId)
|
|
284
|
+
if (!session) throw new Error(`no session for ${clipId}`)
|
|
285
|
+
session.failed = reason
|
|
286
|
+
session.source = undefined
|
|
287
|
+
session.clock.dispose()
|
|
288
|
+
this.scheduler?.sourceChanged(clipId)
|
|
289
|
+
}
|
|
290
|
+
server(clipId: string): FakeFrameServer {
|
|
291
|
+
const session = this.sessions.get(clipId)
|
|
292
|
+
if (!session) throw new Error(`no session for ${clipId}`)
|
|
293
|
+
return session.server
|
|
294
|
+
}
|
|
295
|
+
lastRetain(): SourceRequest[] {
|
|
296
|
+
return this.retainLog[this.retainLog.length - 1] ?? []
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
class FakePainter implements Painter {
|
|
301
|
+
readonly paints: Array<{ frame: FakeFrame; plan: DrawPlan }> = []
|
|
302
|
+
clears = 0
|
|
303
|
+
constructor(
|
|
304
|
+
private readonly width = 1080,
|
|
305
|
+
private readonly height = 1920,
|
|
306
|
+
) {}
|
|
307
|
+
size() {
|
|
308
|
+
return { width: this.width, height: this.height }
|
|
309
|
+
}
|
|
310
|
+
paint(frame: VideoFrame, plan: DrawPlan) {
|
|
311
|
+
this.paints.push({ frame: frame as unknown as FakeFrame, plan })
|
|
312
|
+
}
|
|
313
|
+
clear() {
|
|
314
|
+
this.clears++
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Project builders ────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
function clip(id: string, start: number, end: number, extra: Partial<VisualItem> = {}): VisualItem {
|
|
321
|
+
return {
|
|
322
|
+
id,
|
|
323
|
+
type: 'video',
|
|
324
|
+
src: `/media/${id}.mov`,
|
|
325
|
+
proxySrc: `/proxies/${id}_proxy.mp4`,
|
|
326
|
+
start,
|
|
327
|
+
end,
|
|
328
|
+
inPoint: 0,
|
|
329
|
+
outPoint: end - start,
|
|
330
|
+
...extra,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function overlay(id: string, start: number, end: number, opaque = false): VisualItem {
|
|
335
|
+
return { id, type: 'overlay', src: `/overlays/${id}.jsx`, start, end, opaque }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function project(
|
|
339
|
+
track0: VisualItem[],
|
|
340
|
+
overlays: VisualItem[] = [],
|
|
341
|
+
extra: Partial<Project> = {},
|
|
342
|
+
): Project {
|
|
343
|
+
return {
|
|
344
|
+
id: 'p1',
|
|
345
|
+
status: 'draft',
|
|
346
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
347
|
+
tracks: overlays.length > 0 ? [track0, overlays] : [track0],
|
|
348
|
+
...extra,
|
|
349
|
+
} as Project
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
interface Harness {
|
|
353
|
+
scheduler: Scheduler
|
|
354
|
+
host: FakeHost
|
|
355
|
+
painter: FakePainter
|
|
356
|
+
times: number[]
|
|
357
|
+
statuses: EngineStatus[]
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function harness(p: Project, opts: { autoReady?: boolean; painter?: FakePainter } = {}): Harness {
|
|
361
|
+
const host = new FakeHost()
|
|
362
|
+
host.autoReady = opts.autoReady ?? true
|
|
363
|
+
const painter = opts.painter ?? new FakePainter()
|
|
364
|
+
const times: number[] = []
|
|
365
|
+
const statuses: EngineStatus[] = []
|
|
366
|
+
const scheduler = createScheduler({
|
|
367
|
+
project: p,
|
|
368
|
+
host,
|
|
369
|
+
painter,
|
|
370
|
+
onTime: (t) => times.push(t),
|
|
371
|
+
onStatusChange: (s) => statuses.push(s),
|
|
372
|
+
})
|
|
373
|
+
host.scheduler = scheduler
|
|
374
|
+
return { scheduler, host, painter, times, statuses }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Move the clocks and run one tick. */
|
|
378
|
+
function step(h: Harness, t: number): void {
|
|
379
|
+
h.host.setTime(t)
|
|
380
|
+
h.scheduler.tick()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Pure helpers ────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
describe('track0VideoItems', () => {
|
|
386
|
+
it('keeps only track-0 video items, sorted by start (the legacy `clips` memo)', () => {
|
|
387
|
+
const p = project(
|
|
388
|
+
[
|
|
389
|
+
{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 1 },
|
|
390
|
+
clip('b', 5, 7),
|
|
391
|
+
clip('a', 0, 2),
|
|
392
|
+
],
|
|
393
|
+
[overlay('o', 0, 9)],
|
|
394
|
+
)
|
|
395
|
+
expect(track0VideoItems(p).map((c) => c.id)).toEqual(['a', 'b'])
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
describe('transportEndFor', () => {
|
|
400
|
+
it('uses projectEnd for a video project — audio tail included', () => {
|
|
401
|
+
const p = project([clip('a', 0, 4)], [overlay('o', 0, 5)], {
|
|
402
|
+
audio: { tracks: [{ id: 'm', src: '/m.mp3', start: 0, end: 8 }] },
|
|
403
|
+
})
|
|
404
|
+
expect(transportEndFor(p)).toBe(8)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('uses max(overlayEnd, captionEnd) for a canvas project — audio EXCLUDED', () => {
|
|
408
|
+
// The legacy canvas rAF's ceiling is `canvasMaxEndRef`, which really does
|
|
409
|
+
// leave audio out. Faithful, not unified.
|
|
410
|
+
const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 3 }], [
|
|
411
|
+
overlay('o', 0, 4),
|
|
412
|
+
], {
|
|
413
|
+
audio: { tracks: [{ id: 'm', src: '/m.mp3', start: 0, end: 30 }] },
|
|
414
|
+
captions: { style: 'pop', segments: [{ text: 'hi', start: 0, end: 6 }] },
|
|
415
|
+
})
|
|
416
|
+
expect(transportEndFor(p)).toBe(6)
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
describe('engineSrcFor — proxy-only playback is structural', () => {
|
|
421
|
+
it('accepts the proxy when the resolver chose it', () => {
|
|
422
|
+
const c = clip('a', 0, 2)
|
|
423
|
+
expect(engineSrcFor(c, { src: c.proxySrc })).toEqual({ src: c.proxySrc })
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('blocks a clip with no proxy yet — never opens the master', () => {
|
|
427
|
+
const c = clip('a', 0, 2, { proxySrc: undefined })
|
|
428
|
+
const result = engineSrcFor(c, { src: c.src })
|
|
429
|
+
expect(result.src).toBe('')
|
|
430
|
+
expect(result.blocked).toMatch(/proxy/i)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('blocks a clip whose nobg_preview_src outranks the proxy', () => {
|
|
434
|
+
const c = clip('a', 0, 2, { nobg_preview_src: '/nobg/a.webm' })
|
|
435
|
+
const result = engineSrcFor(c, { src: '/nobg/a.webm' })
|
|
436
|
+
expect(result.src).toBe('')
|
|
437
|
+
expect(result.blocked).toMatch(/decode/i)
|
|
438
|
+
})
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
describe('placeInSource — the loopOffset reimplementation', () => {
|
|
442
|
+
const window = { inPoint: 0, outPoint: 2 }
|
|
443
|
+
|
|
444
|
+
it('maps project time linearly for a non-looping clip', () => {
|
|
445
|
+
const c = clip('a', 10, 14)
|
|
446
|
+
expect(placeInSource(c, { inPoint: 1.5, outPoint: 5.5 }, 12)).toEqual({
|
|
447
|
+
mediaS: 3.5,
|
|
448
|
+
wraps: 0,
|
|
449
|
+
loopOffset: 0,
|
|
450
|
+
looping: false,
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('wraps a looping clip and derives loopOffset from t', () => {
|
|
455
|
+
const c = clip('a', 0, 10, { loop: true })
|
|
456
|
+
expect(placeInSource(c, window, 0.5)).toMatchObject({ mediaS: 0.5, wraps: 0, loopOffset: 0 })
|
|
457
|
+
expect(placeInSource(c, window, 2.5)).toMatchObject({ mediaS: 0.5, wraps: 1, loopOffset: 2 })
|
|
458
|
+
expect(placeInSource(c, window, 4.5)).toMatchObject({ mediaS: 0.5, wraps: 2, loopOffset: 4 })
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('scrubbing into a looped region lands where a wrap would have', () => {
|
|
462
|
+
// The property the legacy hook's two writers of `loopOffsetRef` were trying
|
|
463
|
+
// to keep by hand; here it holds by construction.
|
|
464
|
+
const c = clip('a', 0, 10, { loop: true })
|
|
465
|
+
expect(placeInSource(c, window, 6.75)).toEqual(placeInSource(c, window, 6.75))
|
|
466
|
+
expect(placeInSource(c, window, 6.75).mediaS).toBeCloseTo(0.75, 9)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('falls back to the non-loop branch when there is no outPoint', () => {
|
|
470
|
+
const c = clip('a', 0, 10, { loop: true, outPoint: undefined })
|
|
471
|
+
expect(placeInSource(c, { inPoint: 0, outPoint: undefined }, 5)).toMatchObject({
|
|
472
|
+
mediaS: 5,
|
|
473
|
+
looping: false,
|
|
474
|
+
})
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('falls back to the non-loop branch on a degenerate window', () => {
|
|
478
|
+
const c = clip('a', 0, 10, { loop: true })
|
|
479
|
+
expect(placeInSource(c, { inPoint: 3, outPoint: 3 }, 5)).toMatchObject({
|
|
480
|
+
mediaS: 8,
|
|
481
|
+
looping: false,
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('clamps to the in-point before the clip starts', () => {
|
|
486
|
+
const c = clip('a', 5, 9)
|
|
487
|
+
expect(placeInSource(c, { inPoint: 2, outPoint: 6 }, 1).mediaS).toBe(2)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('scales the source traversal by per-clip speed (non-looping)', () => {
|
|
491
|
+
// speed 2: 2.5s into the clip consumes 5s of source; speed 0.5 consumes 1.25s.
|
|
492
|
+
const fast = clip('a', 10, 14, { speed: 2 })
|
|
493
|
+
expect(placeInSource(fast, { inPoint: 1, outPoint: 9 }, 12.5)).toMatchObject({
|
|
494
|
+
mediaS: 1 + 5,
|
|
495
|
+
wraps: 0,
|
|
496
|
+
looping: false,
|
|
497
|
+
})
|
|
498
|
+
const slow = clip('a', 10, 14, { speed: 0.5 })
|
|
499
|
+
expect(placeInSource(slow, { inPoint: 1, outPoint: 9 }, 12.5).mediaS).toBeCloseTo(1 + 1.25, 9)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('speed 1 (and absent speed) is unchanged', () => {
|
|
503
|
+
const plain = clip('a', 10, 14)
|
|
504
|
+
const one = clip('a', 10, 14, { speed: 1 })
|
|
505
|
+
for (const t of [10, 11.3, 13.9]) {
|
|
506
|
+
expect(placeInSource(one, { inPoint: 1.5, outPoint: 5.5 }, t)).toEqual(
|
|
507
|
+
placeInSource(plain, { inPoint: 1.5, outPoint: 5.5 }, t),
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('wraps a sped-up loop in source terms (loopDur unaffected, traversed faster)', () => {
|
|
513
|
+
// window is a 2s source loop; at speed 2 a loop iteration is exhausted in 1s
|
|
514
|
+
// of project time. loopDur stays 2 (source); wraps/loopOffset stay in source.
|
|
515
|
+
const c = clip('a', 0, 10, { loop: true, speed: 2 })
|
|
516
|
+
const window = { inPoint: 0, outPoint: 2 }
|
|
517
|
+
// 0.25s project → 0.5s source → mediaS 0.5, no wrap yet.
|
|
518
|
+
expect(placeInSource(c, window, 0.25)).toMatchObject({ mediaS: 0.5, wraps: 0, loopOffset: 0 })
|
|
519
|
+
// 1.25s project → 2.5s source → one full loop (2s) + 0.5s → mediaS 0.5, wraps 1.
|
|
520
|
+
expect(placeInSource(c, window, 1.25)).toMatchObject({ mediaS: 0.5, wraps: 1, loopOffset: 2 })
|
|
521
|
+
})
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
describe('endsOnLoopBoundary', () => {
|
|
525
|
+
it('is true when the project span is a whole number of loops', () => {
|
|
526
|
+
expect(endsOnLoopBoundary(clip('a', 0, 6, { loop: true }), { inPoint: 0, outPoint: 2 })).toBe(
|
|
527
|
+
true,
|
|
528
|
+
)
|
|
529
|
+
})
|
|
530
|
+
it('is false mid-loop — the legacy hard-stop case', () => {
|
|
531
|
+
expect(endsOnLoopBoundary(clip('a', 0, 5, { loop: true }), { inPoint: 0, outPoint: 2 })).toBe(
|
|
532
|
+
false,
|
|
533
|
+
)
|
|
534
|
+
})
|
|
535
|
+
it('is false for a non-looping clip', () => {
|
|
536
|
+
expect(endsOnLoopBoundary(clip('a', 0, 6), { inPoint: 0, outPoint: 2 })).toBe(false)
|
|
537
|
+
})
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
describe('planTick', () => {
|
|
541
|
+
it('resolves the active track-0 clip, the opaque flag and the next clip', () => {
|
|
542
|
+
const p = project([clip('a', 0, 2), clip('b', 3, 5)], [overlay('o', 1, 4, true)])
|
|
543
|
+
const clips = track0VideoItems(p)
|
|
544
|
+
const plan = planTick(p, 1.5, clips)
|
|
545
|
+
expect(plan.active?.clipId).toBe('a')
|
|
546
|
+
expect(plan.opaque).toBe(true)
|
|
547
|
+
expect(plan.next).toMatchObject({ clipId: 'b', start: 3 })
|
|
548
|
+
expect(plan.canvas).toBe(false)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('returns no active clip inside a gap (resolveAt has no last-clip fallback)', () => {
|
|
552
|
+
const p = project([clip('a', 0, 2), clip('b', 3, 5)])
|
|
553
|
+
const plan = planTick(p, 2.5, track0VideoItems(p))
|
|
554
|
+
expect(plan.active).toBeNull()
|
|
555
|
+
expect(plan.next?.clipId).toBe('b')
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('picks the earliest-start clip when two track-0 videos overlap', () => {
|
|
559
|
+
// Document order puts 'late' first; the legacy hook start-sorts.
|
|
560
|
+
const p = project([clip('late', 1, 4), clip('early', 0, 4)])
|
|
561
|
+
expect(planTick(p, 2, track0VideoItems(p)).active?.clipId).toBe('early')
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('flags a canvas project', () => {
|
|
565
|
+
const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 4 }])
|
|
566
|
+
const plan = planTick(p, 1, track0VideoItems(p))
|
|
567
|
+
expect(plan.canvas).toBe(true)
|
|
568
|
+
expect(plan.active).toBeNull()
|
|
569
|
+
})
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
describe('track audio folds into the source requests', () => {
|
|
573
|
+
// `project()` above builds the legacy array-of-arrays shape, which has
|
|
574
|
+
// nowhere to put track settings — these cases need the object shape.
|
|
575
|
+
function tracked(items: VisualItem[], track: { volume?: number; muted?: boolean } = {}): Project {
|
|
576
|
+
return {
|
|
577
|
+
id: 'p1',
|
|
578
|
+
status: 'draft',
|
|
579
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
580
|
+
tracks: [{ id: 'trk-0', items, ...track }],
|
|
581
|
+
} as Project
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The retained request for `clipId` on the last tick. */
|
|
585
|
+
function requested(h: Harness, clipId: string): SourceRequest {
|
|
586
|
+
const found = h.host.lastRetain().find((r) => r.clipId === clipId)
|
|
587
|
+
if (!found) throw new Error(`no request for ${clipId}`)
|
|
588
|
+
return found
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
it('multiplies the track volume into the clip volume, keeping the mix between clips', () => {
|
|
592
|
+
// a and b are a 2:1 mix apart. Pulling the track down must move both by the
|
|
593
|
+
// same factor — replacing rather than multiplying would flatten them onto
|
|
594
|
+
// one level and throw away the per-clip work.
|
|
595
|
+
const h = harness(tracked([clip('a', 0, 2, { volume: 0.8 }), clip('b', 2, 4, { volume: 0.4 })], { volume: 0.5 }))
|
|
596
|
+
h.scheduler.play()
|
|
597
|
+
step(h, 1.5) // inside the prewarm lead, so both clips are retained at once
|
|
598
|
+
|
|
599
|
+
expect(requested(h, 'a').item.volume).toBeCloseTo(0.4, 10)
|
|
600
|
+
expect(requested(h, 'b').item.volume).toBeCloseTo(0.2, 10)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it('mutes every clip on a muted track, whatever their own volume', () => {
|
|
604
|
+
const h = harness(tracked([clip('a', 0, 2, { volume: 2 })], { muted: true }))
|
|
605
|
+
h.scheduler.play()
|
|
606
|
+
step(h, 1)
|
|
607
|
+
expect(requested(h, 'a').item.muted).toBe(true)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('keeps a muted clip muted on an unmuted track', () => {
|
|
611
|
+
const h = harness(tracked([clip('a', 0, 2, { muted: true })], { volume: 1 }))
|
|
612
|
+
h.scheduler.play()
|
|
613
|
+
step(h, 1)
|
|
614
|
+
expect(requested(h, 'a').item.muted).toBe(true)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it('passes the clip through untouched when the track carries no settings', () => {
|
|
618
|
+
const h = harness(tracked([clip('a', 0, 2, { volume: 0.8, muted: false })]))
|
|
619
|
+
h.scheduler.play()
|
|
620
|
+
step(h, 1)
|
|
621
|
+
expect(requested(h, 'a').item.volume).toBeCloseTo(0.8, 10)
|
|
622
|
+
expect(requested(h, 'a').item.muted).toBe(false)
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('defaults to unity for a clip and track that set nothing', () => {
|
|
626
|
+
const h = harness(tracked([clip('a', 0, 2)]))
|
|
627
|
+
h.scheduler.play()
|
|
628
|
+
step(h, 1)
|
|
629
|
+
expect(requested(h, 'a').item.volume).toBe(1)
|
|
630
|
+
expect(requested(h, 'a').item.muted).toBe(false)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('DERIVES the request item — the project\'s own clip is never written to', () => {
|
|
634
|
+
// The accessors share item objects by reference all the way from
|
|
635
|
+
// project.json; `resolveProjectPaths` (renderer) and `_apply_project_edits`
|
|
636
|
+
// (server) both mutate them in place. Folding by assignment would corrupt
|
|
637
|
+
// the stored project with every tick and break nothing visible until export.
|
|
638
|
+
const item = clip('a', 0, 2, { volume: 0.8 })
|
|
639
|
+
const p = tracked([item], { volume: 0.5, muted: true })
|
|
640
|
+
const h = harness(p)
|
|
641
|
+
h.scheduler.play()
|
|
642
|
+
step(h, 1)
|
|
643
|
+
|
|
644
|
+
expect(requested(h, 'a').item).not.toBe(item)
|
|
645
|
+
expect(item.volume).toBe(0.8)
|
|
646
|
+
expect(item.muted).toBeUndefined()
|
|
647
|
+
// Everything the fold does not own rides along, or the host would rebuild
|
|
648
|
+
// the session against a clip with no trim and no src.
|
|
649
|
+
expect(requested(h, 'a').item).toMatchObject({ id: 'a', start: 0, inPoint: 0, outPoint: 2 })
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
it('re-folds after setProject, so a track edit reaches the next retain', () => {
|
|
653
|
+
const items = [clip('a', 0, 2, { volume: 0.8 })]
|
|
654
|
+
const h = harness(tracked(items, { volume: 0.5 }))
|
|
655
|
+
h.scheduler.play()
|
|
656
|
+
step(h, 1)
|
|
657
|
+
expect(requested(h, 'a').item.volume).toBeCloseTo(0.4, 10)
|
|
658
|
+
|
|
659
|
+
// Same clip object, new track settings — the only thing that changed is the
|
|
660
|
+
// track, so nothing but the fold can carry it through.
|
|
661
|
+
h.scheduler.setProject(tracked(items, { volume: 0.25, muted: true }))
|
|
662
|
+
expect(requested(h, 'a').item.volume).toBeCloseTo(0.2, 10)
|
|
663
|
+
expect(requested(h, 'a').item.muted).toBe(true)
|
|
664
|
+
})
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
// ── Transitions ─────────────────────────────────────────────────────────────
|
|
668
|
+
|
|
669
|
+
describe('transition: play into a gap and out the other side', () => {
|
|
670
|
+
const p = project([clip('a', 0, 2), clip('b', 3, 5)])
|
|
671
|
+
|
|
672
|
+
it('hides the picture, keeps time advancing, and picks the next clip up', () => {
|
|
673
|
+
const h = harness(p)
|
|
674
|
+
h.scheduler.play()
|
|
675
|
+
step(h, 1)
|
|
676
|
+
expect(h.scheduler.status()).toMatchObject({
|
|
677
|
+
transport: 'playing',
|
|
678
|
+
picture: 'video',
|
|
679
|
+
clipId: 'a',
|
|
680
|
+
clock: 'audio',
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
// Into the gap: black, but still PLAYING and still emitting time — the
|
|
684
|
+
// legacy gap clock's whole job.
|
|
685
|
+
step(h, 2.4)
|
|
686
|
+
expect(h.scheduler.status()).toMatchObject({
|
|
687
|
+
transport: 'playing',
|
|
688
|
+
picture: 'black',
|
|
689
|
+
clipId: null,
|
|
690
|
+
clock: 'fallback',
|
|
691
|
+
})
|
|
692
|
+
step(h, 2.8)
|
|
693
|
+
expect(last(h.times)).toBe(2.8)
|
|
694
|
+
expect(h.scheduler.status().transport).toBe('playing')
|
|
695
|
+
|
|
696
|
+
// Out the other side.
|
|
697
|
+
step(h, 3.05)
|
|
698
|
+
expect(h.scheduler.status()).toMatchObject({
|
|
699
|
+
transport: 'playing',
|
|
700
|
+
picture: 'video',
|
|
701
|
+
clipId: 'b',
|
|
702
|
+
clock: 'audio',
|
|
703
|
+
})
|
|
704
|
+
expect(h.times).toEqual([...h.times].sort((x, y) => x - y))
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
it('prewarms the next clip at boundary−1s and not before', () => {
|
|
708
|
+
const h = harness(p)
|
|
709
|
+
h.scheduler.play()
|
|
710
|
+
step(h, 1.5) // 1.5s to b.start — outside the lead
|
|
711
|
+
expect(h.host.lastRetain().map((r) => r.clipId)).toEqual(['a'])
|
|
712
|
+
|
|
713
|
+
step(h, 2.4) // 0.6s to b.start — inside the lead
|
|
714
|
+
expect(h.host.lastRetain().map((r) => r.clipId)).toContain('b')
|
|
715
|
+
expect(h.host.sessions.has('b')).toBe(true)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it('keeps the clip it just left retained, and drops it once it is no longer behind', () => {
|
|
719
|
+
// Reverses the original terminate-on-boundary rule. Disposing the outgoing
|
|
720
|
+
// clip made nudging the playhead back over a cut — the commonest gesture in
|
|
721
|
+
// an editor — rebuild it from scratch every single time. It is retained
|
|
722
|
+
// instead: its stream is stopped and its clock paused (asserted below), but
|
|
723
|
+
// the session survives so crossing back is free. Affordable only because
|
|
724
|
+
// ranged loading made a retained source cost a sample index rather than a
|
|
725
|
+
// whole proxy.
|
|
726
|
+
const three = project([clip('a', 0, 2), clip('b', 3, 5), clip('c', 5, 7)])
|
|
727
|
+
const h = harness(three)
|
|
728
|
+
h.scheduler.play()
|
|
729
|
+
step(h, 1)
|
|
730
|
+
const serverA = h.host.server('a')
|
|
731
|
+
const sessionA = h.host.sessions.get('a')!
|
|
732
|
+
|
|
733
|
+
step(h, 3.05)
|
|
734
|
+
expect(serverA.stops).toBeGreaterThan(0)
|
|
735
|
+
expect(serverA.disposed).toBe(false)
|
|
736
|
+
expect(sessionA.clock.playing).toBe(false)
|
|
737
|
+
expect(h.host.lastRetain().map((r) => r.clipId)).toContain('a')
|
|
738
|
+
expect(h.host.droppedClips).not.toContain('a')
|
|
739
|
+
|
|
740
|
+
// Two cuts later 'a' is neither active nor the clip behind the playhead, so
|
|
741
|
+
// the set difference finally takes it.
|
|
742
|
+
step(h, 5.05)
|
|
743
|
+
expect(h.host.lastRetain().map((r) => r.clipId)).not.toContain('a')
|
|
744
|
+
expect(h.host.droppedClips).toContain('a')
|
|
745
|
+
expect(serverA.disposed).toBe(true)
|
|
746
|
+
})
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
describe('transition: scrub into a gap', () => {
|
|
750
|
+
const p = project([clip('a', 0, 2), clip('b', 3, 5)])
|
|
751
|
+
|
|
752
|
+
it('hides the picture while paused and clears the canvas', () => {
|
|
753
|
+
const h = harness(p)
|
|
754
|
+
h.scheduler.seek(1)
|
|
755
|
+
expect(h.scheduler.status()).toMatchObject({ transport: 'paused', picture: 'video' })
|
|
756
|
+
h.scheduler.seek(2.5)
|
|
757
|
+
expect(h.scheduler.status()).toMatchObject({
|
|
758
|
+
transport: 'paused',
|
|
759
|
+
picture: 'black',
|
|
760
|
+
clipId: null,
|
|
761
|
+
})
|
|
762
|
+
expect(h.painter.clears).toBeGreaterThan(0)
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
it('keeps playing when scrubbed into a gap mid-playback', () => {
|
|
766
|
+
const h = harness(p)
|
|
767
|
+
h.scheduler.play()
|
|
768
|
+
step(h, 1)
|
|
769
|
+
h.scheduler.seek(2.5)
|
|
770
|
+
expect(h.scheduler.status()).toMatchObject({ transport: 'playing', picture: 'black' })
|
|
771
|
+
// The wall clock it swapped onto really is running.
|
|
772
|
+
expect(last(h.host.fallbacks)?.playing).toBe(true)
|
|
773
|
+
})
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
describe('transition: scrub past the end', () => {
|
|
777
|
+
const p = project([clip('a', 0, 2)], [], {
|
|
778
|
+
audio: { tracks: [{ id: 'm', src: '/m.mp3', start: 0, end: 6 }] },
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
it('stops playback outright rather than leaving audio running under a dark frame', () => {
|
|
782
|
+
const h = harness(p)
|
|
783
|
+
h.scheduler.play()
|
|
784
|
+
step(h, 1)
|
|
785
|
+
h.scheduler.seek(9)
|
|
786
|
+
expect(h.scheduler.status()).toMatchObject({ transport: 'ended', picture: 'black' })
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
it('re-arms on a scrub back inside the timeline', () => {
|
|
790
|
+
const h = harness(p)
|
|
791
|
+
h.scheduler.seek(9)
|
|
792
|
+
expect(h.scheduler.status().transport).toBe('ended')
|
|
793
|
+
h.scheduler.seek(1)
|
|
794
|
+
expect(h.scheduler.status()).toMatchObject({ transport: 'paused', picture: 'video' })
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it('refuses to start playing from the end (the legacy togglePlay no-op)', () => {
|
|
798
|
+
const h = harness(p)
|
|
799
|
+
h.scheduler.seek(9)
|
|
800
|
+
h.scheduler.play()
|
|
801
|
+
expect(h.scheduler.status().transport).toBe('ended')
|
|
802
|
+
})
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
describe('transition: end of project', () => {
|
|
806
|
+
it('keeps running past the last clip to projectEnd, then stops exactly there', () => {
|
|
807
|
+
// The trailing-tail case: audio to 6s under a last clip that ends at 2s.
|
|
808
|
+
const p = project([clip('a', 0, 2)], [overlay('o', 2, 4)], {
|
|
809
|
+
audio: { tracks: [{ id: 'm', src: '/m.mp3', start: 0, end: 6 }] },
|
|
810
|
+
})
|
|
811
|
+
const h = harness(p)
|
|
812
|
+
h.scheduler.play()
|
|
813
|
+
step(h, 1)
|
|
814
|
+
step(h, 3) // past the last clip, inside the tail
|
|
815
|
+
expect(h.scheduler.status()).toMatchObject({ transport: 'playing', picture: 'black' })
|
|
816
|
+
step(h, 5.5)
|
|
817
|
+
expect(h.scheduler.status().transport).toBe('playing')
|
|
818
|
+
step(h, 6.2)
|
|
819
|
+
expect(h.scheduler.status().transport).toBe('ended')
|
|
820
|
+
// Clamped to the ceiling exactly, never past it.
|
|
821
|
+
expect(last(h.times)).toBe(6)
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
it('runs a canvas project on the wall clock and stops at its overlay/caption ceiling', () => {
|
|
825
|
+
const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 3 }], [
|
|
826
|
+
overlay('o', 0, 4),
|
|
827
|
+
])
|
|
828
|
+
const h = harness(p)
|
|
829
|
+
h.scheduler.play()
|
|
830
|
+
step(h, 1)
|
|
831
|
+
expect(h.scheduler.status()).toMatchObject({
|
|
832
|
+
transport: 'playing',
|
|
833
|
+
picture: 'black',
|
|
834
|
+
clock: 'fallback',
|
|
835
|
+
})
|
|
836
|
+
expect(h.host.retainLog.every((r) => r.length === 0)).toBe(true)
|
|
837
|
+
step(h, 4.1)
|
|
838
|
+
expect(h.scheduler.status().transport).toBe('ended')
|
|
839
|
+
expect(last(h.times)).toBe(4)
|
|
840
|
+
})
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
describe('transition: loop wrap', () => {
|
|
844
|
+
it('restarts the decode session at the window start on every wrap', () => {
|
|
845
|
+
const p = project([clip('a', 0, 6, { loop: true, outPoint: 2 })])
|
|
846
|
+
const h = harness(p)
|
|
847
|
+
h.scheduler.play() // opens the session at t=0
|
|
848
|
+
const clock = h.host.sessions.get('a')!.clock
|
|
849
|
+
step(h, 0.5)
|
|
850
|
+
const server = h.host.server('a')
|
|
851
|
+
expect(server.starts).toEqual([0])
|
|
852
|
+
expect(last(server.pulls)).toBe(500_000)
|
|
853
|
+
|
|
854
|
+
step(h, 1.5) // same loop — no restart, just a pull
|
|
855
|
+
expect(server.starts).toEqual([0])
|
|
856
|
+
expect(last(server.pulls)).toBe(1_500_000)
|
|
857
|
+
|
|
858
|
+
step(h, 2.25) // wrapped
|
|
859
|
+
expect(server.starts).toEqual([0, 250_000])
|
|
860
|
+
expect(last(server.pulls)).toBe(250_000)
|
|
861
|
+
// The audio clock's ring must restart at the SAME wrapped media position
|
|
862
|
+
// as the picture, not at the pre-wrap mapping of project time `t` through
|
|
863
|
+
// the clip's timebase — that would leave audio a whole loop window ahead.
|
|
864
|
+
expect(last(clock.seeks)).toBeCloseTo(2.25, 6)
|
|
865
|
+
expect(last(clock.seekMediaS)).toBeCloseTo(0.25, 6)
|
|
866
|
+
|
|
867
|
+
step(h, 4.1) // wrapped again
|
|
868
|
+
expect(last(server.starts)).toBeCloseTo(100_000, 0)
|
|
869
|
+
expect(last(clock.seeks)).toBeCloseTo(4.1, 6)
|
|
870
|
+
expect(last(clock.seekMediaS)).toBeCloseTo(0.1, 6)
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
it('does not re-seek the audio clock on a same-loop pull (no wrap, no discontinuity)', () => {
|
|
874
|
+
const p = project([clip('a', 0, 6, { loop: true, outPoint: 2 })])
|
|
875
|
+
const h = harness(p)
|
|
876
|
+
h.scheduler.play()
|
|
877
|
+
const clock = h.host.sessions.get('a')!.clock
|
|
878
|
+
const seeksAfterOpen = clock.seeks.length
|
|
879
|
+
step(h, 0.5)
|
|
880
|
+
step(h, 1.5)
|
|
881
|
+
expect(clock.seeks).toHaveLength(seeksAfterOpen)
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
it('advances normally when the clip span ends on a wrap', () => {
|
|
885
|
+
const p = project([clip('a', 0, 6, { loop: true, outPoint: 2 }), clip('b', 6, 8)])
|
|
886
|
+
const h = harness(p)
|
|
887
|
+
h.scheduler.play()
|
|
888
|
+
step(h, 1)
|
|
889
|
+
step(h, 6.05)
|
|
890
|
+
expect(h.scheduler.status()).toMatchObject({ transport: 'playing', clipId: 'b' })
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it('stops at clip.end when the span ends mid-loop (legacy behavior, warts included)', () => {
|
|
894
|
+
const p = project([clip('a', 0, 5, { loop: true, outPoint: 2 }), clip('b', 5, 8)])
|
|
895
|
+
const h = harness(p)
|
|
896
|
+
h.scheduler.play()
|
|
897
|
+
step(h, 1)
|
|
898
|
+
step(h, 5.05)
|
|
899
|
+
expect(h.scheduler.status().transport).toBe('paused')
|
|
900
|
+
expect(last(h.times)).toBe(5)
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
it('does not fire the loop stop on a scrub across the clip end', () => {
|
|
904
|
+
const p = project([clip('a', 0, 5, { loop: true, outPoint: 2 }), clip('b', 5, 8)])
|
|
905
|
+
const h = harness(p)
|
|
906
|
+
h.scheduler.play()
|
|
907
|
+
step(h, 1)
|
|
908
|
+
h.scheduler.seek(6)
|
|
909
|
+
expect(h.scheduler.status()).toMatchObject({ transport: 'playing', clipId: 'b' })
|
|
910
|
+
})
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
describe('transition: boundary swap', () => {
|
|
914
|
+
const p = project([clip('a', 0, 2), clip('b', 2, 4)])
|
|
915
|
+
|
|
916
|
+
it('stops the outgoing stream, starts the incoming one, and swaps the clock', () => {
|
|
917
|
+
const h = harness(p)
|
|
918
|
+
h.scheduler.play()
|
|
919
|
+
step(h, 1)
|
|
920
|
+
const serverA = h.host.server('a')
|
|
921
|
+
const clockA = h.host.sessions.get('a')!.clock
|
|
922
|
+
expect(serverA.starts).toEqual([0])
|
|
923
|
+
expect(last(serverA.pulls)).toBe(1_000_000)
|
|
924
|
+
|
|
925
|
+
step(h, 2.05)
|
|
926
|
+
const serverB = h.host.server('b')
|
|
927
|
+
// The outgoing session is STOPPED, not destroyed: nothing decodes and
|
|
928
|
+
// nothing plays out of it, but it stays retained as the clip behind the
|
|
929
|
+
// playhead so a scrub back over the cut does not rebuild it. What the swap
|
|
930
|
+
// rule was really protecting — no live session is ever reconfigured — is
|
|
931
|
+
// asserted by the next test.
|
|
932
|
+
expect(serverA.stops).toBeGreaterThan(0)
|
|
933
|
+
expect(serverA.disposed).toBe(false)
|
|
934
|
+
expect(clockA.disposed).toBe(false)
|
|
935
|
+
expect(clockA.playing).toBe(false)
|
|
936
|
+
// Anchored at the tick's time, not snapped back to the cut.
|
|
937
|
+
expect(serverB.starts).toHaveLength(1)
|
|
938
|
+
expect(serverB.starts[0]).toBeCloseTo(50_000, 3)
|
|
939
|
+
expect(h.host.sessions.get('b')!.clock.playing).toBe(true)
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it('never reconfigures a live session — a new src means a new server', () => {
|
|
943
|
+
const h = harness(p)
|
|
944
|
+
h.scheduler.play()
|
|
945
|
+
step(h, 1)
|
|
946
|
+
const serverA = h.host.server('a')
|
|
947
|
+
step(h, 2.05)
|
|
948
|
+
expect(h.host.server('b')).not.toBe(serverA)
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
it('maps project time through the video track origin, not the audio one', () => {
|
|
952
|
+
const h = harness(project([clip('a', 0, 4, { inPoint: 1.5 })]))
|
|
953
|
+
h.scheduler.play()
|
|
954
|
+
step(h, 0.5)
|
|
955
|
+
const server = h.host.server('a')
|
|
956
|
+
// The clock's timebase carries a 999_000µs AUDIO origin; the video track's
|
|
957
|
+
// is 0, so nothing here may show a 999ms offset.
|
|
958
|
+
// inPoint 1.5 + elapsed 0.5 = 2.0s into the file.
|
|
959
|
+
expect(last(server.pulls)).toBe(2_000_000)
|
|
960
|
+
// …and the VIDEO origin, when it is non-zero, is added exactly once.
|
|
961
|
+
server.video.firstPresentationTsUs = 7_000
|
|
962
|
+
step(h, 1.5)
|
|
963
|
+
expect(last(server.pulls)).toBe(3_007_000)
|
|
964
|
+
})
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
describe('setRate — the live transport rate', () => {
|
|
968
|
+
const p = project([clip('a', 0, 2), clip('b', 2, 4)])
|
|
969
|
+
|
|
970
|
+
it('pushes the rate to the active clock and carries it onto clocks built afterward', () => {
|
|
971
|
+
const h = harness(p)
|
|
972
|
+
h.scheduler.play()
|
|
973
|
+
step(h, 0.5)
|
|
974
|
+
const clockA = h.host.sessions.get('a')!.clock
|
|
975
|
+
expect(clockA.kind).toBe('audio')
|
|
976
|
+
|
|
977
|
+
h.scheduler.setRate(2)
|
|
978
|
+
expect(clockA.rates).toEqual([2])
|
|
979
|
+
|
|
980
|
+
// Cross into clip b: the newly adopted clock inherits the live rate.
|
|
981
|
+
step(h, 2.1)
|
|
982
|
+
const clockB = h.host.sessions.get('b')!.clock
|
|
983
|
+
expect(clockB.rates).toContain(2)
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
it('leaves clocks untouched while R stays 1 (strict no-op default path)', () => {
|
|
987
|
+
const h = harness(p)
|
|
988
|
+
h.scheduler.play()
|
|
989
|
+
step(h, 0.5)
|
|
990
|
+
const clockA = h.host.sessions.get('a')!.clock
|
|
991
|
+
step(h, 2.1) // crosses the boundary; a's session is dropped afterward
|
|
992
|
+
const clockB = h.host.sessions.get('b')!.clock
|
|
993
|
+
// Never told a rate — the ownership swap must not re-anchor at the default.
|
|
994
|
+
expect(clockA.rates).toEqual([])
|
|
995
|
+
expect(clockB.rates).toEqual([])
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
it('is idempotent — an unchanged rate never touches the active clock', () => {
|
|
999
|
+
const h = harness(p)
|
|
1000
|
+
h.scheduler.play()
|
|
1001
|
+
step(h, 0.5)
|
|
1002
|
+
const clockA = h.host.sessions.get('a')!.clock
|
|
1003
|
+
h.scheduler.setRate(1) // already 1× — the shuttle's stop/reverse call
|
|
1004
|
+
expect(clockA.rates).toEqual([])
|
|
1005
|
+
h.scheduler.setRate(2) // a real change lands
|
|
1006
|
+
expect(clockA.rates).toEqual([2])
|
|
1007
|
+
h.scheduler.setRate(2) // same again — no-op, no re-anchor
|
|
1008
|
+
expect(clockA.rates).toEqual([2])
|
|
1009
|
+
})
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
describe('transition: opaque toggle mid-play', () => {
|
|
1013
|
+
const p = project([clip('a', 0, 4)], [overlay('o', 1, 2, true)])
|
|
1014
|
+
|
|
1015
|
+
it('skips the paint under an opaque overlay but keeps the pipeline moving', () => {
|
|
1016
|
+
const h = harness(p)
|
|
1017
|
+
const frames: FakeFrame[] = []
|
|
1018
|
+
h.scheduler.play()
|
|
1019
|
+
step(h, 0.5)
|
|
1020
|
+
h.host.server('a').supply = (us) => {
|
|
1021
|
+
const f = fakeFrame(us)
|
|
1022
|
+
frames.push(f)
|
|
1023
|
+
return f
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
step(h, 0.75)
|
|
1027
|
+
expect(h.painter.paints).toHaveLength(1)
|
|
1028
|
+
expect(h.scheduler.status().picture).toBe('video')
|
|
1029
|
+
|
|
1030
|
+
// Opaque window: the frame is still PULLED (so the decode-ahead buffer
|
|
1031
|
+
// keeps draining) and immediately closed unpainted.
|
|
1032
|
+
step(h, 1.25)
|
|
1033
|
+
expect(h.scheduler.status().picture).toBe('opaque')
|
|
1034
|
+
expect(h.painter.paints).toHaveLength(1)
|
|
1035
|
+
expect(last(h.host.server('a').pulls)).toBe(1_250_000)
|
|
1036
|
+
expect(last(frames)!.closed).toBe(true)
|
|
1037
|
+
|
|
1038
|
+
// Back to video, no session churn.
|
|
1039
|
+
step(h, 2.25)
|
|
1040
|
+
expect(h.scheduler.status().picture).toBe('video')
|
|
1041
|
+
expect(h.painter.paints).toHaveLength(2)
|
|
1042
|
+
expect(h.host.server('a').starts).toHaveLength(1)
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
it('keeps the clip clock running under the overlay — opaque replaces picture, not audio', () => {
|
|
1046
|
+
const h = harness(p)
|
|
1047
|
+
h.scheduler.play()
|
|
1048
|
+
step(h, 1.25)
|
|
1049
|
+
expect(h.scheduler.status().clock).toBe('audio')
|
|
1050
|
+
expect(h.host.sessions.get('a')!.clock.playing).toBe(true)
|
|
1051
|
+
})
|
|
1052
|
+
})
|
|
1053
|
+
|
|
1054
|
+
describe('transition: decode failure mid-clip', () => {
|
|
1055
|
+
const p = project([clip('a', 0, 4), clip('b', 4, 6)])
|
|
1056
|
+
|
|
1057
|
+
it('routes to `preparing` for that clip only and never stops the project', () => {
|
|
1058
|
+
const h = harness(p)
|
|
1059
|
+
h.scheduler.play()
|
|
1060
|
+
step(h, 1)
|
|
1061
|
+
expect(h.scheduler.status().picture).toBe('video')
|
|
1062
|
+
|
|
1063
|
+
h.host.fail('a', 'decoder: VideoDecoder error')
|
|
1064
|
+
expect(h.scheduler.status()).toMatchObject({
|
|
1065
|
+
transport: 'playing',
|
|
1066
|
+
picture: 'preparing',
|
|
1067
|
+
clipId: 'a',
|
|
1068
|
+
reason: 'decoder: VideoDecoder error',
|
|
1069
|
+
clock: 'fallback',
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
// Gap semantics: time keeps advancing through the failed clip's range.
|
|
1073
|
+
step(h, 2)
|
|
1074
|
+
expect(last(h.times)).toBe(2)
|
|
1075
|
+
expect(h.scheduler.status().transport).toBe('playing')
|
|
1076
|
+
|
|
1077
|
+
// …and the NEXT clip plays normally. No whole-project fallback.
|
|
1078
|
+
step(h, 4.05)
|
|
1079
|
+
expect(h.scheduler.status()).toMatchObject({ picture: 'video', clipId: 'b' })
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
it('reaches the same state when the proxy has not arrived yet', () => {
|
|
1083
|
+
const h = harness(project([clip('a', 0, 4, { proxySrc: undefined })]), { autoReady: true })
|
|
1084
|
+
h.scheduler.play()
|
|
1085
|
+
step(h, 1)
|
|
1086
|
+
expect(h.scheduler.status()).toMatchObject({ picture: 'preparing', clipId: 'a' })
|
|
1087
|
+
expect(h.scheduler.status().reason).toMatch(/proxy/i)
|
|
1088
|
+
// Nothing was ever requested — the engine never opens a master.
|
|
1089
|
+
expect(h.host.retainLog.every((r) => r.length === 0)).toBe(true)
|
|
1090
|
+
})
|
|
1091
|
+
|
|
1092
|
+
it('reaches the same state while a proxy is still loading', () => {
|
|
1093
|
+
const h = harness(p, { autoReady: false })
|
|
1094
|
+
h.scheduler.play()
|
|
1095
|
+
step(h, 1)
|
|
1096
|
+
expect(h.scheduler.status().picture).toBe('preparing')
|
|
1097
|
+
h.host.ready('a')
|
|
1098
|
+
expect(h.scheduler.status()).toMatchObject({ picture: 'video', clipId: 'a' })
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
it('resolves when a project update delivers the proxy', () => {
|
|
1102
|
+
const h = harness(project([clip('a', 0, 4, { proxySrc: undefined })]))
|
|
1103
|
+
h.scheduler.seek(1)
|
|
1104
|
+
expect(h.scheduler.status().picture).toBe('preparing')
|
|
1105
|
+
|
|
1106
|
+
h.scheduler.setProject(project([clip('a', 0, 4)]))
|
|
1107
|
+
expect(h.scheduler.status()).toMatchObject({ picture: 'video', clipId: 'a' })
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
it('rebuilds the session when a clip swaps to a different proxy', () => {
|
|
1111
|
+
const h = harness(project([clip('a', 0, 4)]))
|
|
1112
|
+
h.scheduler.seek(1)
|
|
1113
|
+
const first = h.host.server('a')
|
|
1114
|
+
h.scheduler.setProject(project([clip('a', 0, 4, { proxySrc: '/proxies/a_proxy_v2.mp4' })]))
|
|
1115
|
+
expect(first.disposed).toBe(true)
|
|
1116
|
+
expect(h.host.server('a')).not.toBe(first)
|
|
1117
|
+
expect(h.host.server('a').src).toBe('/proxies/a_proxy_v2.mp4')
|
|
1118
|
+
})
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
// ── Painting & frame ownership ──────────────────────────────────────────────
|
|
1122
|
+
|
|
1123
|
+
describe('painting', () => {
|
|
1124
|
+
it('paints with the crop plan the item asks for and closes every frame', () => {
|
|
1125
|
+
const item = clip('a', 0, 4, {
|
|
1126
|
+
sourceCrop: { x: 0.25, y: 0, w: 0.5, h: 1 },
|
|
1127
|
+
sourceWidth: 1920,
|
|
1128
|
+
sourceHeight: 1080,
|
|
1129
|
+
})
|
|
1130
|
+
const h = harness(project([item]))
|
|
1131
|
+
h.scheduler.play()
|
|
1132
|
+
step(h, 0.5)
|
|
1133
|
+
const frame = fakeFrame(500_000)
|
|
1134
|
+
h.host.server('a').supply = () => frame
|
|
1135
|
+
step(h, 1)
|
|
1136
|
+
|
|
1137
|
+
expect(h.painter.paints).toHaveLength(1)
|
|
1138
|
+
expect(h.painter.paints[0].plan).toEqual(drawPlanFor(item, 1280, 720, 1080, 1920))
|
|
1139
|
+
expect(frame.closed).toBe(true)
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
it('closes a frame it cannot paint (no canvas attached)', () => {
|
|
1143
|
+
const h = harness(project([clip('a', 0, 4)]))
|
|
1144
|
+
h.scheduler.attach(null)
|
|
1145
|
+
h.scheduler.play()
|
|
1146
|
+
const frame = fakeFrame(0)
|
|
1147
|
+
step(h, 0.5)
|
|
1148
|
+
h.host.server('a').supply = () => frame
|
|
1149
|
+
step(h, 1)
|
|
1150
|
+
expect(frame.closed).toBe(true)
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
it('paints a paused scrub from the seek path, once it lands', async () => {
|
|
1154
|
+
const h = harness(project([clip('a', 0, 4)]))
|
|
1155
|
+
h.scheduler.seek(1)
|
|
1156
|
+
const server = h.host.server('a')
|
|
1157
|
+
expect(server.seekTargets).toEqual([1_000_000])
|
|
1158
|
+
expect(h.scheduler.status().seeking).toBe(true)
|
|
1159
|
+
|
|
1160
|
+
const frame = fakeFrame(1_000_000)
|
|
1161
|
+
server.pendingSeeks[0](asFrame(frame))
|
|
1162
|
+
await Promise.resolve()
|
|
1163
|
+
expect(h.painter.paints).toHaveLength(1)
|
|
1164
|
+
expect(frame.closed).toBe(true)
|
|
1165
|
+
expect(h.scheduler.status().seeking).toBe(false)
|
|
1166
|
+
})
|
|
1167
|
+
|
|
1168
|
+
it('discards a superseded scrub frame instead of painting it', async () => {
|
|
1169
|
+
const h = harness(project([clip('a', 0, 4)]))
|
|
1170
|
+
h.scheduler.seek(1)
|
|
1171
|
+
h.scheduler.seek(2)
|
|
1172
|
+
const server = h.host.server('a')
|
|
1173
|
+
const stale = fakeFrame(1_000_000)
|
|
1174
|
+
const fresh = fakeFrame(2_000_000)
|
|
1175
|
+
server.pendingSeeks[0](asFrame(stale))
|
|
1176
|
+
server.pendingSeeks[1](asFrame(fresh))
|
|
1177
|
+
await Promise.resolve()
|
|
1178
|
+
expect(stale.closed).toBe(true)
|
|
1179
|
+
expect(h.painter.paints.map((p) => p.frame)).toEqual([fresh])
|
|
1180
|
+
})
|
|
1181
|
+
|
|
1182
|
+
it('does not re-seek a paused picture that has not moved', () => {
|
|
1183
|
+
// An overlay drag spreads the project on every pointer event; a decoder
|
|
1184
|
+
// seek per pointermove would be both wasteful and visibly unstable.
|
|
1185
|
+
const h = harness(project([clip('a', 0, 4)], [overlay('o', 0, 4)]))
|
|
1186
|
+
h.scheduler.seek(1)
|
|
1187
|
+
const server = h.host.server('a')
|
|
1188
|
+
expect(server.seekTargets).toHaveLength(1)
|
|
1189
|
+
h.scheduler.setProject(project([clip('a', 0, 4)], [overlay('o', 0, 4)]))
|
|
1190
|
+
h.scheduler.setProject(project([clip('a', 0, 4)], [overlay('o', 0, 4)]))
|
|
1191
|
+
expect(server.seekTargets).toHaveLength(1)
|
|
1192
|
+
// …but a real move does.
|
|
1193
|
+
h.scheduler.seek(1.5)
|
|
1194
|
+
expect(server.seekTargets).toHaveLength(2)
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1197
|
+
it('repaints when a fresh canvas is attached', () => {
|
|
1198
|
+
const h = harness(project([clip('a', 0, 4)]))
|
|
1199
|
+
h.scheduler.seek(1)
|
|
1200
|
+
const server = h.host.server('a')
|
|
1201
|
+
expect(server.seekTargets).toHaveLength(1)
|
|
1202
|
+
h.scheduler.attach(new FakePainter())
|
|
1203
|
+
expect(server.seekTargets).toHaveLength(2)
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
it('retries after a seek that produced no frame', async () => {
|
|
1207
|
+
const h = harness(project([clip('a', 0, 4)]))
|
|
1208
|
+
h.scheduler.seek(1)
|
|
1209
|
+
const server = h.host.server('a')
|
|
1210
|
+
server.pendingSeeks[0](null)
|
|
1211
|
+
await Promise.resolve()
|
|
1212
|
+
expect(h.painter.paints).toHaveLength(0)
|
|
1213
|
+
h.scheduler.setProject(project([clip('a', 0, 4)]))
|
|
1214
|
+
expect(server.seekTargets).toHaveLength(2)
|
|
1215
|
+
})
|
|
1216
|
+
|
|
1217
|
+
it('does not restart a live stream just because the project object changed', () => {
|
|
1218
|
+
// An overlay drag spreads the project every frame; that must not stutter
|
|
1219
|
+
// the picture.
|
|
1220
|
+
const p = project([clip('a', 0, 4)], [overlay('o', 0, 4)])
|
|
1221
|
+
const h = harness(p)
|
|
1222
|
+
h.scheduler.play()
|
|
1223
|
+
step(h, 1)
|
|
1224
|
+
const server = h.host.server('a')
|
|
1225
|
+
expect(server.starts).toHaveLength(1)
|
|
1226
|
+
h.scheduler.setProject(project([clip('a', 0, 4)], [overlay('o', 0, 4)]))
|
|
1227
|
+
expect(server.starts).toHaveLength(1)
|
|
1228
|
+
expect(server.disposed).toBe(false)
|
|
1229
|
+
})
|
|
1230
|
+
})
|
|
1231
|
+
|
|
1232
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
|
1233
|
+
|
|
1234
|
+
describe('lifecycle', () => {
|
|
1235
|
+
it('emits status only when it changes', () => {
|
|
1236
|
+
const h = harness(project([clip('a', 0, 4)]))
|
|
1237
|
+
h.scheduler.play()
|
|
1238
|
+
step(h, 0.5)
|
|
1239
|
+
const before = h.statuses.length
|
|
1240
|
+
step(h, 0.6)
|
|
1241
|
+
step(h, 0.7)
|
|
1242
|
+
expect(h.statuses.length).toBe(before)
|
|
1243
|
+
})
|
|
1244
|
+
|
|
1245
|
+
it('emits the playhead every tick', () => {
|
|
1246
|
+
const h = harness(project([clip('a', 0, 4)]))
|
|
1247
|
+
h.scheduler.play()
|
|
1248
|
+
step(h, 0.5)
|
|
1249
|
+
step(h, 0.6)
|
|
1250
|
+
expect(h.times.slice(-2)).toEqual([0.5, 0.6])
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1253
|
+
it('pause stops the stream and freezes the clock', () => {
|
|
1254
|
+
const h = harness(project([clip('a', 0, 4)]))
|
|
1255
|
+
h.scheduler.play()
|
|
1256
|
+
step(h, 1)
|
|
1257
|
+
const server = h.host.server('a')
|
|
1258
|
+
h.scheduler.pause()
|
|
1259
|
+
expect(h.scheduler.status().transport).toBe('paused')
|
|
1260
|
+
expect(server.stops).toBeGreaterThan(0)
|
|
1261
|
+
expect(h.host.sessions.get('a')!.clock.playing).toBe(false)
|
|
1262
|
+
})
|
|
1263
|
+
|
|
1264
|
+
it('tick is inert before anything establishes a position', () => {
|
|
1265
|
+
const h = harness(project([clip('a', 0, 4)]))
|
|
1266
|
+
h.scheduler.tick()
|
|
1267
|
+
expect(h.times).toEqual([])
|
|
1268
|
+
expect(h.scheduler.status().transport).toBe('idle')
|
|
1269
|
+
})
|
|
1270
|
+
|
|
1271
|
+
it('dispose releases every session and stops painting', () => {
|
|
1272
|
+
const h = harness(project([clip('a', 0, 4)]))
|
|
1273
|
+
h.scheduler.play()
|
|
1274
|
+
step(h, 1)
|
|
1275
|
+
const server = h.host.server('a')
|
|
1276
|
+
h.scheduler.dispose()
|
|
1277
|
+
expect(server.disposed).toBe(true)
|
|
1278
|
+
expect(h.host.sessions.size).toBe(0)
|
|
1279
|
+
const painted = h.painter.paints.length
|
|
1280
|
+
step(h, 2)
|
|
1281
|
+
expect(h.painter.paints.length).toBe(painted)
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
it('surfaces a painter throw without stopping the transport', () => {
|
|
1285
|
+
const painter = new FakePainter()
|
|
1286
|
+
const errors: string[] = []
|
|
1287
|
+
const host = new FakeHost()
|
|
1288
|
+
const scheduler = createScheduler({
|
|
1289
|
+
project: project([clip('a', 0, 4)]),
|
|
1290
|
+
host,
|
|
1291
|
+
painter,
|
|
1292
|
+
onError: (m) => errors.push(m),
|
|
1293
|
+
})
|
|
1294
|
+
host.scheduler = scheduler
|
|
1295
|
+
const boom = vi.spyOn(painter, 'paint').mockImplementation(() => {
|
|
1296
|
+
throw new Error('context lost')
|
|
1297
|
+
})
|
|
1298
|
+
scheduler.play()
|
|
1299
|
+
host.setTime(0.5)
|
|
1300
|
+
scheduler.tick()
|
|
1301
|
+
const frame = fakeFrame(500_000)
|
|
1302
|
+
host.server('a').supply = () => frame
|
|
1303
|
+
host.setTime(1)
|
|
1304
|
+
scheduler.tick()
|
|
1305
|
+
expect(errors.some((e) => e.includes('context lost'))).toBe(true)
|
|
1306
|
+
expect(frame.closed).toBe(true)
|
|
1307
|
+
expect(scheduler.status().transport).toBe('playing')
|
|
1308
|
+
boom.mockRestore()
|
|
1309
|
+
})
|
|
1310
|
+
})
|