@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,950 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T5 — the engine facade.
|
|
3
|
+
*
|
|
4
|
+
* `scheduler.ts` decides; this module makes those decisions possible. It owns
|
|
5
|
+
* the three things a pure state machine cannot:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Resources.** `demux → createFrameServer → createMasterClock` for one
|
|
8
|
+
* clip, behind the scheduler's `SourceHost` interface. The scheduler
|
|
9
|
+
* declares which clips it needs; this reconciles. `demux` is ranged — it
|
|
10
|
+
* returns once the header is read and pulls media bytes as the playhead
|
|
11
|
+
* reaches them — so a build finishing is no longer the same event as the
|
|
12
|
+
* whole proxy having arrived.
|
|
13
|
+
* 2. **The rAF loop.** Injected (`requestFrame`/`cancelFrame`) so tests drive
|
|
14
|
+
* it by hand, and running ONLY while the transport is playing — a paused
|
|
15
|
+
* editor burns no frames, exactly like the legacy hook's boundary rAF.
|
|
16
|
+
* 3. **The canvas.** `attach(canvas)` builds the `Painter` the scheduler
|
|
17
|
+
* paints through.
|
|
18
|
+
*
|
|
19
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
20
|
+
* WHAT IS SHARED, AND WHAT IS TERMINATED (the boundary-swap rule)
|
|
21
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
22
|
+
* The spike's rule is per-SOURCE: "switching source is terminate + respawn -
|
|
23
|
+
* never `decoder.reset()` on a live worker" (`player.ts`'s `load`). That is
|
|
24
|
+
* exactly what happens here, and it is deliberately NOT per-clip:
|
|
25
|
+
*
|
|
26
|
+
* - **`FrameServer` — one per `src`, refcounted by clip.** A silence-trimmed
|
|
27
|
+
* timeline is fifty clips off ONE proxy; giving each its own decoder would
|
|
28
|
+
* re-fetch and re-demux the same file fifty times. The legacy hook has the
|
|
29
|
+
* same shape for the same reason (its comment notes same-source cuts were
|
|
30
|
+
* the fast path because the moov was already cached). Two clips never
|
|
31
|
+
* stream from one server at once — the scheduler stops the outgoing
|
|
32
|
+
* session before starting the incoming one — and when the last clip
|
|
33
|
+
* referencing a src is dropped, the worker really is terminated.
|
|
34
|
+
* - **`MasterClock` — one per CLIP**, because it is anchored to that clip's
|
|
35
|
+
* `start`/`inPoint`/`volume`/`muted` and T4 is explicit that a live clock is
|
|
36
|
+
* never reconfigured.
|
|
37
|
+
* - **`DemuxedSource` — cached by `src` in a small LRU** beyond the live refs.
|
|
38
|
+
* Demuxing used to be the expensive half (a WHOLE-FILE fetch plus a
|
|
39
|
+
* sample-table walk) and scrubbing back and forth across a cut is the
|
|
40
|
+
* single most common thing anyone does in an editor. Ranged loading made
|
|
41
|
+
* the fetch half cheap, but the cache earns its place either way: it holds
|
|
42
|
+
* the parsed sample index and whatever bytes have already been pulled, so a
|
|
43
|
+
* re-entered source starts warm. What lingers is now bounded by
|
|
44
|
+
* `demux.ts`'s resident-byte budgets rather than by the file's size.
|
|
45
|
+
*/
|
|
46
|
+
import { designCanvas, sourceWindow } from '@bycrux/timeline-core'
|
|
47
|
+
import type { EditorProject as Project, VisualItem } from '../schema'
|
|
48
|
+
import { createMasterClock, createWallClock, type ClipTimebase } from './audio-clock'
|
|
49
|
+
import { demux, type DemuxedSource } from './demux'
|
|
50
|
+
import { createFrameServer, type FrameServer, type HardwarePref } from './frame-server'
|
|
51
|
+
import type { FileUrlResolver } from './media-loader'
|
|
52
|
+
import {
|
|
53
|
+
createScheduler,
|
|
54
|
+
type ClipSource,
|
|
55
|
+
type EngineStatus,
|
|
56
|
+
type Painter,
|
|
57
|
+
type Scheduler,
|
|
58
|
+
type SourceHost,
|
|
59
|
+
type SourceRequest,
|
|
60
|
+
type SourceState,
|
|
61
|
+
} from './scheduler'
|
|
62
|
+
|
|
63
|
+
export * from './scheduler'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parsed sources kept alive past their last reference.
|
|
67
|
+
*
|
|
68
|
+
* Was 3 — "the clip you are on, the one before it, the one after it" — because
|
|
69
|
+
* a cached source used to pin a WHOLE PROXY in memory and three of those was
|
|
70
|
+
* already hundreds of megabytes. Ranged loading changed what a cached source
|
|
71
|
+
* costs: it is now a sample index plus a bounded byte cache (`demux.ts`'s
|
|
72
|
+
* `MAX_RESIDENT_*_BYTES` and `media-loader.ts`'s `MAX_CACHED_BYTES`, ~26 MB
|
|
73
|
+
* between them at the ceiling), so the same memory buys more of them. Five
|
|
74
|
+
* covers the retain window plus the two clips either side of it, which is the
|
|
75
|
+
* span a fast back-and-forth scrub across two cuts touches.
|
|
76
|
+
*/
|
|
77
|
+
export const DEMUX_CACHE_MAX = 5
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* How long a source build may run before it is aborted.
|
|
81
|
+
*
|
|
82
|
+
* The failure this exists for: a fetch that never settles left the session in
|
|
83
|
+
* `loading` forever, and `state()` reports `loading` as the same
|
|
84
|
+
* "Preparing preview…" a not-yet-encoded proxy produces — so a hung request was
|
|
85
|
+
* indistinguishable from a proxy that simply had not arrived, and stayed on
|
|
86
|
+
* screen until the tab was reloaded. Aborting turns it into a `failed` session
|
|
87
|
+
* with a reason, which the retry below can then act on.
|
|
88
|
+
*
|
|
89
|
+
* Generous on purpose. A ranged build is a header fetch and at most one or two
|
|
90
|
+
* follow-ups; twenty seconds is not a latency budget, it is the point past
|
|
91
|
+
* which the request is not coming back.
|
|
92
|
+
*/
|
|
93
|
+
export const BUILD_TIMEOUT_MS = 20_000
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How many times a failed session may be rebuilt before the clip is left alone.
|
|
97
|
+
*
|
|
98
|
+
* Bounded because the failure might be permanent (a corrupt proxy, a codec the
|
|
99
|
+
* browser will not configure) and `retain` runs every tick — an unbounded retry
|
|
100
|
+
* would be an infinite rebuild loop burning a decode worker per attempt.
|
|
101
|
+
*/
|
|
102
|
+
export const MAX_SESSION_RETRIES = 3
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Backoff before the first retry, doubling per attempt (≈0.75s, 1.5s, 3s).
|
|
106
|
+
*
|
|
107
|
+
* Without it the three attempts would all be spent inside a few frames of the
|
|
108
|
+
* rAF loop, which retries nothing useful: the transient cases worth retrying —
|
|
109
|
+
* a proxy still being written, a request that lost its connection — need wall
|
|
110
|
+
* time to resolve, not another immediate attempt.
|
|
111
|
+
*/
|
|
112
|
+
export const SESSION_RETRY_BASE_MS = 750
|
|
113
|
+
|
|
114
|
+
// ── Public surface ──────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export interface EngineDeps {
|
|
117
|
+
/** Host path → fetchable URL. `EditorAdapter.fileUrl`, threaded through unchanged. */
|
|
118
|
+
fileUrl: FileUrlResolver
|
|
119
|
+
/**
|
|
120
|
+
* The playhead, every tick.
|
|
121
|
+
*
|
|
122
|
+
* T6's bridge contract: mirror each emitted value into a `lastEmittedRef`
|
|
123
|
+
* BEFORE forwarding it to `clock.set`, and treat an incoming `currentTime`
|
|
124
|
+
* that differs from it by more than the legacy dead-zone as an external
|
|
125
|
+
* scrub. Pass a stable function that reads a ref — the engine captures this
|
|
126
|
+
* once at construction and is not rebuilt when a React callback identity
|
|
127
|
+
* changes.
|
|
128
|
+
*/
|
|
129
|
+
onTime?: (projectS: number) => void
|
|
130
|
+
/** Fires only when the status actually changes, never per tick. Same stability note as `onTime`. */
|
|
131
|
+
onStatusChange?: (status: EngineStatus) => void
|
|
132
|
+
/** Decoder, loader and paint failures. Advisory: none of them stop the transport. */
|
|
133
|
+
onError?: (message: string) => void
|
|
134
|
+
hardwareAcceleration?: HardwarePref
|
|
135
|
+
decodeAheadFrames?: number
|
|
136
|
+
/** Boundary−N seconds for prewarm. Defaults to the scheduler's `PREWARM_LEAD_S`. */
|
|
137
|
+
prewarmLeadS?: number
|
|
138
|
+
startProjectS?: number
|
|
139
|
+
/** rAF seam. Defaults to `requestAnimationFrame`; tests pump by hand. */
|
|
140
|
+
requestFrame?: (cb: () => void) => number
|
|
141
|
+
cancelFrame?: (handle: number) => void
|
|
142
|
+
/** Wall-clock seam, forwarded to every fallback clock. Defaults to `performance.now`. */
|
|
143
|
+
nowMs?: () => number
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A read-only view of whatever clock is currently driving the transport. */
|
|
147
|
+
export interface EngineClock {
|
|
148
|
+
/** Playhead in project seconds. */
|
|
149
|
+
now(): number
|
|
150
|
+
readonly playing: boolean
|
|
151
|
+
/** `'audio'` when the active clip's Opus is driving, `'fallback'` on the wall clock. */
|
|
152
|
+
readonly kind: 'audio' | 'fallback'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* T7's debug HUD reads this. A deliberately small, read-only aggregate — NOT
|
|
157
|
+
* the frame server's full `FrameServerStats` (buffered/inFlightFrames/
|
|
158
|
+
* inFlightBatches/received/dropped/atEndOfSource/drained/lastError) and NOT
|
|
159
|
+
* the spike's per-(source, hardwareAcceleration) bucket matrix
|
|
160
|
+
* (`spikes/playback-engine/src/hud.ts`). The HUD needs four numbers — is
|
|
161
|
+
* playback keeping up right now — not the frame server's internals or a
|
|
162
|
+
* benchmarking history, so this is the smallest surface that answers that
|
|
163
|
+
* from React without reaching past `Engine` into `EngineSourceHost` /
|
|
164
|
+
* `FrameServer`.
|
|
165
|
+
*/
|
|
166
|
+
export interface EngineStats {
|
|
167
|
+
/** Painted frames per second over a trailing {@link STATS_FPS_WINDOW_MS} window. 0 when nothing has painted recently. */
|
|
168
|
+
fps: number
|
|
169
|
+
/** Frames dropped by the active clip's frame server (superseded or overtaken), lifetime. 0 with no active session. */
|
|
170
|
+
dropped: number
|
|
171
|
+
/** Frames buffered ahead in the active clip's decode-ahead pipeline. 0 with no active session. */
|
|
172
|
+
buffered: number
|
|
173
|
+
/** Which clock is driving the transport right now — same value as `EngineStatus.clock`. */
|
|
174
|
+
clock: 'audio' | 'fallback'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A pinned handle to a shared demuxed source. The demux LRU treats a src as
|
|
179
|
+
* unevictable while at least one `AcquiredDemux` for it is unreleased — so a
|
|
180
|
+
* non-scheduler consumer (the drag-scrub audio source) can share the warm
|
|
181
|
+
* cache without racing the scheduler's own eviction. Release exactly once.
|
|
182
|
+
*/
|
|
183
|
+
export interface AcquiredDemux {
|
|
184
|
+
source: DemuxedSource
|
|
185
|
+
release(): void
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface Engine {
|
|
189
|
+
/**
|
|
190
|
+
* Bind the canvas the engine paints into, or `null` to unbind.
|
|
191
|
+
*
|
|
192
|
+
* Sizes the backing store to the project's design canvas as a default. The
|
|
193
|
+
* host may resize it at any time (a `ResizeObserver` on the frame box, say) —
|
|
194
|
+
* the painter re-reads `canvas.width`/`height` on every paint, so the crop
|
|
195
|
+
* and contain-fit math follows automatically.
|
|
196
|
+
*/
|
|
197
|
+
attach(canvas: HTMLCanvasElement | null): void
|
|
198
|
+
play(): void
|
|
199
|
+
pause(): void
|
|
200
|
+
/** External scrub. Playback survives it; scrubbing past the end stops it. */
|
|
201
|
+
seek(projectS: number): void
|
|
202
|
+
/**
|
|
203
|
+
* Set the live transport rate R (default 1): project time advances R× wall
|
|
204
|
+
* time, applied to the active clock immediately and to every session built
|
|
205
|
+
* afterward. This is the J/K/L shuttle's knob. A per-clip `speed` change is
|
|
206
|
+
* NOT this — it arrives through {@link updateProject} as a session rebuild,
|
|
207
|
+
* like any other timeline edit.
|
|
208
|
+
*/
|
|
209
|
+
setRate(rate: number): void
|
|
210
|
+
/**
|
|
211
|
+
* Adopt an edited project.
|
|
212
|
+
*
|
|
213
|
+
* Beyond the obvious (new clips, moved boundaries), this is the path a
|
|
214
|
+
* `preparing` clip resolves through: when SSE delivers a `proxySrc` the
|
|
215
|
+
* affected clip's session is rebuilt and that clip alone leaves the Preparing
|
|
216
|
+
* state. Engine ELIGIBILITY is NOT re-evaluated here — plan decision 2 pins it
|
|
217
|
+
* to project-load, and the "initially-ineligible stays legacy" policy is T6's.
|
|
218
|
+
*/
|
|
219
|
+
updateProject(project: Project): void
|
|
220
|
+
status(): EngineStatus
|
|
221
|
+
readonly clock: EngineClock
|
|
222
|
+
/** T7's debug HUD aggregate. See {@link EngineStats}. Cheap — safe to call on a polling timer. */
|
|
223
|
+
stats(): EngineStats
|
|
224
|
+
/**
|
|
225
|
+
* Acquire a shared demuxed source for `src`, pinning it in the LRU until
|
|
226
|
+
* the returned handle is released. Used by the drag-scrub audio source so a
|
|
227
|
+
* scrub across a cut hits the same warm cache the scheduler built up
|
|
228
|
+
* instead of re-demuxing the proxy privately. Release exactly once.
|
|
229
|
+
*/
|
|
230
|
+
acquireDemux(src: string): Promise<AcquiredDemux>
|
|
231
|
+
dispose(): void
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Painter ─────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Canvas 2D painter (plan decision 3: `drawImage(VideoFrame)`, not WebGL, for
|
|
238
|
+
* v1 — one video layer is all the engine composites, and `drawImage` of a
|
|
239
|
+
* `VideoFrame` is GPU-backed in Chromium).
|
|
240
|
+
*
|
|
241
|
+
* Every paint fills black first. Without it a letterboxed frame leaves the
|
|
242
|
+
* previous, differently-shaped frame's edges around its bars — the same reason
|
|
243
|
+
* the legacy surface sits on a `bg-black` container.
|
|
244
|
+
*/
|
|
245
|
+
export function createCanvasPainter(canvas: HTMLCanvasElement): Painter {
|
|
246
|
+
const ctx = canvas.getContext('2d', { alpha: false })
|
|
247
|
+
const fill = () => {
|
|
248
|
+
if (!ctx) return
|
|
249
|
+
ctx.fillStyle = '#000'
|
|
250
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
size: () => ({ width: canvas.width, height: canvas.height }),
|
|
254
|
+
paint(frame, plan) {
|
|
255
|
+
if (!ctx) return
|
|
256
|
+
fill()
|
|
257
|
+
if (plan.sw <= 0 || plan.sh <= 0 || plan.dw <= 0 || plan.dh <= 0) return
|
|
258
|
+
ctx.drawImage(
|
|
259
|
+
frame,
|
|
260
|
+
plan.sx,
|
|
261
|
+
plan.sy,
|
|
262
|
+
plan.sw,
|
|
263
|
+
plan.sh,
|
|
264
|
+
plan.dx,
|
|
265
|
+
plan.dy,
|
|
266
|
+
plan.dw,
|
|
267
|
+
plan.dh,
|
|
268
|
+
)
|
|
269
|
+
},
|
|
270
|
+
clear: fill,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── The source host ─────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
interface Session {
|
|
277
|
+
clipId: string
|
|
278
|
+
src: string
|
|
279
|
+
status: 'loading' | 'ready' | 'failed'
|
|
280
|
+
reason?: string
|
|
281
|
+
source?: ClipSource
|
|
282
|
+
/** Set when the session is dropped mid-build; every await point checks it. */
|
|
283
|
+
cancelled: boolean
|
|
284
|
+
/**
|
|
285
|
+
* `request.item.start` as of the last (re)build. `retain` compares this
|
|
286
|
+
* against the wanted request to detect a mid-session trim on the SAME src —
|
|
287
|
+
* `src` alone is not enough, since the master clock's timebase is captured
|
|
288
|
+
* at build time and a trim otherwise leaves audio on the pre-trim mapping.
|
|
289
|
+
*/
|
|
290
|
+
start: number
|
|
291
|
+
/** `sourceWindow(item, 'preview').inPoint` as of the last (re)build. Same trim-detection role as `start`. */
|
|
292
|
+
inPoint: number
|
|
293
|
+
/**
|
|
294
|
+
* `!!item.muted` as of the last (re)build. A mute toggle respawns the
|
|
295
|
+
* session — the clock's KIND depends on it. EFFECTIVE mute: the scheduler
|
|
296
|
+
* folds the clip's track in before the request is built, so muting the
|
|
297
|
+
* TRACK respawns here exactly as muting the clip does.
|
|
298
|
+
*/
|
|
299
|
+
muted: boolean
|
|
300
|
+
/**
|
|
301
|
+
* `item.volume ?? 1` as of the last (re)build — the effective volume, track
|
|
302
|
+
* gain already multiplied in (see `SourceRequest.item`). Unlike `muted`, a
|
|
303
|
+
* change here is pushed live via `setVolume`, not a respawn trigger.
|
|
304
|
+
*/
|
|
305
|
+
volume: number
|
|
306
|
+
/**
|
|
307
|
+
* `item.speed ?? 1` as of the last (re)build. Speed re-maps source↔timeline
|
|
308
|
+
* and the master clock's timebase captures it at build time, so — like a trim
|
|
309
|
+
* or a mute toggle, and UNLIKE `volume` — a change respawns the session rather
|
|
310
|
+
* than pushing live. The transport rate R is the live axis; per-clip speed is
|
|
311
|
+
* not.
|
|
312
|
+
*/
|
|
313
|
+
speed: number
|
|
314
|
+
/** Rebuild attempts already spent on this clip — see {@link MAX_SESSION_RETRIES}. */
|
|
315
|
+
retries: number
|
|
316
|
+
/**
|
|
317
|
+
* Wall-clock milliseconds before which a `failed` session must not be
|
|
318
|
+
* retried. 0 on a session that has not failed.
|
|
319
|
+
*/
|
|
320
|
+
retryAfterMs: number
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
interface ServerEntry {
|
|
324
|
+
server: FrameServer
|
|
325
|
+
refs: Set<string>
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
interface DemuxEntry {
|
|
329
|
+
promise: Promise<DemuxedSource>
|
|
330
|
+
controller: AbortController
|
|
331
|
+
/** Clears the build timeout. Idempotent; called on settle and on abandon. */
|
|
332
|
+
clearTimeout: () => void
|
|
333
|
+
waiters: number
|
|
334
|
+
/** How many `abandonDemux` calls this entry has absorbed — see `abandonDemux`. */
|
|
335
|
+
abandoned: number
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
class EngineSourceHost implements SourceHost {
|
|
339
|
+
private readonly sessions = new Map<string, Session>()
|
|
340
|
+
private readonly servers = new Map<string, ServerEntry>()
|
|
341
|
+
/** Insertion-ordered, oldest first — the LRU. */
|
|
342
|
+
private readonly demuxCache = new Map<string, DemuxedSource>()
|
|
343
|
+
private readonly demuxing = new Map<string, DemuxEntry>()
|
|
344
|
+
/**
|
|
345
|
+
* Refcount of outstanding `AcquiredDemux` pins per src. A pinned src is
|
|
346
|
+
* skipped by `evictDemux` so a non-scheduler consumer (the drag-scrub audio
|
|
347
|
+
* source) can hold a warm demux across a cut without the scheduler evicting
|
|
348
|
+
* it out from under them. Counted (not a Set) so a reentrant acquire on the
|
|
349
|
+
* same src does not silently trip the "already pinned" check on release.
|
|
350
|
+
*/
|
|
351
|
+
private readonly demuxPins = new Map<string, number>()
|
|
352
|
+
private scheduler: Scheduler | null = null
|
|
353
|
+
private disposed = false
|
|
354
|
+
|
|
355
|
+
constructor(private readonly deps: EngineDeps) {}
|
|
356
|
+
|
|
357
|
+
bind(scheduler: Scheduler): void {
|
|
358
|
+
this.scheduler = scheduler
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
retain(requests: readonly SourceRequest[]): void {
|
|
362
|
+
if (this.disposed) return
|
|
363
|
+
const wanted = new Map(requests.map((r) => [r.clipId, r]))
|
|
364
|
+
// Drop first: a clip whose src changed under it (a proxy arriving via SSE),
|
|
365
|
+
// or whose trim or mute state changed, must lose its old session before the
|
|
366
|
+
// new one is built, so the rebuild is a genuine respawn rather than two
|
|
367
|
+
// sessions on one clip.
|
|
368
|
+
for (const [clipId, session] of [...this.sessions]) {
|
|
369
|
+
const want = wanted.get(clipId)
|
|
370
|
+
const changed =
|
|
371
|
+
!want ||
|
|
372
|
+
want.src !== session.src ||
|
|
373
|
+
want.item.start !== session.start ||
|
|
374
|
+
sourceWindow(want.item, 'preview').inPoint !== session.inPoint ||
|
|
375
|
+
!!want.item.muted !== session.muted ||
|
|
376
|
+
(want.item.speed ?? 1) !== session.speed
|
|
377
|
+
if (changed) this.dropSession(clipId)
|
|
378
|
+
}
|
|
379
|
+
// A clip's volume can change without a rebuild: push it straight to the
|
|
380
|
+
// live clock (`MasterClock.setVolume`) rather than tearing the session
|
|
381
|
+
// down. Only sessions that survived the drop loop above and already have a
|
|
382
|
+
// built `source` are eligible — a still-loading session picks up the
|
|
383
|
+
// request's volume when `build()` finishes.
|
|
384
|
+
for (const request of requests) {
|
|
385
|
+
const session = this.sessions.get(request.clipId)
|
|
386
|
+
if (!session?.source) continue
|
|
387
|
+
const vol = request.item.volume ?? 1
|
|
388
|
+
if (vol !== session.volume) {
|
|
389
|
+
session.volume = vol
|
|
390
|
+
session.source.clock.setVolume(vol)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (const request of requests) {
|
|
394
|
+
const existing = this.sessions.get(request.clipId)
|
|
395
|
+
if (existing) {
|
|
396
|
+
// A `failed` session is KEPT in the map so `state()` can answer
|
|
397
|
+
// `'failed'` and this loop does not immediately rebuild it. That was
|
|
398
|
+
// unconditional, which made every failure permanent for as long as the
|
|
399
|
+
// clip stayed retained — including the transient ones (a proxy still
|
|
400
|
+
// being written, a request that lost its connection, a build that hit
|
|
401
|
+
// the timeout above). Retry is bounded and backed off so the "leave it
|
|
402
|
+
// alone" property still holds for a genuinely broken clip.
|
|
403
|
+
if (existing.status !== 'failed') continue
|
|
404
|
+
if (existing.retries >= MAX_SESSION_RETRIES) continue
|
|
405
|
+
if (this.now() < existing.retryAfterMs) continue
|
|
406
|
+
const retries = existing.retries + 1
|
|
407
|
+
this.dropSession(request.clipId)
|
|
408
|
+
this.startSession(request, retries)
|
|
409
|
+
continue
|
|
410
|
+
}
|
|
411
|
+
this.startSession(request)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Wall clock, through the injected seam so tests drive the backoff by hand. */
|
|
416
|
+
private now(): number {
|
|
417
|
+
return this.deps.nowMs?.() ?? performance.now()
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
state(clipId: string): SourceState {
|
|
421
|
+
const session = this.sessions.get(clipId)
|
|
422
|
+
if (!session) return { status: 'idle' }
|
|
423
|
+
if (session.status === 'ready' && session.source) {
|
|
424
|
+
return { status: 'ready', source: session.source }
|
|
425
|
+
}
|
|
426
|
+
if (session.status === 'failed') {
|
|
427
|
+
return { status: 'failed', reason: session.reason ?? 'preview unavailable' }
|
|
428
|
+
}
|
|
429
|
+
return { status: 'loading' }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
fallbackClock(startProjectS: number) {
|
|
433
|
+
return createWallClock(startProjectS, 'no clip audio', this.deps.nowMs)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
disposeAll(): void {
|
|
437
|
+
this.disposed = true
|
|
438
|
+
for (const clipId of [...this.sessions.keys()]) this.dropSession(clipId)
|
|
439
|
+
for (const [, entry] of this.servers) entry.server.dispose()
|
|
440
|
+
this.servers.clear()
|
|
441
|
+
// Ranged sources hold cached file bytes and can have reads in flight;
|
|
442
|
+
// dropping the map reference alone would leave both to the collector.
|
|
443
|
+
// Only done here, never on LRU eviction: `evictDemux` skips any src with a
|
|
444
|
+
// live server, so an evicted source has nothing reading it and nothing to
|
|
445
|
+
// abort, whereas closing one out from under an in-flight `build()` would
|
|
446
|
+
// strand the clip it was building.
|
|
447
|
+
for (const [, source] of this.demuxCache) source.dispose?.()
|
|
448
|
+
this.demuxCache.clear()
|
|
449
|
+
this.demuxPins.clear()
|
|
450
|
+
for (const [, entry] of this.demuxing) {
|
|
451
|
+
entry.clearTimeout()
|
|
452
|
+
entry.controller.abort()
|
|
453
|
+
}
|
|
454
|
+
this.demuxing.clear()
|
|
455
|
+
this.scheduler = null
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── session lifecycle ─────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
private startSession(request: SourceRequest, retries = 0): void {
|
|
461
|
+
const session: Session = {
|
|
462
|
+
clipId: request.clipId,
|
|
463
|
+
src: request.src,
|
|
464
|
+
status: 'loading',
|
|
465
|
+
cancelled: false,
|
|
466
|
+
start: request.item.start,
|
|
467
|
+
inPoint: sourceWindow(request.item, 'preview').inPoint,
|
|
468
|
+
muted: !!request.item.muted,
|
|
469
|
+
volume: request.item.volume ?? 1,
|
|
470
|
+
speed: request.item.speed ?? 1,
|
|
471
|
+
retries,
|
|
472
|
+
retryAfterMs: 0,
|
|
473
|
+
}
|
|
474
|
+
this.sessions.set(request.clipId, session)
|
|
475
|
+
void this.build(session, request)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private async build(session: Session, request: SourceRequest): Promise<void> {
|
|
479
|
+
// Set once `acquireServer` actually adds a ref, so the `catch` below knows
|
|
480
|
+
// whether it has to release one — a throw between `acquireServer` and the
|
|
481
|
+
// `session.source` assignment would otherwise leak the ref forever.
|
|
482
|
+
let acquired = false
|
|
483
|
+
try {
|
|
484
|
+
const demuxed = await this.acquireDemux(session.src)
|
|
485
|
+
if (session.cancelled) return
|
|
486
|
+
const server = this.acquireServer(session.src, demuxed, session.clipId)
|
|
487
|
+
acquired = true
|
|
488
|
+
|
|
489
|
+
const window = sourceWindow(request.item, 'preview')
|
|
490
|
+
// Re-stamp the trim/mute/volume fields `retain`'s drop test reads, in
|
|
491
|
+
// case this build was triggered by something other than `startSession`
|
|
492
|
+
// (e.g. a respawn) and the session object predates this request.
|
|
493
|
+
session.start = request.item.start
|
|
494
|
+
session.inPoint = window.inPoint
|
|
495
|
+
session.muted = !!request.item.muted
|
|
496
|
+
session.volume = request.item.volume ?? 1
|
|
497
|
+
session.speed = request.item.speed ?? 1
|
|
498
|
+
const timebase: ClipTimebase = {
|
|
499
|
+
start: request.item.start,
|
|
500
|
+
// `sourceWindow(...).inPoint`, NOT the raw `item.inPoint` — for a
|
|
501
|
+
// `normalizedSrc` window cache the two differ by the cache origin and
|
|
502
|
+
// every seek would land in the wrong place. Same value the legacy hook
|
|
503
|
+
// feeds its <video> elements through `effectiveInPoint`.
|
|
504
|
+
inPoint: window.inPoint,
|
|
505
|
+
// The AUDIO track's origin, per `ClipTimebase`. The video track has its
|
|
506
|
+
// own and the scheduler reads it straight off the frame server.
|
|
507
|
+
firstPresentationTsUs: demuxed.audio?.firstPresentationTsUs ?? 0,
|
|
508
|
+
// Per-clip speed is captured at build time (the clock is never
|
|
509
|
+
// reconfigured live); a speed edit respawns the session — see `Session
|
|
510
|
+
// .speed`. Absent ⇒ 1, the strict no-op mapping.
|
|
511
|
+
speed: request.item.speed ?? 1,
|
|
512
|
+
}
|
|
513
|
+
// Never rejects: a muted clip, a track with no decodable audio, a browser
|
|
514
|
+
// without WebCodecs audio — all resolve to a wall clock with a reason.
|
|
515
|
+
const clock = await createMasterClock({
|
|
516
|
+
audio: demuxed.audio,
|
|
517
|
+
timebase,
|
|
518
|
+
startProjectS: request.anchorProjectS,
|
|
519
|
+
volume: request.item.volume,
|
|
520
|
+
muted: request.item.muted,
|
|
521
|
+
onError: this.deps.onError,
|
|
522
|
+
nowMs: this.deps.nowMs,
|
|
523
|
+
})
|
|
524
|
+
if (session.cancelled) {
|
|
525
|
+
clock.dispose()
|
|
526
|
+
this.releaseServer(session.src, session.clipId)
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
session.source = {
|
|
530
|
+
clipId: session.clipId,
|
|
531
|
+
src: session.src,
|
|
532
|
+
frameServer: server,
|
|
533
|
+
clock,
|
|
534
|
+
timebase,
|
|
535
|
+
}
|
|
536
|
+
session.status = 'ready'
|
|
537
|
+
} catch (err) {
|
|
538
|
+
if (acquired) this.releaseServer(session.src, session.clipId)
|
|
539
|
+
if (session.cancelled) return
|
|
540
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
541
|
+
this.markFailed(session, message)
|
|
542
|
+
this.deps.onError?.(`engine: ${session.src} — ${message}`)
|
|
543
|
+
}
|
|
544
|
+
this.scheduler?.sourceChanged(session.clipId)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Move a session to `failed` and arm its retry window.
|
|
549
|
+
*
|
|
550
|
+
* The backoff doubles per attempt already spent, so the three attempts land
|
|
551
|
+
* roughly 0.75s, 1.5s and 3s after their respective failures rather than all
|
|
552
|
+
* inside the same handful of rAF ticks.
|
|
553
|
+
*/
|
|
554
|
+
private markFailed(session: Session, reason: string): void {
|
|
555
|
+
session.status = 'failed'
|
|
556
|
+
session.reason = reason
|
|
557
|
+
session.retryAfterMs = this.now() + SESSION_RETRY_BASE_MS * 2 ** session.retries
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private dropSession(clipId: string): void {
|
|
561
|
+
const session = this.sessions.get(clipId)
|
|
562
|
+
if (!session) return
|
|
563
|
+
this.sessions.delete(clipId)
|
|
564
|
+
session.cancelled = true
|
|
565
|
+
session.source?.clock.dispose()
|
|
566
|
+
if (session.source) this.releaseServer(session.src, clipId)
|
|
567
|
+
else this.abandonDemux(session.src)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* A decoder error on a shared src fails every clip using it.
|
|
572
|
+
*
|
|
573
|
+
* `frame-server.ts` surfaces decode errors and deliberately does not
|
|
574
|
+
* self-heal, so the session really is dead: its clock is disposed and its
|
|
575
|
+
* worker released, and the scheduler puts those clips' ranges into the SAME
|
|
576
|
+
* `preparing` state a not-yet-encoded proxy produces. The failed session is
|
|
577
|
+
* KEPT in the map (rather than deleted) so `state()` answers `'failed'` and
|
|
578
|
+
* `retain` does not immediately loop into a rebuild; it clears when the clip
|
|
579
|
+
* leaves the retained set or its `src` changes.
|
|
580
|
+
*/
|
|
581
|
+
private onDecodeError(src: string, message: string): void {
|
|
582
|
+
this.deps.onError?.(`engine: decode failed for ${src} — ${message}`)
|
|
583
|
+
for (const session of this.sessions.values()) {
|
|
584
|
+
if (session.src !== src || session.status === 'failed') continue
|
|
585
|
+
this.markFailed(session, message)
|
|
586
|
+
session.source?.clock.dispose()
|
|
587
|
+
if (session.source) {
|
|
588
|
+
session.source = undefined
|
|
589
|
+
this.releaseServer(src, session.clipId)
|
|
590
|
+
}
|
|
591
|
+
this.scheduler?.sourceChanged(session.clipId)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ── per-src resources ─────────────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
private acquireServer(src: string, source: DemuxedSource, clipId: string): FrameServer {
|
|
598
|
+
let entry = this.servers.get(src)
|
|
599
|
+
if (!entry) {
|
|
600
|
+
entry = {
|
|
601
|
+
server: createFrameServer({
|
|
602
|
+
source,
|
|
603
|
+
hardwareAcceleration: this.deps.hardwareAcceleration,
|
|
604
|
+
decodeAheadFrames: this.deps.decodeAheadFrames,
|
|
605
|
+
onError: (message) => this.onDecodeError(src, message),
|
|
606
|
+
}),
|
|
607
|
+
refs: new Set(),
|
|
608
|
+
}
|
|
609
|
+
this.servers.set(src, entry)
|
|
610
|
+
}
|
|
611
|
+
entry.refs.add(clipId)
|
|
612
|
+
return entry.server
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Last clip off this src leaves ⇒ the worker is terminated. The spike's `load` rule. */
|
|
616
|
+
private releaseServer(src: string, clipId: string): void {
|
|
617
|
+
const entry = this.servers.get(src)
|
|
618
|
+
if (!entry) return
|
|
619
|
+
entry.refs.delete(clipId)
|
|
620
|
+
if (entry.refs.size > 0) return
|
|
621
|
+
entry.server.dispose()
|
|
622
|
+
this.servers.delete(src)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private async acquireDemux(src: string): Promise<DemuxedSource> {
|
|
626
|
+
const cached = this.demuxCache.get(src)
|
|
627
|
+
if (cached) {
|
|
628
|
+
// Touch: re-insert so Map iteration order stays oldest-first.
|
|
629
|
+
this.demuxCache.delete(src)
|
|
630
|
+
this.demuxCache.set(src, cached)
|
|
631
|
+
return cached
|
|
632
|
+
}
|
|
633
|
+
let entry = this.demuxing.get(src)
|
|
634
|
+
if (!entry) {
|
|
635
|
+
const controller = new AbortController()
|
|
636
|
+
// The AbortController was already here for `abandonDemux`; the timer just
|
|
637
|
+
// gives it a second trigger. Aborting is the only thing that CAN end a
|
|
638
|
+
// hung fetch — a `Promise.race` would resolve the build's promise while
|
|
639
|
+
// the request stayed open, which is how you leak a connection per stalled
|
|
640
|
+
// clip on a timeline that keeps scrubbing past them.
|
|
641
|
+
let timedOut = false
|
|
642
|
+
let timer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
|
|
643
|
+
timedOut = true
|
|
644
|
+
controller.abort()
|
|
645
|
+
}, BUILD_TIMEOUT_MS)
|
|
646
|
+
const clear = () => {
|
|
647
|
+
if (timer === undefined) return
|
|
648
|
+
clearTimeout(timer)
|
|
649
|
+
timer = undefined
|
|
650
|
+
}
|
|
651
|
+
const promise = demux(src, this.deps.fileUrl, { signal: controller.signal }).then(
|
|
652
|
+
(source) => {
|
|
653
|
+
clear()
|
|
654
|
+
return source
|
|
655
|
+
},
|
|
656
|
+
(err: unknown) => {
|
|
657
|
+
clear()
|
|
658
|
+
// The abort surfaces as a generic `AbortError`, which reads as "the
|
|
659
|
+
// caller cancelled" and would be shown to the user as such. Name the
|
|
660
|
+
// real cause instead: the session's `reason` is what reaches the
|
|
661
|
+
// Preparing placeholder.
|
|
662
|
+
if (timedOut) {
|
|
663
|
+
throw new Error(`preview build timed out after ${BUILD_TIMEOUT_MS} ms`)
|
|
664
|
+
}
|
|
665
|
+
throw err
|
|
666
|
+
},
|
|
667
|
+
)
|
|
668
|
+
entry = { promise, controller, clearTimeout: clear, waiters: 0, abandoned: 0 }
|
|
669
|
+
this.demuxing.set(src, entry)
|
|
670
|
+
}
|
|
671
|
+
entry.waiters++
|
|
672
|
+
try {
|
|
673
|
+
const source = await entry.promise
|
|
674
|
+
this.demuxCache.set(src, source)
|
|
675
|
+
this.evictDemux()
|
|
676
|
+
return source
|
|
677
|
+
} finally {
|
|
678
|
+
entry.waiters--
|
|
679
|
+
this.demuxing.delete(src)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* A session dropped before its bytes landed; abort the fetch once EVERY
|
|
685
|
+
* waiter has abandoned it, not just the first.
|
|
686
|
+
*
|
|
687
|
+
* `waiters` never shrinks when a session is dropped (only when its
|
|
688
|
+
* `acquireDemux` await settles), so comparing against a fixed `waiters > 1`
|
|
689
|
+
* threshold only ever protects the FIRST drop on a shared src — a second
|
|
690
|
+
* clip dropped moments later would see the same stale `waiters` count and
|
|
691
|
+
* never abort, leaking the fetch for good. Counting how many times this has
|
|
692
|
+
* been called instead, and comparing against `waiters`, aborts exactly when
|
|
693
|
+
* every current waiter has actually abandoned it.
|
|
694
|
+
*/
|
|
695
|
+
private abandonDemux(src: string): void {
|
|
696
|
+
const entry = this.demuxing.get(src)
|
|
697
|
+
if (!entry) return
|
|
698
|
+
entry.abandoned++
|
|
699
|
+
if (entry.abandoned < entry.waiters) return
|
|
700
|
+
entry.clearTimeout()
|
|
701
|
+
entry.controller.abort()
|
|
702
|
+
this.demuxing.delete(src)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private evictDemux(): void {
|
|
706
|
+
while (this.demuxCache.size > DEMUX_CACHE_MAX) {
|
|
707
|
+
let victim: string | null = null
|
|
708
|
+
for (const src of this.demuxCache.keys()) {
|
|
709
|
+
// Never evict a source a live decode session is reading from.
|
|
710
|
+
if (this.servers.has(src)) continue
|
|
711
|
+
// Or one an outside consumer (the drag-scrub source) is holding.
|
|
712
|
+
if (this.demuxPins.has(src)) continue
|
|
713
|
+
victim = src
|
|
714
|
+
break
|
|
715
|
+
}
|
|
716
|
+
if (victim === null) return
|
|
717
|
+
this.demuxCache.delete(victim)
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Shared with the drag-scrub audio source. `acquireDemux` already coalesces
|
|
723
|
+
* concurrent builds and populates the LRU; this wraps it in a per-src pin
|
|
724
|
+
* so the caller can hold the demux across a cut without the scheduler
|
|
725
|
+
* evicting it, and releases the pin exactly once.
|
|
726
|
+
*
|
|
727
|
+
* On a disposed host the pin is a no-op and release is silent — the
|
|
728
|
+
* scrubber can outlive us during teardown and its release must not throw.
|
|
729
|
+
*/
|
|
730
|
+
async acquirePinnedDemux(src: string): Promise<AcquiredDemux> {
|
|
731
|
+
const source = await this.acquireDemux(src)
|
|
732
|
+
if (this.disposed) {
|
|
733
|
+
return { source, release: () => {} }
|
|
734
|
+
}
|
|
735
|
+
this.demuxPins.set(src, (this.demuxPins.get(src) ?? 0) + 1)
|
|
736
|
+
let released = false
|
|
737
|
+
return {
|
|
738
|
+
source,
|
|
739
|
+
release: () => {
|
|
740
|
+
if (released) return
|
|
741
|
+
released = true
|
|
742
|
+
if (this.disposed) return
|
|
743
|
+
const next = (this.demuxPins.get(src) ?? 0) - 1
|
|
744
|
+
if (next > 0) {
|
|
745
|
+
this.demuxPins.set(src, next)
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
this.demuxPins.delete(src)
|
|
749
|
+
// The pin may have held this src past the LRU cap; unpinned entries
|
|
750
|
+
// are eligible again so re-check now instead of waiting for the next
|
|
751
|
+
// `acquireDemux` to reconcile it.
|
|
752
|
+
this.evictDemux()
|
|
753
|
+
},
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ── createEngine ────────────────────────────────────────────────────────────
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Build the playback engine for one project.
|
|
762
|
+
*
|
|
763
|
+
* Nothing happens until something drives it: `attach` a canvas to see the
|
|
764
|
+
* current frame, `seek` to move, `play` to run. The engine starts paused at
|
|
765
|
+
* `startProjectS` (default 0).
|
|
766
|
+
*
|
|
767
|
+
* ── Surface notes for T6 ──
|
|
768
|
+
* `attach/play/pause/seek/dispose/clock` are the plan's table. Three additions,
|
|
769
|
+
* each because the hook genuinely needs it:
|
|
770
|
+
* - `updateProject(project)` — the editor mutates the project constantly, and
|
|
771
|
+
* without it a proxy arriving mid-session could never resolve a Preparing
|
|
772
|
+
* clip.
|
|
773
|
+
* - `status()` + the `onStatusChange` dep — the hook has to render
|
|
774
|
+
* `isPlaying`, the Preparing placeholder and (T7) the HUD; polling a
|
|
775
|
+
* getter every render is the alternative and it is worse.
|
|
776
|
+
* - `onTime` as a dep rather than a `subscribe()` method — the clock bridge
|
|
777
|
+
* needs the value BEFORE it reaches React state (see the dep's own note),
|
|
778
|
+
* and one construction-time callback reading a ref is the stable-identity
|
|
779
|
+
* shape React wants.
|
|
780
|
+
*/
|
|
781
|
+
/** Rolling window for `stats().fps` — matches the spike HUD's `FPS_WINDOW_MS`. */
|
|
782
|
+
const STATS_FPS_WINDOW_MS = 2000
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Drop every timestamp in `timesMs` older than `windowMs` before `nowMs`
|
|
786
|
+
* (mutates in place; the array must be ascending, oldest first — the order
|
|
787
|
+
* paints naturally arrive in). Exported, and split from `fpsFromPaintTimes`
|
|
788
|
+
* below, so `stats()`'s rolling-window math is unit-testable without a
|
|
789
|
+
* canvas, an rAF loop or a live decode session — the same reason
|
|
790
|
+
* `scheduler.ts` splits `containFitPlan`/`sourceCropDrawPlan` out of the
|
|
791
|
+
* class that owns the mutable state they serve.
|
|
792
|
+
*
|
|
793
|
+
* Called on every recorded paint (not just on `stats()` reads): a session
|
|
794
|
+
* where `debugHud` is never turned on still paints for the whole session, and
|
|
795
|
+
* pruning only on read would otherwise grow this array without bound for as
|
|
796
|
+
* long as nobody asks for `stats()`.
|
|
797
|
+
*/
|
|
798
|
+
export function pruneOlderThan(timesMs: number[], nowMs: number, windowMs: number): void {
|
|
799
|
+
const cutoff = nowMs - windowMs
|
|
800
|
+
let i = 0
|
|
801
|
+
while (i < timesMs.length && timesMs[i] < cutoff) i++
|
|
802
|
+
if (i > 0) timesMs.splice(0, i)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Instantaneous fps from an already-pruned, ascending list of paint timestamps. */
|
|
806
|
+
export function fpsFromPaintTimes(prunedTimesMs: readonly number[], nowMs: number, windowMs: number): number {
|
|
807
|
+
if (prunedTimesMs.length === 0) return 0
|
|
808
|
+
const span = Math.min(windowMs, nowMs - prunedTimesMs[0])
|
|
809
|
+
return span > 0 ? (prunedTimesMs.length / span) * 1000 : 0
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function createEngine(project: Project, deps: EngineDeps): Engine {
|
|
813
|
+
const host = new EngineSourceHost(deps)
|
|
814
|
+
const scheduler = createScheduler({
|
|
815
|
+
project,
|
|
816
|
+
host,
|
|
817
|
+
onTime: deps.onTime,
|
|
818
|
+
onStatusChange: deps.onStatusChange,
|
|
819
|
+
onError: deps.onError,
|
|
820
|
+
prewarmLeadS: deps.prewarmLeadS,
|
|
821
|
+
startProjectS: deps.startProjectS,
|
|
822
|
+
})
|
|
823
|
+
host.bind(scheduler)
|
|
824
|
+
|
|
825
|
+
const requestFrame = deps.requestFrame ?? ((cb: () => void) => requestAnimationFrame(cb))
|
|
826
|
+
const cancelFrame = deps.cancelFrame ?? ((handle: number) => cancelAnimationFrame(handle))
|
|
827
|
+
|
|
828
|
+
let raf: number | null = null
|
|
829
|
+
let currentProject = project
|
|
830
|
+
let canvas: HTMLCanvasElement | null = null
|
|
831
|
+
let disposed = false
|
|
832
|
+
|
|
833
|
+
const nowMs = deps.nowMs ?? (() => performance.now())
|
|
834
|
+
/** Painted-frame timestamps within the trailing window, oldest first. `stats()`'s fps source. */
|
|
835
|
+
let paintTimesMs: number[] = []
|
|
836
|
+
const recordPaint = () => {
|
|
837
|
+
const t = nowMs()
|
|
838
|
+
paintTimesMs.push(t)
|
|
839
|
+
pruneOlderThan(paintTimesMs, t, STATS_FPS_WINDOW_MS)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const pump = () => {
|
|
843
|
+
raf = null
|
|
844
|
+
if (disposed) return
|
|
845
|
+
scheduler.tick()
|
|
846
|
+
// The transport can end inside the tick (project end, or a looping clip's
|
|
847
|
+
// mid-loop stop); re-reading it here is what stops the loop without a
|
|
848
|
+
// separate "should I still be running" flag to keep in sync.
|
|
849
|
+
if (scheduler.status().transport === 'playing') raf = requestFrame(pump)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const start = () => {
|
|
853
|
+
if (raf !== null || disposed) return
|
|
854
|
+
if (scheduler.status().transport !== 'playing') return
|
|
855
|
+
raf = requestFrame(pump)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const stop = () => {
|
|
859
|
+
if (raf === null) return
|
|
860
|
+
cancelFrame(raf)
|
|
861
|
+
raf = null
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const sizeCanvas = () => {
|
|
865
|
+
if (!canvas) return
|
|
866
|
+
const [w, h] = designCanvas(currentProject.settings?.resolution)
|
|
867
|
+
if (canvas.width !== w) canvas.width = w
|
|
868
|
+
if (canvas.height !== h) canvas.height = h
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return {
|
|
872
|
+
attach(next: HTMLCanvasElement | null) {
|
|
873
|
+
canvas = next
|
|
874
|
+
if (!next) {
|
|
875
|
+
scheduler.attach(null)
|
|
876
|
+
return
|
|
877
|
+
}
|
|
878
|
+
sizeCanvas()
|
|
879
|
+
const painter = createCanvasPainter(next)
|
|
880
|
+
// Wrapped only to feed `stats().fps` — every other call is `painter`'s own.
|
|
881
|
+
scheduler.attach({
|
|
882
|
+
...painter,
|
|
883
|
+
paint: (frame, plan) => {
|
|
884
|
+
recordPaint()
|
|
885
|
+
painter.paint(frame, plan)
|
|
886
|
+
},
|
|
887
|
+
})
|
|
888
|
+
},
|
|
889
|
+
play() {
|
|
890
|
+
scheduler.play()
|
|
891
|
+
start()
|
|
892
|
+
},
|
|
893
|
+
pause() {
|
|
894
|
+
scheduler.pause()
|
|
895
|
+
stop()
|
|
896
|
+
},
|
|
897
|
+
seek(projectS: number) {
|
|
898
|
+
scheduler.seek(projectS)
|
|
899
|
+
if (scheduler.status().transport !== 'playing') stop()
|
|
900
|
+
},
|
|
901
|
+
setRate(rate: number) {
|
|
902
|
+
scheduler.setRate(rate)
|
|
903
|
+
},
|
|
904
|
+
updateProject(next: Project) {
|
|
905
|
+
currentProject = next
|
|
906
|
+
sizeCanvas()
|
|
907
|
+
scheduler.setProject(next)
|
|
908
|
+
},
|
|
909
|
+
status: () => scheduler.status(),
|
|
910
|
+
stats(): EngineStats {
|
|
911
|
+
// Prune on read too — a paused engine (no rAF, no paints) would otherwise
|
|
912
|
+
// report a stale fps from before it stopped rather than decaying to 0.
|
|
913
|
+
const t = nowMs()
|
|
914
|
+
pruneOlderThan(paintTimesMs, t, STATS_FPS_WINDOW_MS)
|
|
915
|
+
const fps = fpsFromPaintTimes(paintTimesMs, t, STATS_FPS_WINDOW_MS)
|
|
916
|
+
|
|
917
|
+
const st = scheduler.status()
|
|
918
|
+
const state = st.clipId ? host.state(st.clipId) : { status: 'idle' as const }
|
|
919
|
+
const frameStats = state.status === 'ready' ? state.source.frameServer.stats() : null
|
|
920
|
+
|
|
921
|
+
return {
|
|
922
|
+
fps,
|
|
923
|
+
dropped: frameStats?.dropped ?? 0,
|
|
924
|
+
buffered: frameStats?.buffered ?? 0,
|
|
925
|
+
clock: st.clock,
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
clock: {
|
|
929
|
+
now: () => scheduler.now(),
|
|
930
|
+
get playing() {
|
|
931
|
+
return scheduler.status().transport === 'playing'
|
|
932
|
+
},
|
|
933
|
+
get kind() {
|
|
934
|
+
return scheduler.status().clock
|
|
935
|
+
},
|
|
936
|
+
},
|
|
937
|
+
acquireDemux: (src: string) => host.acquirePinnedDemux(src),
|
|
938
|
+
dispose() {
|
|
939
|
+
if (disposed) return
|
|
940
|
+
disposed = true
|
|
941
|
+
stop()
|
|
942
|
+
scheduler.dispose()
|
|
943
|
+
host.disposeAll()
|
|
944
|
+
paintTimesMs = []
|
|
945
|
+
},
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** Re-exported so T6 can type its own item lists without reaching into the schema. */
|
|
950
|
+
export type { VisualItem }
|