@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,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 of the preview-performance work: the two ways a clip used to get
|
|
3
|
+
* stuck on "Preparing preview…" forever, and the retain window that stops a
|
|
4
|
+
* scrub across a cut rebuilding what it just left.
|
|
5
|
+
*
|
|
6
|
+
* Both stuck states came from the same design gap. `SourceState` reports
|
|
7
|
+
* `loading` and `failed` into the SAME placeholder as a proxy that has not been
|
|
8
|
+
* encoded yet — deliberately, because from the user's side they are the same
|
|
9
|
+
* sentence — but that meant nothing distinguished "still coming" from "never
|
|
10
|
+
* coming":
|
|
11
|
+
*
|
|
12
|
+
* 1. A fetch that never settles left the session `loading` for the lifetime of
|
|
13
|
+
* the tab. `BUILD_TIMEOUT_MS` turns it into a `failed` session with a real
|
|
14
|
+
* reason.
|
|
15
|
+
* 2. A `failed` session was kept in the map precisely so `retain` would not
|
|
16
|
+
* rebuild it, which made every failure permanent — including the transient
|
|
17
|
+
* ones. `MAX_SESSION_RETRIES` with a doubling backoff rebuilds it a bounded
|
|
18
|
+
* number of times.
|
|
19
|
+
*
|
|
20
|
+
* `demux` is mocked because the point here is session bookkeeping, not
|
|
21
|
+
* container parsing, and because these tests need to hold a build open
|
|
22
|
+
* indefinitely — which no real fetch will do on request.
|
|
23
|
+
*/
|
|
24
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
25
|
+
import type { EditorProject as Project, VisualItem } from '../../schema'
|
|
26
|
+
|
|
27
|
+
const demuxMock = vi.fn()
|
|
28
|
+
vi.mock('../demux', async (importOriginal) => {
|
|
29
|
+
const actual = await importOriginal<typeof import('../demux')>()
|
|
30
|
+
return { ...actual, demux: (...args: unknown[]) => demuxMock(...args) }
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// The frame server and master clock both want WebCodecs/WebAudio, neither of
|
|
34
|
+
// which jsdom has. Stubbed to the smallest thing `build()` can finish against.
|
|
35
|
+
vi.mock('../frame-server', () => ({
|
|
36
|
+
createFrameServer: vi.fn(() => ({
|
|
37
|
+
src: 'stub',
|
|
38
|
+
video: { samples: [], presIndex: [], firstPresentationTsUs: 0 },
|
|
39
|
+
decodeAheadFrames: 30,
|
|
40
|
+
seek: () => ({ reqId: 0, frame: Promise.resolve(null) }),
|
|
41
|
+
startStream: () => 0,
|
|
42
|
+
stopStream: () => {},
|
|
43
|
+
nextFrameFor: () => ({ frame: null, dropped: 0 }),
|
|
44
|
+
stats: () => ({
|
|
45
|
+
buffered: 0,
|
|
46
|
+
inFlightFrames: 0,
|
|
47
|
+
inFlightBatches: 0,
|
|
48
|
+
received: 0,
|
|
49
|
+
dropped: 0,
|
|
50
|
+
atEndOfSource: false,
|
|
51
|
+
drained: false,
|
|
52
|
+
lastError: null,
|
|
53
|
+
}),
|
|
54
|
+
dispose: () => {},
|
|
55
|
+
})),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
function clip(id: string, start: number, end: number, extra: Partial<VisualItem> = {}): VisualItem {
|
|
59
|
+
return {
|
|
60
|
+
id,
|
|
61
|
+
type: 'video',
|
|
62
|
+
src: `/media/${id}.mov`,
|
|
63
|
+
proxySrc: `/proxies/${id}_proxy.mp4`,
|
|
64
|
+
start,
|
|
65
|
+
end,
|
|
66
|
+
inPoint: 0,
|
|
67
|
+
outPoint: end - start,
|
|
68
|
+
...extra,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function project(items: VisualItem[]): Project {
|
|
73
|
+
return {
|
|
74
|
+
id: 'p1',
|
|
75
|
+
status: 'draft',
|
|
76
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
77
|
+
tracks: [{ id: 'trk-0', items }],
|
|
78
|
+
} as unknown as Project
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A demuxed source shaped just enough for `build()` to reach `ready`. */
|
|
82
|
+
function fakeDemuxed(src: string) {
|
|
83
|
+
return {
|
|
84
|
+
src,
|
|
85
|
+
video: { kind: 'video', samples: [{ tsUs: 0 }], presIndex: [0], firstPresentationTsUs: 0 },
|
|
86
|
+
audio: null,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Drain the microtask queue so an in-flight `build()` settles. */
|
|
91
|
+
const settle = () => new Promise<void>((r) => setTimeout(r, 0))
|
|
92
|
+
|
|
93
|
+
let clock = 0
|
|
94
|
+
const nowMs = () => clock
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
clock = 0
|
|
98
|
+
demuxMock.mockReset()
|
|
99
|
+
vi.useRealTimers()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
vi.useRealTimers()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
async function engineFor(items: VisualItem[]) {
|
|
107
|
+
const { createEngine } = await import('../index')
|
|
108
|
+
const errors: string[] = []
|
|
109
|
+
const engine = createEngine(project(items), {
|
|
110
|
+
fileUrl: (p) => p,
|
|
111
|
+
nowMs,
|
|
112
|
+
onError: (m) => errors.push(m),
|
|
113
|
+
requestFrame: () => 1,
|
|
114
|
+
cancelFrame: () => {},
|
|
115
|
+
})
|
|
116
|
+
return { engine, errors }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe('build timeout', () => {
|
|
120
|
+
it('aborts a build that never settles and reports why', async () => {
|
|
121
|
+
vi.useFakeTimers()
|
|
122
|
+
const { BUILD_TIMEOUT_MS } = await import('../index')
|
|
123
|
+
// A fetch that hangs: the abort signal is the ONLY thing that can end it,
|
|
124
|
+
// which is why the timeout aborts rather than racing a timer against the
|
|
125
|
+
// promise (a race would resolve the build while the request stayed open).
|
|
126
|
+
demuxMock.mockImplementation(
|
|
127
|
+
(_src: string, _url: unknown, options: { signal?: AbortSignal }) =>
|
|
128
|
+
new Promise((_resolve, reject) => {
|
|
129
|
+
options.signal?.addEventListener('abort', () =>
|
|
130
|
+
reject(new DOMException('The operation was aborted.', 'AbortError')),
|
|
131
|
+
)
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
const { engine } = await engineFor([clip('a', 0, 4)])
|
|
135
|
+
|
|
136
|
+
engine.seek(1)
|
|
137
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
138
|
+
expect(engine.status()).toMatchObject({ picture: 'preparing' })
|
|
139
|
+
|
|
140
|
+
await vi.advanceTimersByTimeAsync(BUILD_TIMEOUT_MS + 10)
|
|
141
|
+
|
|
142
|
+
expect(engine.status().reason).toMatch(/timed out after 20000 ms/)
|
|
143
|
+
engine.dispose()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('does not fire for a build that finishes normally', async () => {
|
|
147
|
+
vi.useFakeTimers()
|
|
148
|
+
const { BUILD_TIMEOUT_MS } = await import('../index')
|
|
149
|
+
demuxMock.mockImplementation(async (src: string) => fakeDemuxed(src))
|
|
150
|
+
const { engine, errors } = await engineFor([clip('a', 0, 4)])
|
|
151
|
+
|
|
152
|
+
engine.seek(1)
|
|
153
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
154
|
+
// Past the deadline: a timer left armed would abort a source that is
|
|
155
|
+
// already in use.
|
|
156
|
+
await vi.advanceTimersByTimeAsync(BUILD_TIMEOUT_MS * 2)
|
|
157
|
+
|
|
158
|
+
expect(errors).toEqual([])
|
|
159
|
+
expect(engine.status().reason).toBeUndefined()
|
|
160
|
+
engine.dispose()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('failed-session retry', () => {
|
|
165
|
+
it('rebuilds a failed clip after the backoff, and succeeds when the cause clears', async () => {
|
|
166
|
+
demuxMock
|
|
167
|
+
.mockRejectedValueOnce(new Error('read: /proxies/a_proxy.mp4 → 503 Service Unavailable'))
|
|
168
|
+
.mockImplementation(async (src: string) => fakeDemuxed(src))
|
|
169
|
+
const { engine } = await engineFor([clip('a', 0, 4)])
|
|
170
|
+
|
|
171
|
+
engine.seek(1)
|
|
172
|
+
await settle()
|
|
173
|
+
expect(engine.status()).toMatchObject({ picture: 'preparing' })
|
|
174
|
+
expect(engine.status().reason).toMatch(/503 Service Unavailable/)
|
|
175
|
+
expect(demuxMock).toHaveBeenCalledTimes(1)
|
|
176
|
+
|
|
177
|
+
// Inside the backoff: a tick must NOT retry, or the three attempts would be
|
|
178
|
+
// spent inside a few frames of the rAF loop.
|
|
179
|
+
engine.seek(1.1)
|
|
180
|
+
await settle()
|
|
181
|
+
expect(demuxMock).toHaveBeenCalledTimes(1)
|
|
182
|
+
|
|
183
|
+
clock = 1000
|
|
184
|
+
engine.seek(1.2)
|
|
185
|
+
await settle()
|
|
186
|
+
|
|
187
|
+
expect(demuxMock).toHaveBeenCalledTimes(2)
|
|
188
|
+
expect(engine.status()).toMatchObject({ picture: 'video' })
|
|
189
|
+
engine.dispose()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('gives up after MAX_SESSION_RETRIES rather than rebuilding forever', async () => {
|
|
193
|
+
const { MAX_SESSION_RETRIES } = await import('../index')
|
|
194
|
+
demuxMock.mockRejectedValue(new Error('corrupt proxy'))
|
|
195
|
+
const { engine } = await engineFor([clip('a', 0, 4)])
|
|
196
|
+
|
|
197
|
+
engine.seek(1)
|
|
198
|
+
await settle()
|
|
199
|
+
|
|
200
|
+
// Walk the clock far past every backoff and keep ticking.
|
|
201
|
+
for (let i = 0; i < MAX_SESSION_RETRIES + 5; i++) {
|
|
202
|
+
clock += 60_000
|
|
203
|
+
engine.seek(1 + i * 0.01)
|
|
204
|
+
await settle()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// One initial build plus exactly MAX_SESSION_RETRIES rebuilds.
|
|
208
|
+
expect(demuxMock).toHaveBeenCalledTimes(MAX_SESSION_RETRIES + 1)
|
|
209
|
+
expect(engine.status()).toMatchObject({ picture: 'preparing' })
|
|
210
|
+
expect(engine.status().reason).toMatch(/corrupt proxy/)
|
|
211
|
+
engine.dispose()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('a src change resets the retry budget — a new proxy is a new problem', async () => {
|
|
215
|
+
// A proxy arriving over SSE replaces `proxySrc`; `retain`'s drop loop tears
|
|
216
|
+
// the session down for the src change, so the new one starts fresh rather
|
|
217
|
+
// than inheriting an exhausted budget from the file it replaced.
|
|
218
|
+
const { MAX_SESSION_RETRIES } = await import('../index')
|
|
219
|
+
demuxMock.mockRejectedValue(new Error('corrupt proxy'))
|
|
220
|
+
const { engine } = await engineFor([clip('a', 0, 4)])
|
|
221
|
+
|
|
222
|
+
engine.seek(1)
|
|
223
|
+
await settle()
|
|
224
|
+
for (let i = 0; i < MAX_SESSION_RETRIES + 2; i++) {
|
|
225
|
+
clock += 60_000
|
|
226
|
+
engine.seek(1 + i * 0.01)
|
|
227
|
+
await settle()
|
|
228
|
+
}
|
|
229
|
+
const exhausted = demuxMock.mock.calls.length
|
|
230
|
+
|
|
231
|
+
demuxMock.mockImplementation(async (src: string) => fakeDemuxed(src))
|
|
232
|
+
engine.updateProject(project([clip('a', 0, 4, { proxySrc: '/proxies/a_proxy_v2.mp4' })]))
|
|
233
|
+
engine.seek(1.5)
|
|
234
|
+
await settle()
|
|
235
|
+
|
|
236
|
+
expect(demuxMock.mock.calls.length).toBe(exhausted + 1)
|
|
237
|
+
expect(engine.status()).toMatchObject({ picture: 'video' })
|
|
238
|
+
engine.dispose()
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('retain window', () => {
|
|
243
|
+
it('holds the previous clip so a scrub back over a cut does not rebuild it', async () => {
|
|
244
|
+
demuxMock.mockImplementation(async (src: string) => fakeDemuxed(src))
|
|
245
|
+
const { engine } = await engineFor([clip('a', 0, 2), clip('b', 2, 4), clip('c', 4, 6)])
|
|
246
|
+
|
|
247
|
+
engine.seek(1)
|
|
248
|
+
await settle()
|
|
249
|
+
engine.seek(3)
|
|
250
|
+
await settle()
|
|
251
|
+
const afterForward = demuxMock.mock.calls.length
|
|
252
|
+
|
|
253
|
+
// Back over the cut. 'a' was retained as the clip behind the playhead, so
|
|
254
|
+
// there is nothing to rebuild — no new demux, and the picture is live
|
|
255
|
+
// immediately rather than passing through `preparing`.
|
|
256
|
+
engine.seek(1)
|
|
257
|
+
await settle()
|
|
258
|
+
|
|
259
|
+
expect(demuxMock.mock.calls.length).toBe(afterForward)
|
|
260
|
+
expect(engine.status()).toMatchObject({ picture: 'video', clipId: 'a' })
|
|
261
|
+
engine.dispose()
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T5 — the facade: the rAF loop, the canvas painter, and the surface T6
|
|
3
|
+
* builds on.
|
|
4
|
+
*
|
|
5
|
+
* The transport itself is covered by `scheduler.test.ts` over fakes; what is
|
|
6
|
+
* left here is the wiring only `index.ts` owns. A CANVAS project is the vehicle
|
|
7
|
+
* for the loop tests on purpose: it has no track-0 video, so nothing ever
|
|
8
|
+
* reaches `loadBytes`/`demux`/WebCodecs and the whole thing runs in jsdom with
|
|
9
|
+
* a real `EngineSourceHost` rather than a stand-in for one.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import type { EditorProject as Project } from '../../schema'
|
|
13
|
+
import { createCanvasPainter, createEngine, fpsFromPaintTimes, pruneOlderThan } from '../index'
|
|
14
|
+
import type { DrawPlan } from '../scheduler'
|
|
15
|
+
|
|
16
|
+
/** `Array.prototype.at` is ES2022; this package targets ES2020. */
|
|
17
|
+
const last = <T>(items: readonly T[]): T | undefined => items[items.length - 1]
|
|
18
|
+
|
|
19
|
+
function canvasProject(overlayEnd = 4): Project {
|
|
20
|
+
return {
|
|
21
|
+
id: 'p1',
|
|
22
|
+
status: 'draft',
|
|
23
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
24
|
+
tracks: [
|
|
25
|
+
{ id: 'trk-0', items: [{ id: 'img', type: 'image', src: '/bg.png', start: 0, end: overlayEnd }] },
|
|
26
|
+
{ id: 'trk-1', items: [{ id: 'o', type: 'overlay', src: '/o.jsx', start: 0, end: overlayEnd }] },
|
|
27
|
+
],
|
|
28
|
+
} as Project
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A hand-pumped rAF, so no test depends on a real frame callback firing. */
|
|
32
|
+
function fakeRaf() {
|
|
33
|
+
const queue = new Map<number, () => void>()
|
|
34
|
+
let nextHandle = 1
|
|
35
|
+
const cancelFrame = vi.fn((handle: number) => {
|
|
36
|
+
queue.delete(handle)
|
|
37
|
+
})
|
|
38
|
+
return {
|
|
39
|
+
requestFrame: (cb: () => void) => {
|
|
40
|
+
const handle = nextHandle++
|
|
41
|
+
queue.set(handle, cb)
|
|
42
|
+
return handle
|
|
43
|
+
},
|
|
44
|
+
cancelFrame,
|
|
45
|
+
/** Run every callback queued so far (each may enqueue the next). */
|
|
46
|
+
flush(times = 1) {
|
|
47
|
+
for (let i = 0; i < times; i++) {
|
|
48
|
+
const pending = [...queue.values()]
|
|
49
|
+
queue.clear()
|
|
50
|
+
for (const cb of pending) cb()
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
get pending() {
|
|
54
|
+
return queue.size
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('createEngine — transport over the rAF loop', () => {
|
|
60
|
+
it('runs the loop only while playing and stops at the project end', () => {
|
|
61
|
+
const raf = fakeRaf()
|
|
62
|
+
let now = 0
|
|
63
|
+
const times: number[] = []
|
|
64
|
+
const engine = createEngine(canvasProject(4), {
|
|
65
|
+
fileUrl: (p) => p,
|
|
66
|
+
onTime: (t) => times.push(t),
|
|
67
|
+
requestFrame: raf.requestFrame,
|
|
68
|
+
cancelFrame: raf.cancelFrame,
|
|
69
|
+
nowMs: () => now,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Nothing runs until asked to.
|
|
73
|
+
expect(raf.pending).toBe(0)
|
|
74
|
+
engine.play()
|
|
75
|
+
expect(raf.pending).toBe(1)
|
|
76
|
+
|
|
77
|
+
now = 1000
|
|
78
|
+
raf.flush()
|
|
79
|
+
expect(last(times)).toBeCloseTo(1, 6)
|
|
80
|
+
expect(engine.status().transport).toBe('playing')
|
|
81
|
+
expect(raf.pending).toBe(1)
|
|
82
|
+
|
|
83
|
+
// Past the ceiling: clamped to it exactly, transport ends, loop stops.
|
|
84
|
+
now = 5000
|
|
85
|
+
raf.flush()
|
|
86
|
+
expect(last(times)).toBe(4)
|
|
87
|
+
expect(engine.status().transport).toBe('ended')
|
|
88
|
+
expect(raf.pending).toBe(0)
|
|
89
|
+
|
|
90
|
+
engine.dispose()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('pause stops the loop; play restarts it', () => {
|
|
94
|
+
const raf = fakeRaf()
|
|
95
|
+
let now = 0
|
|
96
|
+
const engine = createEngine(canvasProject(10), {
|
|
97
|
+
fileUrl: (p) => p,
|
|
98
|
+
requestFrame: raf.requestFrame,
|
|
99
|
+
cancelFrame: raf.cancelFrame,
|
|
100
|
+
nowMs: () => now,
|
|
101
|
+
})
|
|
102
|
+
engine.play()
|
|
103
|
+
now = 500
|
|
104
|
+
raf.flush()
|
|
105
|
+
engine.pause()
|
|
106
|
+
expect(raf.cancelFrame).toHaveBeenCalled()
|
|
107
|
+
expect(engine.status().transport).toBe('paused')
|
|
108
|
+
|
|
109
|
+
// The playhead froze where it was, and does not drift while paused.
|
|
110
|
+
const frozen = engine.clock.now()
|
|
111
|
+
now = 4000
|
|
112
|
+
expect(engine.clock.now()).toBe(frozen)
|
|
113
|
+
|
|
114
|
+
engine.play()
|
|
115
|
+
expect(raf.pending).toBe(1)
|
|
116
|
+
engine.dispose()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('a canvas project runs on the wall clock', () => {
|
|
120
|
+
const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
|
|
121
|
+
engine.seek(1)
|
|
122
|
+
expect(engine.clock.kind).toBe('fallback')
|
|
123
|
+
expect(engine.clock.now()).toBe(1)
|
|
124
|
+
expect(engine.status()).toMatchObject({ picture: 'black', clipId: null })
|
|
125
|
+
engine.dispose()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('seek past the end stops the loop', () => {
|
|
129
|
+
const raf = fakeRaf()
|
|
130
|
+
const engine = createEngine(canvasProject(4), {
|
|
131
|
+
fileUrl: (p) => p,
|
|
132
|
+
requestFrame: raf.requestFrame,
|
|
133
|
+
cancelFrame: raf.cancelFrame,
|
|
134
|
+
nowMs: () => 0,
|
|
135
|
+
})
|
|
136
|
+
engine.play()
|
|
137
|
+
engine.seek(9)
|
|
138
|
+
expect(engine.status().transport).toBe('ended')
|
|
139
|
+
expect(raf.cancelFrame).toHaveBeenCalled()
|
|
140
|
+
engine.dispose()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('dispose is idempotent and stops everything', () => {
|
|
144
|
+
const raf = fakeRaf()
|
|
145
|
+
const engine = createEngine(canvasProject(4), {
|
|
146
|
+
fileUrl: (p) => p,
|
|
147
|
+
requestFrame: raf.requestFrame,
|
|
148
|
+
cancelFrame: raf.cancelFrame,
|
|
149
|
+
nowMs: () => 0,
|
|
150
|
+
})
|
|
151
|
+
engine.play()
|
|
152
|
+
engine.dispose()
|
|
153
|
+
engine.dispose()
|
|
154
|
+
expect(raf.pending).toBe(0)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('createEngine — canvas attachment', () => {
|
|
159
|
+
it('sizes the backing store to the project design canvas', () => {
|
|
160
|
+
const canvas = document.createElement('canvas')
|
|
161
|
+
vi.spyOn(canvas, 'getContext').mockReturnValue(null)
|
|
162
|
+
const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
|
|
163
|
+
engine.attach(canvas)
|
|
164
|
+
expect([canvas.width, canvas.height]).toEqual([1080, 1920])
|
|
165
|
+
engine.dispose()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('re-sizes when the project resolution changes', () => {
|
|
169
|
+
const canvas = document.createElement('canvas')
|
|
170
|
+
vi.spyOn(canvas, 'getContext').mockReturnValue(null)
|
|
171
|
+
const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
|
|
172
|
+
engine.attach(canvas)
|
|
173
|
+
const landscape = { ...canvasProject(4), settings: { resolution: [1920, 1080] as [number, number] } }
|
|
174
|
+
engine.updateProject(landscape as Project)
|
|
175
|
+
expect([canvas.width, canvas.height]).toEqual([1920, 1080])
|
|
176
|
+
engine.dispose()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('unbinds cleanly on attach(null)', () => {
|
|
180
|
+
const canvas = document.createElement('canvas')
|
|
181
|
+
vi.spyOn(canvas, 'getContext').mockReturnValue(null)
|
|
182
|
+
const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
|
|
183
|
+
engine.attach(canvas)
|
|
184
|
+
expect(() => engine.attach(null)).not.toThrow()
|
|
185
|
+
engine.dispose()
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('createEngine — stats()', () => {
|
|
190
|
+
it('reports zeroed defaults with no active clip and no paints', () => {
|
|
191
|
+
const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
|
|
192
|
+
// A canvas project's picture is always 'black' (no track-0 video), so
|
|
193
|
+
// there is never a clip clock to derive from — `stats().clock` matches
|
|
194
|
+
// `engine.clock.kind`.
|
|
195
|
+
expect(engine.stats()).toEqual({ fps: 0, dropped: 0, buffered: 0, clock: 'fallback' })
|
|
196
|
+
expect(engine.clock.kind).toBe('fallback')
|
|
197
|
+
engine.dispose()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('attach(null) leaves stats() safe to call — no dangling painter reference', () => {
|
|
201
|
+
const canvas = document.createElement('canvas')
|
|
202
|
+
vi.spyOn(canvas, 'getContext').mockReturnValue(null)
|
|
203
|
+
const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
|
|
204
|
+
engine.attach(canvas)
|
|
205
|
+
engine.attach(null)
|
|
206
|
+
expect(() => engine.stats()).not.toThrow()
|
|
207
|
+
engine.dispose()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// `stats().fps`'s rolling-window math is exercised directly below
|
|
212
|
+
// (`pruneOlderThan`/`fpsFromPaintTimes`) rather than through a canvas and an
|
|
213
|
+
// attached painter: a canvas PROJECT (this file's fixture) never reaches
|
|
214
|
+
// `picture === 'video'`, so nothing here ever calls the wrapped painter's
|
|
215
|
+
// `paint` — that would need a real track-0 clip behind a live decode session,
|
|
216
|
+
// which is `scheduler.test.ts`/`frame-server.test.ts` territory, not this
|
|
217
|
+
// file's.
|
|
218
|
+
|
|
219
|
+
describe('pruneOlderThan', () => {
|
|
220
|
+
it('drops everything at or before the cutoff, keeps the rest, in place', () => {
|
|
221
|
+
const times = [0, 500, 1000, 1500, 1900]
|
|
222
|
+
pruneOlderThan(times, 2000, 1000) // cutoff = 1000: drop < 1000
|
|
223
|
+
expect(times).toEqual([1000, 1500, 1900])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('is a no-op when nothing is old enough to drop', () => {
|
|
227
|
+
const times = [1900, 1950]
|
|
228
|
+
pruneOlderThan(times, 2000, 1000)
|
|
229
|
+
expect(times).toEqual([1900, 1950])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('empties the array when everything is older than the window', () => {
|
|
233
|
+
const times = [0, 100, 200]
|
|
234
|
+
pruneOlderThan(times, 5000, 1000)
|
|
235
|
+
expect(times).toEqual([])
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('fpsFromPaintTimes', () => {
|
|
240
|
+
it('is 0 for an empty list', () => {
|
|
241
|
+
expect(fpsFromPaintTimes([], 1000, 2000)).toBe(0)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('reads 30fps from paints spaced exactly 1000/30 ms apart', () => {
|
|
245
|
+
// 30 paints over one second (0, 33.3, 66.6, … 966.7), read at t=1000 with
|
|
246
|
+
// a 2000ms window — the window covers the whole run, so this is exactly
|
|
247
|
+
// "how many paints in the last second", i.e. fps.
|
|
248
|
+
const times: number[] = []
|
|
249
|
+
for (let i = 0; i < 30; i++) times.push((i * 1000) / 30)
|
|
250
|
+
expect(fpsFromPaintTimes(times, 1000, 2000)).toBeCloseTo(30, 0)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('clamps the span to the window even when the buffer spans longer', () => {
|
|
254
|
+
// First paint 5s ago, but the window is only 2s — span must clamp to
|
|
255
|
+
// 2000ms, not the full 5000ms, or a long-lived low-rate buffer would
|
|
256
|
+
// under-report fps.
|
|
257
|
+
const times = [0, 4000, 4500, 5000]
|
|
258
|
+
expect(fpsFromPaintTimes(times, 5000, 2000)).toBeCloseTo((4 / 2000) * 1000, 6)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('createCanvasPainter', () => {
|
|
263
|
+
interface Recorded {
|
|
264
|
+
fills: Array<[number, number, number, number]>
|
|
265
|
+
draws: unknown[][]
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function stubCanvas(width = 1080, height = 1920): { canvas: HTMLCanvasElement; rec: Recorded } {
|
|
269
|
+
const canvas = document.createElement('canvas')
|
|
270
|
+
canvas.width = width
|
|
271
|
+
canvas.height = height
|
|
272
|
+
const rec: Recorded = { fills: [], draws: [] }
|
|
273
|
+
const ctx = {
|
|
274
|
+
fillStyle: '',
|
|
275
|
+
fillRect: (...args: [number, number, number, number]) => rec.fills.push(args),
|
|
276
|
+
drawImage: (...args: unknown[]) => rec.draws.push(args),
|
|
277
|
+
}
|
|
278
|
+
vi.spyOn(canvas, 'getContext').mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
|
|
279
|
+
return { canvas, rec }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const plan: DrawPlan = { sx: 10, sy: 20, sw: 30, sh: 40, dx: 1, dy: 2, dw: 3, dh: 4 }
|
|
283
|
+
const frame = { close() {} } as unknown as VideoFrame
|
|
284
|
+
|
|
285
|
+
it('fills black before every paint so letterbox bars never keep a stale edge', () => {
|
|
286
|
+
const { canvas, rec } = stubCanvas()
|
|
287
|
+
const painter = createCanvasPainter(canvas)
|
|
288
|
+
painter.paint(frame, plan)
|
|
289
|
+
expect(rec.fills).toEqual([[0, 0, 1080, 1920]])
|
|
290
|
+
expect(rec.draws).toEqual([[frame, 10, 20, 30, 40, 1, 2, 3, 4]])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('clears to black without drawing', () => {
|
|
294
|
+
const { canvas, rec } = stubCanvas()
|
|
295
|
+
createCanvasPainter(canvas).clear()
|
|
296
|
+
expect(rec.fills).toEqual([[0, 0, 1080, 1920]])
|
|
297
|
+
expect(rec.draws).toEqual([])
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('skips a degenerate rect rather than throwing at drawImage', () => {
|
|
301
|
+
const { canvas, rec } = stubCanvas()
|
|
302
|
+
createCanvasPainter(canvas).paint(frame, { ...plan, sw: 0 })
|
|
303
|
+
expect(rec.draws).toEqual([])
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('re-reads the backing-store size on every paint, so a host resize is picked up', () => {
|
|
307
|
+
const { canvas, rec } = stubCanvas()
|
|
308
|
+
const painter = createCanvasPainter(canvas)
|
|
309
|
+
expect(painter.size()).toEqual({ width: 1080, height: 1920 })
|
|
310
|
+
canvas.width = 540
|
|
311
|
+
canvas.height = 960
|
|
312
|
+
expect(painter.size()).toEqual({ width: 540, height: 960 })
|
|
313
|
+
painter.clear()
|
|
314
|
+
expect(last(rec.fills)).toEqual([0, 0, 540, 960])
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('is inert when the canvas gives no 2D context', () => {
|
|
318
|
+
const canvas = document.createElement('canvas')
|
|
319
|
+
vi.spyOn(canvas, 'getContext').mockReturnValue(null)
|
|
320
|
+
const painter = createCanvasPainter(canvas)
|
|
321
|
+
expect(() => {
|
|
322
|
+
painter.clear()
|
|
323
|
+
painter.paint(frame, plan)
|
|
324
|
+
}).not.toThrow()
|
|
325
|
+
})
|
|
326
|
+
})
|