@bycrux/editor 0.12.0 → 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 +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- 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 +586 -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 +6 -2
- 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 +2202 -251
- 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/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,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T4 — the ring processor's own rules, tested against the shipped string.
|
|
3
|
+
*
|
|
4
|
+
* The worklet ships as an inlined source string (plan decision 6), so it can
|
|
5
|
+
* neither be imported as a module nor type-checked. It can be *executed*
|
|
6
|
+
* though — `decode-worker-source.test.ts` is the precedent. Its only free
|
|
7
|
+
* identifiers are `AudioWorkletProcessor`, `registerProcessor`, `globalThis`
|
|
8
|
+
* and `currentTime`; the first three are shadowed by `new Function`
|
|
9
|
+
* parameters, and `currentTime` (which the report cadence reads dynamically)
|
|
10
|
+
* is driven through the real global object.
|
|
11
|
+
*
|
|
12
|
+
* Worth executing rather than re-implementing main-side, because the rules
|
|
13
|
+
* that live in this string live ONLY in this string: silence-through-
|
|
14
|
+
* underruns (the clock must advance through starvation or the painter
|
|
15
|
+
* freezes), the pause gate, and the epoch echo that lets main discard reports
|
|
16
|
+
* from a ring it has already reset.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
19
|
+
import { audioWorkletSource, RING_PROCESSOR_NAME } from '../audio-worklet-source'
|
|
20
|
+
|
|
21
|
+
const QUANTUM = 128
|
|
22
|
+
|
|
23
|
+
interface Report {
|
|
24
|
+
t: 'consumed'
|
|
25
|
+
epoch: number
|
|
26
|
+
samplesConsumed: number
|
|
27
|
+
underrunFrames: number
|
|
28
|
+
queuedFrames: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RingProcessor {
|
|
32
|
+
process(inputs: unknown[], outputs: Float32Array[][]): boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Harness {
|
|
36
|
+
/** Name the string registered under, or null if the guard suppressed it. */
|
|
37
|
+
registeredAs: string | null
|
|
38
|
+
processor: RingProcessor
|
|
39
|
+
/** Everything the processor posted back to main, in order. */
|
|
40
|
+
reports: Report[]
|
|
41
|
+
/** main → worklet. */
|
|
42
|
+
send(msg: Record<string, unknown>): void
|
|
43
|
+
/** Run one render quantum and return the (fresh, zero-filled) output channels. */
|
|
44
|
+
render(channels?: number): Float32Array[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setCurrentTime(seconds: number): void {
|
|
48
|
+
;(globalThis as unknown as { currentTime: number }).currentTime = seconds
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
delete (globalThis as unknown as { currentTime?: number }).currentTime
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
function harness(processorOptions: Record<string, unknown> = {}, scope: Record<string, unknown> = {}): Harness {
|
|
56
|
+
setCurrentTime(0)
|
|
57
|
+
const reports: Report[] = []
|
|
58
|
+
let onmessage: ((ev: { data: unknown }) => void) | null = null
|
|
59
|
+
|
|
60
|
+
const port = {
|
|
61
|
+
set onmessage(fn: ((ev: { data: unknown }) => void) | null) {
|
|
62
|
+
onmessage = fn
|
|
63
|
+
},
|
|
64
|
+
get onmessage() {
|
|
65
|
+
return onmessage
|
|
66
|
+
},
|
|
67
|
+
postMessage(msg: Report) {
|
|
68
|
+
reports.push(msg)
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class FakeAudioWorkletProcessor {
|
|
73
|
+
port = port
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let registered: { name: string; cls: new (opts: unknown) => RingProcessor } | null = null
|
|
77
|
+
const registerProcessor = (name: string, cls: new (opts: unknown) => RingProcessor) => {
|
|
78
|
+
registered = { name, cls }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const load = new Function(
|
|
82
|
+
'AudioWorkletProcessor',
|
|
83
|
+
'registerProcessor',
|
|
84
|
+
'globalThis',
|
|
85
|
+
audioWorkletSource,
|
|
86
|
+
) as (base: unknown, register: unknown, scope: unknown) => void
|
|
87
|
+
load(FakeAudioWorkletProcessor, registerProcessor, scope)
|
|
88
|
+
|
|
89
|
+
const entry = registered as { name: string; cls: new (opts: unknown) => RingProcessor } | null
|
|
90
|
+
if (!entry) {
|
|
91
|
+
return {
|
|
92
|
+
registeredAs: null,
|
|
93
|
+
processor: { process: () => true },
|
|
94
|
+
reports,
|
|
95
|
+
send: () => {},
|
|
96
|
+
render: () => [],
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const processor = new entry.cls({ processorOptions })
|
|
101
|
+
return {
|
|
102
|
+
registeredAs: entry.name,
|
|
103
|
+
processor,
|
|
104
|
+
reports,
|
|
105
|
+
send(msg) {
|
|
106
|
+
onmessage?.({ data: msg })
|
|
107
|
+
},
|
|
108
|
+
render(channels = 2) {
|
|
109
|
+
// The host hands `process` zero-filled output buffers every quantum;
|
|
110
|
+
// "silence" in this processor means "left untouched".
|
|
111
|
+
const out = Array.from({ length: channels }, () => new Float32Array(QUANTUM))
|
|
112
|
+
processor.process([], [out])
|
|
113
|
+
return out
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Interleaved stereo PCM chunk message with `frames` frames of a constant value per channel. */
|
|
119
|
+
function chunk(frames: number, left: number, right: number) {
|
|
120
|
+
const pcm = new Float32Array(frames * 2)
|
|
121
|
+
for (let i = 0; i < frames; i++) {
|
|
122
|
+
pcm[i * 2] = left
|
|
123
|
+
pcm[i * 2 + 1] = right
|
|
124
|
+
}
|
|
125
|
+
return { t: 'chunk', pcm, channels: 2, frames }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe('registration', () => {
|
|
129
|
+
it('registers the processor name audio-clock.ts constructs its node with', () => {
|
|
130
|
+
expect(harness().registeredAs).toBe(RING_PROCESSOR_NAME)
|
|
131
|
+
expect(RING_PROCESSOR_NAME).toBe('montaj-engine-pcm-ring')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('does not re-register into a scope that already has it (shared context, second clock)', () => {
|
|
135
|
+
const scope: Record<string, unknown> = {}
|
|
136
|
+
expect(harness({}, scope).registeredAs).toBe(RING_PROCESSOR_NAME)
|
|
137
|
+
// A duplicate registerProcessor throws NotSupportedError and would reject
|
|
138
|
+
// addModule, dropping a healthy engine onto the wall clock.
|
|
139
|
+
expect(harness({}, scope).registeredAs).toBe(null)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('pause gate', () => {
|
|
144
|
+
it('renders silence and does not advance the clock before play', () => {
|
|
145
|
+
const h = harness()
|
|
146
|
+
h.send(chunk(QUANTUM, 0.5, -0.5))
|
|
147
|
+
const out = h.render()
|
|
148
|
+
expect(Array.from(out[0]).every((v) => v === 0)).toBe(true)
|
|
149
|
+
setCurrentTime(0.2)
|
|
150
|
+
h.render()
|
|
151
|
+
const last = h.reports[h.reports.length - 1]
|
|
152
|
+
expect(last.samplesConsumed).toBe(0)
|
|
153
|
+
// The chunk is still queued — pausing does not throw audio away.
|
|
154
|
+
expect(last.queuedFrames).toBe(QUANTUM)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('stops advancing on pause and resumes from exactly where it stopped', () => {
|
|
158
|
+
const h = harness()
|
|
159
|
+
h.send({ t: 'play' })
|
|
160
|
+
h.send(chunk(QUANTUM * 3, 0.25, 0.25))
|
|
161
|
+
h.render()
|
|
162
|
+
h.send({ t: 'pause' })
|
|
163
|
+
const atPause = h.reports[h.reports.length - 1]
|
|
164
|
+
expect(atPause.samplesConsumed).toBe(QUANTUM)
|
|
165
|
+
expect(atPause.queuedFrames).toBe(QUANTUM * 2)
|
|
166
|
+
|
|
167
|
+
h.render() // paused: no drain, no advance
|
|
168
|
+
setCurrentTime(0.5)
|
|
169
|
+
h.render()
|
|
170
|
+
expect(h.reports[h.reports.length - 1].samplesConsumed).toBe(QUANTUM)
|
|
171
|
+
|
|
172
|
+
h.send({ t: 'play' })
|
|
173
|
+
const out = h.render()
|
|
174
|
+
expect(out[0][0]).toBeCloseTo(0.25, 6)
|
|
175
|
+
setCurrentTime(1)
|
|
176
|
+
h.render()
|
|
177
|
+
expect(h.reports[h.reports.length - 1].samplesConsumed).toBe(QUANTUM * 3)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('reports immediately on pause rather than making main wait a full interval', () => {
|
|
181
|
+
const h = harness()
|
|
182
|
+
h.send({ t: 'play' })
|
|
183
|
+
h.render()
|
|
184
|
+
const before = h.reports.length
|
|
185
|
+
h.send({ t: 'pause' })
|
|
186
|
+
expect(h.reports.length).toBe(before + 1)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('ring drain', () => {
|
|
191
|
+
it('drains interleaved PCM into the output channels in order', () => {
|
|
192
|
+
const h = harness()
|
|
193
|
+
h.send({ t: 'play' })
|
|
194
|
+
h.send(chunk(QUANTUM, 0.5, -0.5))
|
|
195
|
+
const out = h.render()
|
|
196
|
+
expect(out[0][0]).toBeCloseTo(0.5, 6)
|
|
197
|
+
expect(out[1][0]).toBeCloseTo(-0.5, 6)
|
|
198
|
+
expect(out[0][QUANTUM - 1]).toBeCloseTo(0.5, 6)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('spans chunk boundaries within one render quantum', () => {
|
|
202
|
+
const h = harness()
|
|
203
|
+
h.send({ t: 'play' })
|
|
204
|
+
h.send(chunk(QUANTUM / 2, 1, 1))
|
|
205
|
+
h.send(chunk(QUANTUM / 2, -1, -1))
|
|
206
|
+
const out = h.render()
|
|
207
|
+
expect(out[0][0]).toBeCloseTo(1, 6)
|
|
208
|
+
expect(out[0][QUANTUM / 2 - 1]).toBeCloseTo(1, 6)
|
|
209
|
+
expect(out[0][QUANTUM / 2]).toBeCloseTo(-1, 6)
|
|
210
|
+
expect(out[0][QUANTUM - 1]).toBeCloseTo(-1, 6)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('duplicates the last source channel when a mono clip plays to a stereo output', () => {
|
|
214
|
+
const h = harness()
|
|
215
|
+
h.send({ t: 'play' })
|
|
216
|
+
h.send({ t: 'chunk', pcm: new Float32Array(QUANTUM).fill(0.75), channels: 1, frames: QUANTUM })
|
|
217
|
+
const out = h.render()
|
|
218
|
+
expect(out[0][0]).toBeCloseTo(0.75, 6)
|
|
219
|
+
expect(out[1][0]).toBeCloseTo(0.75, 6)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('silence through underruns', () => {
|
|
224
|
+
it('renders silence AND keeps the clock advancing when the ring is empty', () => {
|
|
225
|
+
const h = harness()
|
|
226
|
+
h.send({ t: 'play' })
|
|
227
|
+
const out = h.render()
|
|
228
|
+
expect(Array.from(out[0]).every((v) => v === 0)).toBe(true)
|
|
229
|
+
|
|
230
|
+
setCurrentTime(0.2)
|
|
231
|
+
h.render()
|
|
232
|
+
const report = h.reports[h.reports.length - 1]
|
|
233
|
+
// The load-bearing half: a starved ring must NOT stall the clock, or the
|
|
234
|
+
// canvas painter that follows it freezes on every slow decode tick.
|
|
235
|
+
expect(report.samplesConsumed).toBe(QUANTUM * 2)
|
|
236
|
+
expect(report.underrunFrames).toBe(QUANTUM * 2)
|
|
237
|
+
expect(report.queuedFrames).toBe(0)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('counts only the starved frames, not the rendered ones', () => {
|
|
241
|
+
const h = harness()
|
|
242
|
+
h.send({ t: 'play' })
|
|
243
|
+
h.send(chunk(QUANTUM, 0.5, 0.5))
|
|
244
|
+
h.render() // fully fed
|
|
245
|
+
h.render() // starved
|
|
246
|
+
setCurrentTime(0.2)
|
|
247
|
+
h.render() // starved
|
|
248
|
+
const report = h.reports[h.reports.length - 1]
|
|
249
|
+
expect(report.samplesConsumed).toBe(QUANTUM * 3)
|
|
250
|
+
expect(report.underrunFrames).toBe(QUANTUM * 2)
|
|
251
|
+
// samplesConsumed - underrunFrames is what audio-clock.ts treats as the
|
|
252
|
+
// ring's drained frames; it must equal what was actually enqueued.
|
|
253
|
+
expect(report.samplesConsumed - report.underrunFrames).toBe(QUANTUM)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('report cadence and epoch', () => {
|
|
258
|
+
it('reports no more often than the injected interval', () => {
|
|
259
|
+
const h = harness({ reportIntervalS: 0.1 })
|
|
260
|
+
h.send({ t: 'play' })
|
|
261
|
+
h.render()
|
|
262
|
+
h.render()
|
|
263
|
+
expect(h.reports.length).toBe(0)
|
|
264
|
+
setCurrentTime(0.1)
|
|
265
|
+
h.render()
|
|
266
|
+
expect(h.reports.length).toBe(1)
|
|
267
|
+
setCurrentTime(0.15)
|
|
268
|
+
h.render()
|
|
269
|
+
expect(h.reports.length).toBe(1)
|
|
270
|
+
setCurrentTime(0.2)
|
|
271
|
+
h.render()
|
|
272
|
+
expect(h.reports.length).toBe(2)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('honors an injected interval rather than a duplicated constant', () => {
|
|
276
|
+
const h = harness({ reportIntervalS: 0.5 })
|
|
277
|
+
h.send({ t: 'play' })
|
|
278
|
+
setCurrentTime(0.2)
|
|
279
|
+
h.render()
|
|
280
|
+
expect(h.reports.length).toBe(0)
|
|
281
|
+
setCurrentTime(0.5)
|
|
282
|
+
h.render()
|
|
283
|
+
expect(h.reports.length).toBe(1)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('resets the ring, zeroes the counters and adopts the new epoch immediately', () => {
|
|
287
|
+
const h = harness()
|
|
288
|
+
h.send({ t: 'play' })
|
|
289
|
+
h.send(chunk(QUANTUM * 4, 0.5, 0.5))
|
|
290
|
+
h.render()
|
|
291
|
+
setCurrentTime(0.1)
|
|
292
|
+
h.render()
|
|
293
|
+
expect(h.reports[h.reports.length - 1].epoch).toBe(0)
|
|
294
|
+
|
|
295
|
+
h.send({ t: 'reset', epoch: 7 })
|
|
296
|
+
const afterReset = h.reports[h.reports.length - 1]
|
|
297
|
+
expect(afterReset.epoch).toBe(7)
|
|
298
|
+
expect(afterReset.samplesConsumed).toBe(0)
|
|
299
|
+
expect(afterReset.underrunFrames).toBe(0)
|
|
300
|
+
// Stale audio for the position we just left is discarded, not played.
|
|
301
|
+
expect(afterReset.queuedFrames).toBe(0)
|
|
302
|
+
|
|
303
|
+
const out = h.render()
|
|
304
|
+
expect(Array.from(out[0]).every((v) => v === 0)).toBe(true)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('stamps every later report with the new epoch so main can discard stale ones', () => {
|
|
308
|
+
const h = harness()
|
|
309
|
+
h.send({ t: 'play' })
|
|
310
|
+
h.send({ t: 'reset', epoch: 3 })
|
|
311
|
+
setCurrentTime(0.2)
|
|
312
|
+
h.render()
|
|
313
|
+
expect(h.reports.every((r) => r.epoch === 0 || r.epoch === 3)).toBe(true)
|
|
314
|
+
expect(h.reports[h.reports.length - 1].epoch).toBe(3)
|
|
315
|
+
})
|
|
316
|
+
})
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T3 — the decode planner, which is where three of SP1's five engine
|
|
3
|
+
* requirements are actually decided. The three tests named by the plan
|
|
4
|
+
* (`decode-order-feed`, `pipelined-batches`, `min-cts-target`) are `it()`
|
|
5
|
+
* blocks with exactly those names; the other two (`preroll-epsilon`, and
|
|
6
|
+
* supersession's `stale frames closed, done always posted`) live in
|
|
7
|
+
* `decode-worker-source.test.ts`, where they can be run against the shipped
|
|
8
|
+
* worker string itself.
|
|
9
|
+
*
|
|
10
|
+
* Every table here carries a B-frame pattern for the same reason T2's tests
|
|
11
|
+
* do: on an all-intra table (which is what SP3's proxies are) decode order ==
|
|
12
|
+
* presentation order, so a correct planner and the broken one that sorts by
|
|
13
|
+
* cts — or targets `batch[0].tsUs` — produce identical answers. That is
|
|
14
|
+
* precisely how both bugs survived to SP1's measurement stage
|
|
15
|
+
* (`spikes/playback-engine/RESULTS.md` §6.1, §7.1).
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import { buildPresIndex, type SampleRef, type SampleTable } from '../demux'
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_DECODE_AHEAD_FRAMES,
|
|
21
|
+
MAX_IN_FLIGHT_BATCHES,
|
|
22
|
+
minBatchFrames,
|
|
23
|
+
minCtsUs,
|
|
24
|
+
onFrameBuffered,
|
|
25
|
+
onFramesConsumed,
|
|
26
|
+
planNextBatches,
|
|
27
|
+
planSeek,
|
|
28
|
+
planStreamStart,
|
|
29
|
+
reconcileOnBatchDone,
|
|
30
|
+
type PipelineConfig,
|
|
31
|
+
type PipelineState,
|
|
32
|
+
} from '../batch-planner'
|
|
33
|
+
|
|
34
|
+
/** One synthetic sample. Times in µs; 1000µs "frames" keep the tables readable. */
|
|
35
|
+
function s(cts: number, dts: number, isKey = false): SampleRef {
|
|
36
|
+
return { tsUs: cts, dtsUs: dts, durUs: 1000, isKey, data: new Uint8Array(0) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function table(samples: SampleRef[]): SampleTable {
|
|
40
|
+
return { samples, presIndex: buildPresIndex(samples) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reordered GOP, decode order: I0 P3 B1 B2 P6 B4 B5 I7 (cts in the comments).
|
|
45
|
+
* The same fixture shape `demux.test.ts` uses, so both layers are exercised
|
|
46
|
+
* against one reordering pattern.
|
|
47
|
+
*/
|
|
48
|
+
function bFrameGop(): SampleRef[] {
|
|
49
|
+
return [
|
|
50
|
+
s(0, 0, true), // 0: I0
|
|
51
|
+
s(3000, 1000), // 1: P3
|
|
52
|
+
s(1000, 2000), // 2: B1
|
|
53
|
+
s(2000, 3000), // 3: B2
|
|
54
|
+
s(6000, 4000), // 4: P6
|
|
55
|
+
s(4000, 5000), // 5: B4
|
|
56
|
+
s(5000, 6000), // 6: B5
|
|
57
|
+
s(7000, 7000, true), // 7: I7
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A long all-intra table — the shape of a real SP3 proxy, for the pipeline simulations. */
|
|
62
|
+
function allIntra(n: number): SampleRef[] {
|
|
63
|
+
return Array.from({ length: n }, (_, i) => s(i * 33333.333, i * 33333.333, true))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SHIPPED: PipelineConfig = {
|
|
67
|
+
decodeAheadFrames: DEFAULT_DECODE_AHEAD_FRAMES,
|
|
68
|
+
maxInFlightBatches: MAX_IN_FLIGHT_BATCHES,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* SP1's PRE-fix planner, rebuilt out of the shipped one's config: exactly one
|
|
73
|
+
* request in flight, and each batch sized to the current shortfall with no
|
|
74
|
+
* floor. Nothing else differs, so what the `pipelined-batches` test observes
|
|
75
|
+
* is the scheduling rule and not a different implementation.
|
|
76
|
+
*/
|
|
77
|
+
const DEGENERATE: PipelineConfig = {
|
|
78
|
+
decodeAheadFrames: DEFAULT_DECODE_AHEAD_FRAMES,
|
|
79
|
+
maxInFlightBatches: 1,
|
|
80
|
+
minBatchFrames: 1,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface SimOptions {
|
|
84
|
+
ticks: number
|
|
85
|
+
/** Frames the painter consumes per tick. */
|
|
86
|
+
paintPerTick: number
|
|
87
|
+
/** Ticks between a batch being posted and the worker delivering it (decode + flush + round trip). */
|
|
88
|
+
latency: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface SimResult {
|
|
92
|
+
batchSizes: number[]
|
|
93
|
+
maxInFlightBatches: number
|
|
94
|
+
/** Peak `buffered + inFlightFrames` — the budget N is supposed to cap. */
|
|
95
|
+
maxAlive: number
|
|
96
|
+
painted: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Drive the planner the way the real tick does — pump, paint, then absorb
|
|
101
|
+
* whatever the worker delivered — using the planner's own transition
|
|
102
|
+
* functions, so the simulation cannot drift from the shipped bookkeeping.
|
|
103
|
+
*/
|
|
104
|
+
function simulate(src: SampleTable, config: PipelineConfig, opts: SimOptions): SimResult {
|
|
105
|
+
let state: PipelineState = planStreamStart(src, 0)
|
|
106
|
+
const inFlight: { expected: number; landsAt: number }[] = []
|
|
107
|
+
const batchSizes: number[] = []
|
|
108
|
+
let maxInFlightBatches = 0
|
|
109
|
+
let maxAlive = 0
|
|
110
|
+
let painted = 0
|
|
111
|
+
|
|
112
|
+
for (let t = 0; t < opts.ticks; t++) {
|
|
113
|
+
const planned = planNextBatches(src, state, config)
|
|
114
|
+
state = planned.state
|
|
115
|
+
for (const batch of planned.batches) {
|
|
116
|
+
batchSizes.push(batch.count)
|
|
117
|
+
inFlight.push({ expected: batch.count, landsAt: t + opts.latency })
|
|
118
|
+
}
|
|
119
|
+
maxInFlightBatches = Math.max(maxInFlightBatches, state.inFlightBatches)
|
|
120
|
+
maxAlive = Math.max(maxAlive, state.buffered + state.inFlightFrames)
|
|
121
|
+
|
|
122
|
+
for (let p = 0; p < opts.paintPerTick && state.buffered > 0; p++) {
|
|
123
|
+
state = onFramesConsumed(state, 1)
|
|
124
|
+
painted++
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
while (inFlight.length > 0 && inFlight[0].landsAt <= t) {
|
|
128
|
+
const batch = inFlight.shift()!
|
|
129
|
+
for (let k = 0; k < batch.expected; k++) state = onFrameBuffered(state)
|
|
130
|
+
state = reconcileOnBatchDone(state, batch.expected, batch.expected)
|
|
131
|
+
maxAlive = Math.max(maxAlive, state.buffered + state.inFlightFrames)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { batchSizes, maxInFlightBatches, maxAlive, painted }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe('batch planner — SP1 engine requirements', () => {
|
|
139
|
+
it('decode-order-feed', () => {
|
|
140
|
+
const src = table(bFrameGop())
|
|
141
|
+
|
|
142
|
+
// A seek slices DECODE indices: enclosing keyframe → the sample displayed
|
|
143
|
+
// at the target, found by bisecting presIndex (T2), not by scanning cts.
|
|
144
|
+
const seek = planSeek(src, 2000)!
|
|
145
|
+
expect(seek.startIdx).toBe(0) // I0, walking back through decode order
|
|
146
|
+
expect(seek.endIdx).toBe(4) // B2 (cts 2000) sits at decode index 3
|
|
147
|
+
expect(seek.targetTsUs).toBe(2000)
|
|
148
|
+
|
|
149
|
+
const fed = src.samples.slice(seek.startIdx, seek.endIdx)
|
|
150
|
+
// What actually goes to VideoDecoder: strictly ascending DTS…
|
|
151
|
+
expect(fed.map((x) => x.dtsUs)).toEqual([0, 1000, 2000, 3000])
|
|
152
|
+
// …and a cts sequence that is NOT ascending. If this were ever sorted into
|
|
153
|
+
// presentation order the decoder would throw on the first non-intra frame
|
|
154
|
+
// and stay dead until respawned (SP1 §7.1).
|
|
155
|
+
expect(fed.map((x) => x.tsUs)).toEqual([0, 3000, 1000, 2000])
|
|
156
|
+
expect(fed[0].isKey).toBe(true)
|
|
157
|
+
|
|
158
|
+
// Streaming batches tile `samples` contiguously in decode order: no gaps,
|
|
159
|
+
// no overlaps, no reordering, every sample requested exactly once.
|
|
160
|
+
const src2 = table(allIntra(97))
|
|
161
|
+
let state = planStreamStart(src2, 0)
|
|
162
|
+
const covered: number[] = []
|
|
163
|
+
for (let round = 0; round < 200 && state.nextSampleIdx < src2.samples.length; round++) {
|
|
164
|
+
const planned = planNextBatches(src2, state, SHIPPED)
|
|
165
|
+
for (const batch of planned.batches) {
|
|
166
|
+
for (let i = batch.startIdx; i < batch.endIdx; i++) covered.push(i)
|
|
167
|
+
}
|
|
168
|
+
// Pretend every requested frame arrived and was painted, so the next
|
|
169
|
+
// round has budget again.
|
|
170
|
+
state = { ...planned.state, buffered: 0, inFlightFrames: 0, inFlightBatches: 0 }
|
|
171
|
+
}
|
|
172
|
+
expect(covered).toEqual(Array.from({ length: 97 }, (_, i) => i))
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('min-cts-target', () => {
|
|
176
|
+
const src = table(bFrameGop())
|
|
177
|
+
|
|
178
|
+
// Mid-stream batch (no session pre-roll left): decode indices 1..4 —
|
|
179
|
+
// P3(3000) B1(1000) B2(2000) P6(6000).
|
|
180
|
+
const midStream: PipelineState = {
|
|
181
|
+
nextSampleIdx: 1,
|
|
182
|
+
buffered: 0,
|
|
183
|
+
inFlightFrames: 0,
|
|
184
|
+
inFlightBatches: 0,
|
|
185
|
+
firstBatchTargetTsUs: 0,
|
|
186
|
+
}
|
|
187
|
+
const { batches } = planNextBatches(src, midStream, {
|
|
188
|
+
decodeAheadFrames: 4,
|
|
189
|
+
maxInFlightBatches: 1,
|
|
190
|
+
})
|
|
191
|
+
expect(batches).toHaveLength(1)
|
|
192
|
+
const batch = batches[0]
|
|
193
|
+
expect([batch.startIdx, batch.endIdx]).toEqual([1, 5])
|
|
194
|
+
|
|
195
|
+
// The requirement: the target is the MINIMUM cts in the batch.
|
|
196
|
+
expect(batch.targetTsUs).toBe(1000)
|
|
197
|
+
expect(minCtsUs(src.samples, 1, 5)).toBe(1000)
|
|
198
|
+
|
|
199
|
+
// And the bug it replaces: `batch[0].tsUs` is 3000 in decode order, which
|
|
200
|
+
// would have made the worker discard two perfectly good frames as false
|
|
201
|
+
// pre-roll on every mid-stream batch (SP1 §6.1's second, latent bug).
|
|
202
|
+
const brokenTarget = src.samples[batch.startIdx].tsUs
|
|
203
|
+
expect(brokenTarget).toBe(3000)
|
|
204
|
+
const falselyDropped = src.samples
|
|
205
|
+
.slice(batch.startIdx, batch.endIdx)
|
|
206
|
+
.filter((x) => x.tsUs < brokenTarget)
|
|
207
|
+
expect(falselyDropped.map((x) => x.tsUs)).toEqual([1000, 2000])
|
|
208
|
+
|
|
209
|
+
// The one legitimate pre-roll — keyframe → the frame actually asked for —
|
|
210
|
+
// is expressed as the first batch's floor, not as a per-batch guess.
|
|
211
|
+
const started = planStreamStart(src, 2000)
|
|
212
|
+
expect(started.nextSampleIdx).toBe(0) // decode from I0
|
|
213
|
+
expect(started.firstBatchTargetTsUs).toBe(2000) // …but emit nothing before cts 2000
|
|
214
|
+
const first = planNextBatches(src, started, SHIPPED).batches[0]
|
|
215
|
+
expect(minCtsUs(src.samples, first.startIdx, first.endIdx)).toBe(0)
|
|
216
|
+
expect(first.targetTsUs).toBe(2000)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('pipelined-batches', () => {
|
|
220
|
+
const src = table(allIntra(400))
|
|
221
|
+
const floor = minBatchFrames(DEFAULT_DECODE_AHEAD_FRAMES)
|
|
222
|
+
expect(floor).toBe(7) // max(4, N/4) at N=30
|
|
223
|
+
|
|
224
|
+
// (a) SP1's pre-fix configuration, reproduced against this same planner:
|
|
225
|
+
// one request in flight, batch sized to the shortfall. After the initial
|
|
226
|
+
// fill it asks for ONE frame per batch — and every batch ends in
|
|
227
|
+
// `decoder.flush()`, which drains the whole decoder pipeline. That is the
|
|
228
|
+
// measured 4.5fps supply limit (§6.1), not a hypothetical.
|
|
229
|
+
const degenerate = simulate(src, DEGENERATE, { ticks: 60, paintPerTick: 1, latency: 0 })
|
|
230
|
+
expect(degenerate.maxInFlightBatches).toBe(1)
|
|
231
|
+
expect(new Set(degenerate.batchSizes.slice(1))).toEqual(new Set([1]))
|
|
232
|
+
|
|
233
|
+
// (b) The shipped configuration on the identical workload: batches floored
|
|
234
|
+
// so flush cost amortizes over many frames instead of one.
|
|
235
|
+
const shipped = simulate(src, SHIPPED, { ticks: 60, paintPerTick: 1, latency: 0 })
|
|
236
|
+
expect(Math.min(...shipped.batchSizes)).toBeGreaterThanOrEqual(floor)
|
|
237
|
+
expect(shipped.painted).toBe(degenerate.painted) // same workload, so the comparison is like-for-like
|
|
238
|
+
|
|
239
|
+
// (c) …and when decode takes longer than one pump interval, the planner
|
|
240
|
+
// keeps a second batch queued behind the in-flight one rather than
|
|
241
|
+
// degenerating to a single request the worker has to wait on.
|
|
242
|
+
const pipelined = simulate(src, SHIPPED, { ticks: 60, paintPerTick: 4, latency: 2 })
|
|
243
|
+
expect(pipelined.maxInFlightBatches).toBe(MAX_IN_FLIGHT_BATCHES)
|
|
244
|
+
expect(Math.min(...pipelined.batchSizes)).toBeGreaterThanOrEqual(floor)
|
|
245
|
+
|
|
246
|
+
// (d) The budget invariant that must survive all of the above: N still
|
|
247
|
+
// means "at most N frames alive at once" (SP1 §8's memory sweep rests on
|
|
248
|
+
// it), in every configuration.
|
|
249
|
+
for (const run of [degenerate, shipped, pipelined]) {
|
|
250
|
+
expect(run.maxAlive).toBeLessThanOrEqual(DEFAULT_DECODE_AHEAD_FRAMES)
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('batch planner — bookkeeping', () => {
|
|
256
|
+
it('stops at end of source and never plans past the last sample', () => {
|
|
257
|
+
const src = table(allIntra(5))
|
|
258
|
+
const { batches, state } = planNextBatches(src, planStreamStart(src, 0), SHIPPED)
|
|
259
|
+
expect(batches).toEqual([{ startIdx: 0, endIdx: 5, count: 5, targetTsUs: 0 }])
|
|
260
|
+
expect(planNextBatches(src, state, SHIPPED).batches).toEqual([])
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('lets a starving pipeline through the batch floor rather than stalling', () => {
|
|
264
|
+
const src = table(allIntra(100))
|
|
265
|
+
// Nothing buffered, nothing coming, but only 3 frames of budget: a batch
|
|
266
|
+
// below the floor still beats painting nothing.
|
|
267
|
+
const starving: PipelineState = {
|
|
268
|
+
nextSampleIdx: 10,
|
|
269
|
+
buffered: 0,
|
|
270
|
+
inFlightFrames: 0,
|
|
271
|
+
inFlightBatches: 0,
|
|
272
|
+
firstBatchTargetTsUs: 0,
|
|
273
|
+
}
|
|
274
|
+
const { batches } = planNextBatches(src, starving, { decodeAheadFrames: 3, maxInFlightBatches: 2 })
|
|
275
|
+
expect(batches.map((b) => b.count)).toEqual([3])
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('reconciles inFlightFrames against what a batch actually decoded', () => {
|
|
279
|
+
// A 10-frame batch whose first 4 were pre-roll: only 6 frames will ever
|
|
280
|
+
// arrive, so the per-frame decrement under-counts by 4. Without
|
|
281
|
+
// reconciliation that 4 is lost decode-ahead capacity, permanently.
|
|
282
|
+
let state: PipelineState = {
|
|
283
|
+
nextSampleIdx: 10,
|
|
284
|
+
buffered: 0,
|
|
285
|
+
inFlightFrames: 10,
|
|
286
|
+
inFlightBatches: 1,
|
|
287
|
+
firstBatchTargetTsUs: 0,
|
|
288
|
+
}
|
|
289
|
+
for (let i = 0; i < 6; i++) state = onFrameBuffered(state)
|
|
290
|
+
expect(state).toMatchObject({ buffered: 6, inFlightFrames: 4 })
|
|
291
|
+
state = reconcileOnBatchDone(state, 10, 6)
|
|
292
|
+
expect(state).toMatchObject({ buffered: 6, inFlightFrames: 0, inFlightBatches: 0 })
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('planSeek returns null for an empty table', () => {
|
|
296
|
+
expect(planSeek(table([]), 0)).toBeNull()
|
|
297
|
+
})
|
|
298
|
+
})
|