@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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame server against a RANGED source: what happens when a decode range's
|
|
3
|
+
* bytes are not resident yet.
|
|
4
|
+
*
|
|
5
|
+
* Four properties, each of which fails loudly if it is wrong:
|
|
6
|
+
*
|
|
7
|
+
* 1. **The resident case stays synchronous.** `ensure` returns `null` when the
|
|
8
|
+
* bytes are already there, and the post must go out in the same tick — a
|
|
9
|
+
* seek that hopped a microtask would paint late, and the whole reason
|
|
10
|
+
* all-intra was kept is that a seek decodes exactly one frame instantly.
|
|
11
|
+
* 2. **Nothing overtakes a batch that is still fetching.** `VideoDecoder`
|
|
12
|
+
* throws on the first out-of-order chunk and stays dead until the worker is
|
|
13
|
+
* respawned (SP1 §7.1), so posts are serialized behind a pending fetch.
|
|
14
|
+
* 3. **…but a SUPERSEDED batch is not something to wait for.** A scrub landing
|
|
15
|
+
* mid-fetch retires the queue and posts immediately; queueing behind the
|
|
16
|
+
* request it just made pointless would give back the latency this whole
|
|
17
|
+
* change exists to remove.
|
|
18
|
+
* 4. **A failed or dropped fetch settles, never hangs.** `seek().frame` is
|
|
19
|
+
* contractually total; a range that will not load — or one abandoned
|
|
20
|
+
* because the caller asked for somewhere else — has to resolve `null` and
|
|
21
|
+
* report, not leave the scrubber waiting forever.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
24
|
+
import { buildPresIndex, type ChunkSource, type DemuxedSource, type SampleRef } from '../demux'
|
|
25
|
+
import { createFrameServer, type DecodeCmd, type DecodeEvt, type DecodeWorkerPort } from '../frame-server'
|
|
26
|
+
|
|
27
|
+
interface FakePort extends DecodeWorkerPort {
|
|
28
|
+
sent: DecodeCmd[]
|
|
29
|
+
terminated: boolean
|
|
30
|
+
deliver: (evt: DecodeEvt) => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fakePort(): FakePort {
|
|
34
|
+
const port: FakePort = {
|
|
35
|
+
sent: [],
|
|
36
|
+
terminated: false,
|
|
37
|
+
onmessage: null,
|
|
38
|
+
postMessage(msg) {
|
|
39
|
+
port.sent.push(msg)
|
|
40
|
+
},
|
|
41
|
+
terminate() {
|
|
42
|
+
port.terminated = true
|
|
43
|
+
},
|
|
44
|
+
deliver(evt) {
|
|
45
|
+
port.onmessage?.({ data: evt })
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
return port
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every `decodeRange` posted so far, oldest first. */
|
|
52
|
+
function batches(port: FakePort): { startTs: number; count: number; reqId: number }[] {
|
|
53
|
+
return port.sent
|
|
54
|
+
.filter((m): m is Extract<DecodeCmd, { t: 'decodeRange' }> => m.t === 'decodeRange')
|
|
55
|
+
.map((m) => ({ startTs: m.samples[0]?.tsUs ?? -1, count: m.samples.length, reqId: m.reqId }))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An all-intra ranged source: the index is complete, the bytes arrive through
|
|
60
|
+
* `ensure`. `gate` decides what each call returns — `null` for resident.
|
|
61
|
+
*/
|
|
62
|
+
function rangedSource(
|
|
63
|
+
gate: (startIdx: number, endIdx: number) => Promise<void> | null,
|
|
64
|
+
n = 120,
|
|
65
|
+
): DemuxedSource {
|
|
66
|
+
const samples: SampleRef[] = Array.from({ length: n }, (_, i) => ({
|
|
67
|
+
tsUs: i * 33333,
|
|
68
|
+
dtsUs: i * 33333,
|
|
69
|
+
durUs: 33333,
|
|
70
|
+
isKey: true,
|
|
71
|
+
data: new Uint8Array(0),
|
|
72
|
+
offset: i * 1000,
|
|
73
|
+
size: 1000,
|
|
74
|
+
}))
|
|
75
|
+
const video: ChunkSource = {
|
|
76
|
+
kind: 'video',
|
|
77
|
+
codec: 'avc1.640028',
|
|
78
|
+
description: undefined,
|
|
79
|
+
fps: 30,
|
|
80
|
+
durationS: n / 30,
|
|
81
|
+
coded: { width: 1280, height: 720 },
|
|
82
|
+
samples,
|
|
83
|
+
presIndex: buildPresIndex(samples),
|
|
84
|
+
firstPresentationTsUs: 0,
|
|
85
|
+
ensure: gate,
|
|
86
|
+
}
|
|
87
|
+
return { src: '/proxies/ranged_proxy.mp4', video, audio: null }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A promise plus its settle handles, for driving a fetch by hand. */
|
|
91
|
+
function deferred(): { promise: Promise<void>; resolve: () => void; reject: (e: Error) => void } {
|
|
92
|
+
let resolve: () => void = () => {}
|
|
93
|
+
let reject: (e: Error) => void = () => {}
|
|
94
|
+
const promise = new Promise<void>((res, rej) => {
|
|
95
|
+
resolve = () => res()
|
|
96
|
+
reject = rej
|
|
97
|
+
})
|
|
98
|
+
return { promise, resolve, reject }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Let every already-settled promise in the post chain run. */
|
|
102
|
+
const settle = () => new Promise<void>((r) => setTimeout(r, 0))
|
|
103
|
+
|
|
104
|
+
describe('frame server — ranged source', () => {
|
|
105
|
+
it('posts in the same tick when the range is already resident', () => {
|
|
106
|
+
const ensure = vi.fn(() => null)
|
|
107
|
+
const port = fakePort()
|
|
108
|
+
const server = createFrameServer({ source: rangedSource(ensure), spawnWorker: () => port })
|
|
109
|
+
|
|
110
|
+
server.seek(30 * 33333)
|
|
111
|
+
|
|
112
|
+
// Synchronously — no await anywhere in this test.
|
|
113
|
+
expect(batches(port)).toHaveLength(1)
|
|
114
|
+
expect(batches(port)[0]).toMatchObject({ startTs: 30 * 33333, count: 1 })
|
|
115
|
+
expect(ensure).toHaveBeenCalledWith(30, 31)
|
|
116
|
+
server.dispose()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('holds the post until the bytes land, then sends it', async () => {
|
|
120
|
+
const gate = deferred()
|
|
121
|
+
let calls = 0
|
|
122
|
+
const port = fakePort()
|
|
123
|
+
const server = createFrameServer({
|
|
124
|
+
source: rangedSource(() => (calls++ === 0 ? gate.promise : null)),
|
|
125
|
+
spawnWorker: () => port,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
server.seek(50 * 33333)
|
|
129
|
+
expect(batches(port)).toHaveLength(0)
|
|
130
|
+
|
|
131
|
+
gate.resolve()
|
|
132
|
+
await settle()
|
|
133
|
+
|
|
134
|
+
expect(batches(port)).toHaveLength(1)
|
|
135
|
+
expect(batches(port)[0].startTs).toBe(50 * 33333)
|
|
136
|
+
server.dispose()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('posts nothing further while a range is still fetching, then resumes in order', async () => {
|
|
140
|
+
// `planNextBatches` hands out one batch per pump (the first takes the whole
|
|
141
|
+
// decode-ahead capacity), so this is what "in order" looks like from the
|
|
142
|
+
// outside: while batch A's bytes are in flight nothing else reaches the
|
|
143
|
+
// worker, no matter how many ticks come through. That ordering matters
|
|
144
|
+
// because `VideoDecoder` throws on the first out-of-order chunk and stays
|
|
145
|
+
// dead until the worker is respawned (SP1 §7.1).
|
|
146
|
+
const gate = deferred()
|
|
147
|
+
let calls = 0
|
|
148
|
+
const port = fakePort()
|
|
149
|
+
const server = createFrameServer({
|
|
150
|
+
source: rangedSource(() => (calls++ === 0 ? gate.promise : null)),
|
|
151
|
+
decodeAheadFrames: 8,
|
|
152
|
+
spawnWorker: () => port,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
server.startStream(0)
|
|
156
|
+
server.nextFrameFor(0)
|
|
157
|
+
server.nextFrameFor(33333)
|
|
158
|
+
expect(batches(port)).toHaveLength(0)
|
|
159
|
+
|
|
160
|
+
gate.resolve()
|
|
161
|
+
await settle()
|
|
162
|
+
|
|
163
|
+
const posted = batches(port)
|
|
164
|
+
expect(posted).toHaveLength(1)
|
|
165
|
+
expect(posted[0]).toMatchObject({ startTs: 0, count: 8 })
|
|
166
|
+
server.dispose()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('drops a deferred batch whose request was superseded while fetching', async () => {
|
|
170
|
+
const gate = deferred()
|
|
171
|
+
let calls = 0
|
|
172
|
+
const port = fakePort()
|
|
173
|
+
const server = createFrameServer({
|
|
174
|
+
source: rangedSource(() => (calls++ === 0 ? gate.promise : null)),
|
|
175
|
+
spawnWorker: () => port,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const first = server.seek(10 * 33333)
|
|
179
|
+
const second = server.seek(80 * 33333)
|
|
180
|
+
gate.resolve()
|
|
181
|
+
await settle()
|
|
182
|
+
|
|
183
|
+
// The superseded seek settles with null (that is `claimReqId`'s job) and
|
|
184
|
+
// its batch never reaches the worker — decoding it would only produce
|
|
185
|
+
// frames `onFrameEvt` closes as stale.
|
|
186
|
+
await expect(first.frame).resolves.toBeNull()
|
|
187
|
+
const posted = batches(port)
|
|
188
|
+
expect(posted.every((b) => b.reqId !== first.reqId)).toBe(true)
|
|
189
|
+
expect(posted.some((b) => b.reqId === second.reqId)).toBe(true)
|
|
190
|
+
server.dispose()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('settles a seek with null and reports when its bytes cannot be fetched', async () => {
|
|
194
|
+
const gate = deferred()
|
|
195
|
+
const onError = vi.fn()
|
|
196
|
+
const port = fakePort()
|
|
197
|
+
const server = createFrameServer({
|
|
198
|
+
source: rangedSource(() => gate.promise),
|
|
199
|
+
spawnWorker: () => port,
|
|
200
|
+
onError,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const { frame } = server.seek(60 * 33333)
|
|
204
|
+
gate.reject(new Error('read: /proxies/ranged_proxy.mp4 [0-1048575] → 503 Service Unavailable'))
|
|
205
|
+
|
|
206
|
+
await expect(frame).resolves.toBeNull()
|
|
207
|
+
expect(onError).toHaveBeenCalledWith(expect.stringContaining('503 Service Unavailable'))
|
|
208
|
+
expect(server.stats().lastError).toMatch(/503 Service Unavailable/)
|
|
209
|
+
expect(batches(port)).toHaveLength(0)
|
|
210
|
+
server.dispose()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('does not make a new seek wait behind the fetch it superseded', async () => {
|
|
214
|
+
// The latency trap in a serialized post queue: a scrub lands while the
|
|
215
|
+
// stream's bytes are still in flight, and if the new request queued behind
|
|
216
|
+
// the abandoned one it would paint no sooner than the frame nobody wants.
|
|
217
|
+
// `claimReqId` retires the chain instead, so a resident seek posts in the
|
|
218
|
+
// same tick — exactly as it does with no fetch pending at all.
|
|
219
|
+
const gate = deferred()
|
|
220
|
+
let calls = 0
|
|
221
|
+
const port = fakePort()
|
|
222
|
+
const server = createFrameServer({
|
|
223
|
+
source: rangedSource(() => (calls++ === 0 ? gate.promise : null)),
|
|
224
|
+
decodeAheadFrames: 8,
|
|
225
|
+
spawnWorker: () => port,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
server.startStream(0)
|
|
229
|
+
expect(batches(port)).toHaveLength(0)
|
|
230
|
+
|
|
231
|
+
server.seek(90 * 33333)
|
|
232
|
+
|
|
233
|
+
// Synchronously, with the stream's fetch still unresolved.
|
|
234
|
+
expect(batches(port)).toHaveLength(1)
|
|
235
|
+
expect(batches(port)[0].startTs).toBe(90 * 33333)
|
|
236
|
+
|
|
237
|
+
gate.resolve()
|
|
238
|
+
await settle()
|
|
239
|
+
|
|
240
|
+
// And the abandoned batch never shows up late.
|
|
241
|
+
expect(batches(port)).toHaveLength(1)
|
|
242
|
+
server.dispose()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('does not post a deferred batch after dispose', async () => {
|
|
246
|
+
const gate = deferred()
|
|
247
|
+
const port = fakePort()
|
|
248
|
+
const server = createFrameServer({
|
|
249
|
+
source: rangedSource(() => gate.promise),
|
|
250
|
+
spawnWorker: () => port,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
server.seek(40 * 33333)
|
|
254
|
+
server.dispose()
|
|
255
|
+
gate.resolve()
|
|
256
|
+
await settle()
|
|
257
|
+
|
|
258
|
+
expect(batches(port)).toHaveLength(0)
|
|
259
|
+
expect(port.terminated).toBe(true)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T3 — the frame server's main-side half: the worker protocol, the frame
|
|
3
|
+
* budget, `batchExpected` reconciliation, and the `VideoFrame` leak contract.
|
|
4
|
+
*
|
|
5
|
+
* The worker port is injected, so none of this needs `Worker`,
|
|
6
|
+
* `URL.createObjectURL` or WebCodecs — jsdom has none of the three. What the
|
|
7
|
+
* fakes cannot prove (that a real decoder returns frames at all) is what T9's
|
|
8
|
+
* operator parity checklist is for; what they CAN prove is every place a
|
|
9
|
+
* `VideoFrame` could leak or a promise could hang, which is where SP1's bugs
|
|
10
|
+
* actually were.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
13
|
+
import { buildPresIndex, type ChunkSource, type DemuxedSource, type SampleRef } from '../demux'
|
|
14
|
+
import { DEFAULT_DECODE_AHEAD_FRAMES, minBatchFrames } from '../batch-planner'
|
|
15
|
+
import {
|
|
16
|
+
createFrameServer,
|
|
17
|
+
type DecodeCmd,
|
|
18
|
+
type DecodeEvt,
|
|
19
|
+
type DecodeWorkerPort,
|
|
20
|
+
} from '../frame-server'
|
|
21
|
+
|
|
22
|
+
interface FakeFrame {
|
|
23
|
+
timestamp: number
|
|
24
|
+
closed: boolean
|
|
25
|
+
close: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function frame(timestamp: number): FakeFrame {
|
|
29
|
+
return {
|
|
30
|
+
timestamp,
|
|
31
|
+
closed: false,
|
|
32
|
+
close() {
|
|
33
|
+
this.closed = true
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Hand a fake frame to the server the way the worker would. */
|
|
39
|
+
function asVideoFrame(f: FakeFrame): VideoFrame {
|
|
40
|
+
return f as unknown as VideoFrame
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface FakePort extends DecodeWorkerPort {
|
|
44
|
+
sent: DecodeCmd[]
|
|
45
|
+
terminated: boolean
|
|
46
|
+
deliver: (evt: DecodeEvt) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fakePort(): FakePort {
|
|
50
|
+
const port: FakePort = {
|
|
51
|
+
sent: [],
|
|
52
|
+
terminated: false,
|
|
53
|
+
onmessage: null,
|
|
54
|
+
postMessage(msg) {
|
|
55
|
+
port.sent.push(msg)
|
|
56
|
+
},
|
|
57
|
+
terminate() {
|
|
58
|
+
port.terminated = true
|
|
59
|
+
},
|
|
60
|
+
deliver(evt) {
|
|
61
|
+
port.onmessage?.({ data: evt })
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
return port
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** All-intra source at 30fps, the shape of an SP3 proxy. */
|
|
68
|
+
function proxySource(n = 100, src = '/proxies/clip_proxy.mp4'): DemuxedSource {
|
|
69
|
+
const samples: SampleRef[] = Array.from({ length: n }, (_, i) => ({
|
|
70
|
+
tsUs: i * 33333,
|
|
71
|
+
dtsUs: i * 33333,
|
|
72
|
+
durUs: 33333,
|
|
73
|
+
isKey: true,
|
|
74
|
+
data: new Uint8Array([i & 0xff]),
|
|
75
|
+
}))
|
|
76
|
+
const video: ChunkSource = {
|
|
77
|
+
kind: 'video',
|
|
78
|
+
codec: 'av01.0.05M.08',
|
|
79
|
+
description: undefined,
|
|
80
|
+
fps: 30,
|
|
81
|
+
durationS: n / 30,
|
|
82
|
+
coded: { width: 540, height: 960 },
|
|
83
|
+
samples,
|
|
84
|
+
presIndex: buildPresIndex(samples),
|
|
85
|
+
firstPresentationTsUs: 0,
|
|
86
|
+
}
|
|
87
|
+
return { src, video, audio: null }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A reordered GOP: decode order I0 P3 B1 B2, so a seek run emits frames after the target too. */
|
|
91
|
+
function bFrameSource(): DemuxedSource {
|
|
92
|
+
const mk = (cts: number, dts: number, isKey = false): SampleRef => ({
|
|
93
|
+
tsUs: cts,
|
|
94
|
+
dtsUs: dts,
|
|
95
|
+
durUs: 1000,
|
|
96
|
+
isKey,
|
|
97
|
+
data: new Uint8Array([dts / 1000]),
|
|
98
|
+
})
|
|
99
|
+
const samples = [mk(0, 0, true), mk(3000, 1000), mk(1000, 2000), mk(2000, 3000)]
|
|
100
|
+
const video: ChunkSource = {
|
|
101
|
+
kind: 'video',
|
|
102
|
+
codec: 'hvc1.2.4.L153.B0',
|
|
103
|
+
fps: 1000,
|
|
104
|
+
durationS: 0.004,
|
|
105
|
+
coded: { width: 100, height: 100 },
|
|
106
|
+
samples,
|
|
107
|
+
presIndex: buildPresIndex(samples),
|
|
108
|
+
firstPresentationTsUs: 0,
|
|
109
|
+
}
|
|
110
|
+
return { src: '/proxies/b.mp4', video, audio: null }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function decodeRanges(port: FakePort): Extract<DecodeCmd, { t: 'decodeRange' }>[] {
|
|
114
|
+
return port.sent.flatMap((cmd) => (cmd.t === 'decodeRange' ? [cmd] : []))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('frame server', () => {
|
|
118
|
+
it('decodes only from the single injected proxy source', () => {
|
|
119
|
+
// SP1 requirement 4 (proxy-only playback) is structural here: the server
|
|
120
|
+
// is handed one demuxed source and has no other input, no `fileUrl`, and
|
|
121
|
+
// no fallback branch. What is left to assert is that the bytes it posts
|
|
122
|
+
// really are that source's samples — sliced in decode order, by reference.
|
|
123
|
+
const port = fakePort()
|
|
124
|
+
const source = proxySource(40)
|
|
125
|
+
const server = createFrameServer({ source, spawnWorker: () => port })
|
|
126
|
+
|
|
127
|
+
expect(port.sent[0]).toEqual({
|
|
128
|
+
t: 'init',
|
|
129
|
+
codec: 'av01.0.05M.08',
|
|
130
|
+
description: undefined,
|
|
131
|
+
hardwareAcceleration: 'no-preference',
|
|
132
|
+
preRollEpsilonUs: 1,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
server.startStream(0)
|
|
136
|
+
const [range] = decodeRanges(port)
|
|
137
|
+
expect(range.samples).toHaveLength(DEFAULT_DECODE_AHEAD_FRAMES)
|
|
138
|
+
range.samples.forEach((s, i) => {
|
|
139
|
+
// Identity, not equality: the sample bytes are the demuxed table's own
|
|
140
|
+
// buffers, handed over by structured clone (never transferred — a
|
|
141
|
+
// transfer would detach them from `source` and corrupt every later
|
|
142
|
+
// request overlapping the same GOP).
|
|
143
|
+
expect(s.data).toBe(source.video.samples[i].data)
|
|
144
|
+
expect(s.tsUs).toBe(source.video.samples[i].tsUs)
|
|
145
|
+
})
|
|
146
|
+
expect(server.src).toBe('/proxies/clip_proxy.mp4')
|
|
147
|
+
server.dispose()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('holds buffered + in-flight frames to the decode-ahead budget', () => {
|
|
151
|
+
const port = fakePort()
|
|
152
|
+
const server = createFrameServer({ source: proxySource(200), spawnWorker: () => port })
|
|
153
|
+
const reqId = server.startStream(0)
|
|
154
|
+
|
|
155
|
+
const first = decodeRanges(port)[0]
|
|
156
|
+
expect(first.samples).toHaveLength(30)
|
|
157
|
+
expect(server.stats()).toMatchObject({ buffered: 0, inFlightFrames: 30, inFlightBatches: 1 })
|
|
158
|
+
|
|
159
|
+
for (let i = 0; i < 30; i++) port.deliver({ t: 'frame', frame: asVideoFrame(frame(i * 33333)), reqId })
|
|
160
|
+
port.deliver({ t: 'done', reqId, decoded: 30, dropped: 0 })
|
|
161
|
+
// Budget full: the `done` pumped, and the pump found no capacity.
|
|
162
|
+
expect(server.stats()).toMatchObject({ buffered: 30, inFlightFrames: 0, inFlightBatches: 0 })
|
|
163
|
+
expect(decodeRanges(port)).toHaveLength(1)
|
|
164
|
+
|
|
165
|
+
// Painting 10 frames frees exactly 10 frames of budget, which clears the
|
|
166
|
+
// batch floor and releases the next request.
|
|
167
|
+
const taken = server.nextFrameFor(9 * 33333)
|
|
168
|
+
expect(taken.frame?.timestamp).toBe(9 * 33333)
|
|
169
|
+
expect(taken.dropped).toBe(9)
|
|
170
|
+
expect(decodeRanges(port)).toHaveLength(2)
|
|
171
|
+
expect(decodeRanges(port)[1].samples).toHaveLength(10)
|
|
172
|
+
expect(minBatchFrames(DEFAULT_DECODE_AHEAD_FRAMES)).toBeLessThanOrEqual(10)
|
|
173
|
+
expect(server.stats().buffered + server.stats().inFlightFrames).toBe(30)
|
|
174
|
+
|
|
175
|
+
server.dispose()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('reconciles the budget when the worker drops frames as pre-roll', () => {
|
|
179
|
+
const port = fakePort()
|
|
180
|
+
const server = createFrameServer({ source: proxySource(200), spawnWorker: () => port })
|
|
181
|
+
const reqId = server.startStream(0)
|
|
182
|
+
|
|
183
|
+
// The batch asked for 30 but 4 were pre-roll and never arrive. Without
|
|
184
|
+
// reconciliation `inFlightFrames` would sit at 4 forever and that much
|
|
185
|
+
// decode-ahead capacity would be gone for the rest of the session.
|
|
186
|
+
for (let i = 4; i < 30; i++) port.deliver({ t: 'frame', frame: asVideoFrame(frame(i * 33333)), reqId })
|
|
187
|
+
port.deliver({ t: 'done', reqId, decoded: 26, dropped: 4 })
|
|
188
|
+
|
|
189
|
+
expect(server.stats()).toMatchObject({ buffered: 26, inFlightFrames: 0, inFlightBatches: 0 })
|
|
190
|
+
// 4 frames of budget are free, but that is below the batch floor, so
|
|
191
|
+
// nothing is posted yet — the flush amortization rule still holds.
|
|
192
|
+
expect(decodeRanges(port)).toHaveLength(1)
|
|
193
|
+
|
|
194
|
+
// Consume 7 frames (frames 4..10 by presentation time). Budget now: 11
|
|
195
|
+
// free = 7 painted + the 4 the reconciliation recovered. WITHOUT the
|
|
196
|
+
// reconciliation this next batch would be 7 — that difference is the
|
|
197
|
+
// capacity that would have been lost permanently.
|
|
198
|
+
expect(server.nextFrameFor(10 * 33333).frame?.timestamp).toBe(10 * 33333)
|
|
199
|
+
expect(decodeRanges(port)[1].samples).toHaveLength(11)
|
|
200
|
+
server.dispose()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('closes frames from a superseded request instead of buffering them', () => {
|
|
204
|
+
const port = fakePort()
|
|
205
|
+
const server = createFrameServer({ source: proxySource(60), spawnWorker: () => port })
|
|
206
|
+
const streamReq = server.startStream(0)
|
|
207
|
+
const buffered = frame(0)
|
|
208
|
+
port.deliver({ t: 'frame', frame: asVideoFrame(buffered), reqId: streamReq })
|
|
209
|
+
expect(server.stats().buffered).toBe(1)
|
|
210
|
+
|
|
211
|
+
// A seek supersedes the stream: the frame already buffered for it is
|
|
212
|
+
// closed, and a straggler still in flight for it is closed on arrival.
|
|
213
|
+
server.seek(33333)
|
|
214
|
+
expect(buffered.closed).toBe(true)
|
|
215
|
+
expect(server.stats().buffered).toBe(0)
|
|
216
|
+
|
|
217
|
+
const straggler = frame(33333)
|
|
218
|
+
port.deliver({ t: 'frame', frame: asVideoFrame(straggler), reqId: streamReq })
|
|
219
|
+
expect(straggler.closed).toBe(true)
|
|
220
|
+
expect(server.stats().buffered).toBe(0)
|
|
221
|
+
server.dispose()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('seek resolves the requested frame and closes every other frame in the run', async () => {
|
|
225
|
+
const port = fakePort()
|
|
226
|
+
const server = createFrameServer({ source: bFrameSource(), spawnWorker: () => port })
|
|
227
|
+
const { reqId, frame: pending } = server.seek(2000)
|
|
228
|
+
|
|
229
|
+
const range = decodeRanges(port)[0]
|
|
230
|
+
// The whole GOP up to the target, in decode order.
|
|
231
|
+
expect(range.samples.map((s) => s.tsUs)).toEqual([0, 3000, 1000, 2000])
|
|
232
|
+
expect(range.targetTsUs).toBe(2000)
|
|
233
|
+
|
|
234
|
+
// The worker drops 0 and 1000 as pre-roll; 2000 (the target) and 3000 (a
|
|
235
|
+
// frame that precedes the target in DECODE order) both arrive.
|
|
236
|
+
const target = frame(2000)
|
|
237
|
+
const later = frame(3000)
|
|
238
|
+
port.deliver({ t: 'frame', frame: asVideoFrame(target), reqId })
|
|
239
|
+
port.deliver({ t: 'frame', frame: asVideoFrame(later), reqId })
|
|
240
|
+
port.deliver({ t: 'done', reqId, decoded: 2, dropped: 2 })
|
|
241
|
+
|
|
242
|
+
// The requested frame, not "whatever arrived last" — on a reordered source
|
|
243
|
+
// those are different frames.
|
|
244
|
+
expect(await pending).toBe(asVideoFrame(target))
|
|
245
|
+
expect(target.closed).toBe(false) // handed to the caller, who closes it after painting
|
|
246
|
+
expect(later.closed).toBe(true)
|
|
247
|
+
server.dispose()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('settles every seek, including superseded ones (total done contract)', async () => {
|
|
251
|
+
const port = fakePort()
|
|
252
|
+
const server = createFrameServer({ source: proxySource(60), spawnWorker: () => port })
|
|
253
|
+
|
|
254
|
+
const first = server.seek(33333)
|
|
255
|
+
const early = frame(33333)
|
|
256
|
+
port.deliver({ t: 'frame', frame: asVideoFrame(early), reqId: first.reqId })
|
|
257
|
+
|
|
258
|
+
// A second seek arrives before the first's `done`. The worker owes a
|
|
259
|
+
// `done` for the superseded request regardless (SP1 §7.4) — and the frame
|
|
260
|
+
// already held for it must not leak.
|
|
261
|
+
const second = server.seek(66666)
|
|
262
|
+
expect(early.closed).toBe(true)
|
|
263
|
+
|
|
264
|
+
port.deliver({ t: 'done', reqId: first.reqId, decoded: 1, dropped: 1 })
|
|
265
|
+
expect(await first.frame).toBeNull()
|
|
266
|
+
|
|
267
|
+
const wanted = frame(66666)
|
|
268
|
+
port.deliver({ t: 'frame', frame: asVideoFrame(wanted), reqId: second.reqId })
|
|
269
|
+
port.deliver({ t: 'done', reqId: second.reqId, decoded: 1, dropped: 1 })
|
|
270
|
+
expect(await second.frame).toBe(asVideoFrame(wanted))
|
|
271
|
+
server.dispose()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('holds the newest due frame and closes the ones it overtook', () => {
|
|
275
|
+
const port = fakePort()
|
|
276
|
+
const server = createFrameServer({ source: proxySource(60), spawnWorker: () => port })
|
|
277
|
+
const reqId = server.startStream(0)
|
|
278
|
+
const frames = [frame(0), frame(33333), frame(66666), frame(99999)]
|
|
279
|
+
for (const f of frames) port.deliver({ t: 'frame', frame: asVideoFrame(f), reqId })
|
|
280
|
+
|
|
281
|
+
// Clock sits between frames 2 and 3: frame 2 is due, 0 and 1 became due
|
|
282
|
+
// and were overtaken, 3 is not due yet.
|
|
283
|
+
const { frame: due, dropped } = server.nextFrameFor(70000)
|
|
284
|
+
expect(due?.timestamp).toBe(66666)
|
|
285
|
+
expect(dropped).toBe(2)
|
|
286
|
+
expect(frames.map((f) => f.closed)).toEqual([true, true, false, false])
|
|
287
|
+
expect(server.stats().buffered).toBe(1)
|
|
288
|
+
|
|
289
|
+
// Nothing due yet → nothing painted, nothing closed.
|
|
290
|
+
const none = server.nextFrameFor(80000)
|
|
291
|
+
expect(none.frame).toBeNull()
|
|
292
|
+
expect(frames[3].closed).toBe(false)
|
|
293
|
+
server.dispose()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('closes everything still held on dispose and terminates the worker', async () => {
|
|
297
|
+
const port = fakePort()
|
|
298
|
+
const server = createFrameServer({ source: proxySource(60), spawnWorker: () => port })
|
|
299
|
+
const reqId = server.startStream(0)
|
|
300
|
+
const held = [frame(0), frame(33333)]
|
|
301
|
+
for (const f of held) port.deliver({ t: 'frame', frame: asVideoFrame(f), reqId })
|
|
302
|
+
|
|
303
|
+
server.dispose()
|
|
304
|
+
expect(held.every((f) => f.closed)).toBe(true)
|
|
305
|
+
expect(port.terminated).toBe(true)
|
|
306
|
+
expect(port.onmessage).toBeNull()
|
|
307
|
+
|
|
308
|
+
// A frame that raced the teardown is closed rather than leaked, and a
|
|
309
|
+
// disposed server hands nothing further to the worker.
|
|
310
|
+
const sentBefore = port.sent.length
|
|
311
|
+
server.startStream(0)
|
|
312
|
+
expect(port.sent).toHaveLength(sentBefore)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('reports decoder errors to the host rather than throwing', () => {
|
|
316
|
+
const onError = vi.fn()
|
|
317
|
+
const port = fakePort()
|
|
318
|
+
const server = createFrameServer({ source: proxySource(60), spawnWorker: () => port, onError })
|
|
319
|
+
server.startStream(0)
|
|
320
|
+
port.deliver({ t: 'error', message: 'decoder: Decoding error' })
|
|
321
|
+
|
|
322
|
+
expect(onError).toHaveBeenCalledWith('decoder: Decoding error')
|
|
323
|
+
expect(server.stats().lastError).toBe('decoder: Decoding error')
|
|
324
|
+
server.dispose()
|
|
325
|
+
})
|
|
326
|
+
})
|