@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,1324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T5 — the scheduler: ONE tick, and every transport semantic the editor has.
|
|
3
|
+
*
|
|
4
|
+
* The legacy player (`../video/preview/useVideoPlayback.ts`) runs three clocks —
|
|
5
|
+
* a canvas rAF, a gap rAF, and a per-frame `handleTimeUpdate` pump over a
|
|
6
|
+
* `<video>` element's `currentTime` — plus a double-buffered pair of `<video>`
|
|
7
|
+
* slots and a `loopOffsetRef` threaded through three call sites. All of it
|
|
8
|
+
* exists to answer one question sixty times a second: *what should be on screen
|
|
9
|
+
* and where is the playhead*. This module answers that question once, from a
|
|
10
|
+
* single master clock, and the legacy hook is its specification: every branch
|
|
11
|
+
* below cites the site it reproduces.
|
|
12
|
+
*
|
|
13
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
14
|
+
* WHY THIS IS A SYNCHRONOUS STATE MACHINE
|
|
15
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
16
|
+
* Everything genuinely asynchronous lives BEHIND an injected interface:
|
|
17
|
+
*
|
|
18
|
+
* - `SourceHost` — fetch + demux + `createFrameServer` + `createMasterClock`
|
|
19
|
+
* for one clip. The scheduler never awaits it; it declares which clips it
|
|
20
|
+
* needs (`retain`) and reads a status (`state`) that is `'loading'` until
|
|
21
|
+
* the host says otherwise. The host pokes the scheduler with
|
|
22
|
+
* `sourceChanged(clipId)` when a load lands or fails.
|
|
23
|
+
* - `Painter` — `drawImage` onto a canvas.
|
|
24
|
+
* - `MasterClock` — the T4 clock (audio-derived or wall-clock fallback).
|
|
25
|
+
*
|
|
26
|
+
* The only `await` in the whole module is the frame promise a paused seek
|
|
27
|
+
* returns, and it is guarded by a generation counter. Everything else is
|
|
28
|
+
* straight-line synchronous logic over the injected surfaces — which is exactly
|
|
29
|
+
* what makes "play into a gap", "scrub past the end", "loop wrap", "boundary
|
|
30
|
+
* swap", "opaque toggle mid-play" and "decode failure mid-clip" unit-testable
|
|
31
|
+
* with fakes in jsdom, where there is no WebCodecs, no `Worker` and no canvas.
|
|
32
|
+
*
|
|
33
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
34
|
+
* THE TWO STATE AXES (and why they are not one enum)
|
|
35
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
36
|
+
* `transport` (idle → paused ↔ playing → ended) and `picture` (video / black /
|
|
37
|
+
* opaque / preparing) are ORTHOGONAL. You can be paused on a gap, playing under
|
|
38
|
+
* an opaque overlay, or playing through a clip whose proxy is still encoding.
|
|
39
|
+
* Collapsing them into one enum would make the transition table a product
|
|
40
|
+
* rather than a sum, and would hide the single most important invariant this
|
|
41
|
+
* task exists to establish: **the picture state never stops the clock.** A gap,
|
|
42
|
+
* an opaque overlay and a missing/failed proxy all hide the picture while
|
|
43
|
+
* project time keeps advancing — the legacy gap clock's behavior
|
|
44
|
+
* (`useVideoPlayback.ts`'s `tickGap`), generalized.
|
|
45
|
+
*
|
|
46
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
47
|
+
* MAPPING FROM THE LEGACY HOOK (the spec, by content not by line number)
|
|
48
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
49
|
+
* gap `tickGap` + the `idx === -1` branch of the scrub effect + the
|
|
50
|
+
* `next.start > cur.end + 0.02` branch of `handleTimeUpdate`.
|
|
51
|
+
* Here: `plan.active === null` ⇒ picture `'black'`, the clock is
|
|
52
|
+
* a wall-clock fallback, time advances, and the boundary is
|
|
53
|
+
* simply the tick at which `resolveAt` starts returning the next
|
|
54
|
+
* clip. No gap-specific clock, no `gapTargetRef`.
|
|
55
|
+
* loop the three `loopOffsetRef` sites (scrub-into-loop, wrap-at-
|
|
56
|
+
* outPoint, stop-at-clip.end). Here: `placeInSource` is the
|
|
57
|
+
* scrub site's arithmetic made total, and `loopOffset` is
|
|
58
|
+
* derived (`wraps * loopDur`) rather than accumulated — a
|
|
59
|
+
* derived offset cannot drift, and a scrub lands identically to
|
|
60
|
+
* a wrap by construction.
|
|
61
|
+
* end `handleTimeUpdate`'s last-clip branch (which hands off to the
|
|
62
|
+
* gap clock with `gapTargetRef = projectEnd` so trailing
|
|
63
|
+
* overlays/audio keep playing) + `tickGap`'s no-next-clip tail
|
|
64
|
+
* (`setIsPlaying(false)`). Here: `transportEndFor` and the
|
|
65
|
+
* `t >= end` clamp.
|
|
66
|
+
* opaque NOT a legacy behavior — the legacy `<video>` path keeps showing
|
|
67
|
+
* the video under an opaque overlay while render replaces the
|
|
68
|
+
* picture. The registry disposition (KNOWN-DIVERGENCES, opaque)
|
|
69
|
+
* is to unify on RENDER semantics now that there is a
|
|
70
|
+
* compositing stage: skip the paint, keep the audio.
|
|
71
|
+
* sourceCrop `../video/preview/sourceCropStyle.ts`, re-expressed as a
|
|
72
|
+
* `drawImage` source+destination rect. See `sourceCropDrawPlan`.
|
|
73
|
+
* canvas `isCanvasProject` — a project with no track-0 video items runs
|
|
74
|
+
* the whole timeline on the fallback clock with picture
|
|
75
|
+
* `'black'`; the overlay/caption DOM layers do the drawing.
|
|
76
|
+
*/
|
|
77
|
+
import {
|
|
78
|
+
projectEnd as timelineProjectEnd,
|
|
79
|
+
resolveAt,
|
|
80
|
+
sourceWindow,
|
|
81
|
+
} from '@bycrux/timeline-core'
|
|
82
|
+
import type { Scene, SourceWindow } from '@bycrux/timeline-core'
|
|
83
|
+
import type { EditorProject as Project, VisualItem, VisualTrack } from '../schema'
|
|
84
|
+
import type { ClipTimebase, MasterClock } from './audio-clock'
|
|
85
|
+
import type { FrameServer } from './frame-server'
|
|
86
|
+
import { effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../video/timeline/timeline-model'
|
|
87
|
+
|
|
88
|
+
// ── Tuning constants ────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How far ahead of a clip boundary the NEXT clip's decode session is built.
|
|
92
|
+
*
|
|
93
|
+
* The plan names 1s ("prewarm next item at boundary−1s (the preload analog)").
|
|
94
|
+
* Worth knowing while tuning it: the legacy hook deliberately abandoned a ~1s
|
|
95
|
+
* lead and preloads the next `<video>` slot for the WHOLE of the current clip
|
|
96
|
+
* instead — its comment records that 1s was "far too short" and stalled
|
|
97
|
+
* playback at cross-source cuts. That lesson was measured against 4K 10-bit
|
|
98
|
+
* HEVC masters with the moov atom at the END of the file (a slow tail range
|
|
99
|
+
* fetch before anything can be indexed). The engine only ever opens SP3's
|
|
100
|
+
* faststart H.264 proxies, which is a different order of magnitude — hence the
|
|
101
|
+
* plan's 1s. It is a dep (`prewarmLeadS`) precisely so the parity checklist can
|
|
102
|
+
* move it without a code change if a real project says otherwise.
|
|
103
|
+
*/
|
|
104
|
+
export const PREWARM_LEAD_S = 1
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Tolerance for "the loop window was exhausted at the same instant the clip's
|
|
108
|
+
* project window ended" — see `endsOnLoopBoundary`. Authors set loop spans
|
|
109
|
+
* deliberately (a 2s loop across a 6s clip), so the coincidence is exact
|
|
110
|
+
* arithmetic in practice and this only absorbs float error.
|
|
111
|
+
*/
|
|
112
|
+
export const LOOP_BOUNDARY_EPS_S = 1e-3
|
|
113
|
+
|
|
114
|
+
// ── Painting ────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One `drawImage(frame, sx, sy, sw, sh, dx, dy, dw, dh)` call, split out so the
|
|
118
|
+
* arithmetic is pure and testable without a canvas.
|
|
119
|
+
*
|
|
120
|
+
* Source coordinates are in the decoded frame's own pixels; destination
|
|
121
|
+
* coordinates are in the canvas backing store's pixels. Neither carries the
|
|
122
|
+
* item's `scale`/`offsetX`/`offsetY`: those stay on the CSS transform container
|
|
123
|
+
* that wraps the canvas, untouched, exactly as they wrap the `<video>` slots
|
|
124
|
+
* today (`PreviewPlayer.tsx`'s `transformContainerStyle`).
|
|
125
|
+
*/
|
|
126
|
+
export interface DrawPlan {
|
|
127
|
+
sx: number
|
|
128
|
+
sy: number
|
|
129
|
+
sw: number
|
|
130
|
+
sh: number
|
|
131
|
+
dx: number
|
|
132
|
+
dy: number
|
|
133
|
+
dw: number
|
|
134
|
+
dh: number
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The canvas surface, injected so the scheduler can be tested without one. */
|
|
138
|
+
export interface Painter {
|
|
139
|
+
/** Backing-store size in pixels. Re-read every paint — the host may resize. */
|
|
140
|
+
size(): { width: number; height: number }
|
|
141
|
+
/** Draw one frame. The scheduler closes the frame immediately after this returns. */
|
|
142
|
+
paint(frame: VideoFrame, plan: DrawPlan): void
|
|
143
|
+
/** Fill the whole surface with black (gap / opaque / preparing). */
|
|
144
|
+
clear(): void
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `object-fit: contain` as a `drawImage` rect — the no-crop default, matching
|
|
149
|
+
* `PreviewPlayer.tsx`'s `baseVideoStyle` (`objectFit: 'contain'`, inset 0).
|
|
150
|
+
*/
|
|
151
|
+
export function containFitPlan(
|
|
152
|
+
codedWidth: number,
|
|
153
|
+
codedHeight: number,
|
|
154
|
+
frameWidth: number,
|
|
155
|
+
frameHeight: number,
|
|
156
|
+
): DrawPlan {
|
|
157
|
+
if (!codedWidth || !codedHeight || !frameWidth || !frameHeight) {
|
|
158
|
+
return { sx: 0, sy: 0, sw: 0, sh: 0, dx: 0, dy: 0, dw: 0, dh: 0 }
|
|
159
|
+
}
|
|
160
|
+
const scale = Math.min(frameWidth / codedWidth, frameHeight / codedHeight)
|
|
161
|
+
const dw = codedWidth * scale
|
|
162
|
+
const dh = codedHeight * scale
|
|
163
|
+
return {
|
|
164
|
+
sx: 0,
|
|
165
|
+
sy: 0,
|
|
166
|
+
sw: codedWidth,
|
|
167
|
+
sh: codedHeight,
|
|
168
|
+
dx: (frameWidth - dw) / 2,
|
|
169
|
+
dy: (frameHeight - dh) / 2,
|
|
170
|
+
dw,
|
|
171
|
+
dh,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface SourceCropDrawInput {
|
|
176
|
+
crop: { x: number; y: number; w: number; h: number } | undefined
|
|
177
|
+
/** The ITEM's intrinsic source dims. Absent ⇒ no crop; see the guard note below. */
|
|
178
|
+
sourceWidth: number | undefined
|
|
179
|
+
sourceHeight: number | undefined
|
|
180
|
+
/** The DECODED frame's dims — what the source rect is expressed in. */
|
|
181
|
+
codedWidth: number
|
|
182
|
+
codedHeight: number
|
|
183
|
+
frameWidth: number
|
|
184
|
+
frameHeight: number
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `sourceCropStyle.ts`'s algebra, re-expressed as a `drawImage` rect.
|
|
189
|
+
*
|
|
190
|
+
* The CSS version sizes the `<video>` LARGER than its frame box and translates
|
|
191
|
+
* it so only the crop sub-rect is visible; the canvas version draws only the
|
|
192
|
+
* crop sub-rect, into the box that sub-rect would have occupied. They are the
|
|
193
|
+
* same statement:
|
|
194
|
+
*
|
|
195
|
+
* CSS: video box = (videoWRatio, videoHRatio) at (leftRatio, topRatio),
|
|
196
|
+
* with videoWRatio = cropWRatio / crop.w and
|
|
197
|
+
* leftRatio = (1 - cropWRatio)/2 - crop.x * videoWRatio.
|
|
198
|
+
* The crop sub-rect therefore lands at
|
|
199
|
+
* leftRatio + crop.x * videoWRatio = (1 - cropWRatio)/2
|
|
200
|
+
* and spans crop.w * videoWRatio = cropWRatio.
|
|
201
|
+
*
|
|
202
|
+
* …which is exactly `dx`/`dw` below. `__tests__/scheduler-crop.test.ts` asserts
|
|
203
|
+
* that equality against `sourceCropVideoStyle`'s own test vectors rather than
|
|
204
|
+
* trusting this comment.
|
|
205
|
+
*
|
|
206
|
+
* ── The no-dims guard (a parity decision, not an oversight) ──
|
|
207
|
+
* Returns `null` — i.e. NO crop — when the ITEM carries no
|
|
208
|
+
* `sourceWidth`/`sourceHeight`. `sourceCropStyle.ts:32` makes the same call,
|
|
209
|
+
* and so does render (`encode-segment.js`'s `if (sc && item.sourceWidth &&
|
|
210
|
+
* item.sourceHeight)` gate, which drops the `crop=` filter step entirely) —
|
|
211
|
+
* KNOWN-DIVERGENCES entry 8, `sourcecrop-missing-dims-silent-drop`.
|
|
212
|
+
*
|
|
213
|
+
* What is deliberately NOT reproduced is `PreviewPlayer.tsx`'s call-site
|
|
214
|
+
* fallback `activeClip?.sourceWidth ?? videoDims?.w`, which substitutes the
|
|
215
|
+
* loaded `<video>`'s intrinsic dims when the item has none. The decoded frame's
|
|
216
|
+
* `coded` dims are the engine's equivalent of `videoDims` and were available
|
|
217
|
+
* here, so this is a choice: that fallback makes the PREVIEW crop an item render
|
|
218
|
+
* would not crop, which is the divergence entry 8 documents. The plan's
|
|
219
|
+
* disposition is the "parity-safe" guard, so the engine gates on the item.
|
|
220
|
+
*/
|
|
221
|
+
export function sourceCropDrawPlan(input: SourceCropDrawInput): DrawPlan | null {
|
|
222
|
+
const { crop, sourceWidth, sourceHeight, codedWidth, codedHeight, frameWidth, frameHeight } =
|
|
223
|
+
input
|
|
224
|
+
// sourceCropStyle.ts:32 — all four dims required.
|
|
225
|
+
if (!sourceWidth || !sourceHeight || !frameWidth || !frameHeight) return null
|
|
226
|
+
if (!codedWidth || !codedHeight) return null
|
|
227
|
+
// sourceCropStyle.ts:33 — an inverted or empty crop is not a crop.
|
|
228
|
+
if (!crop || crop.w <= 0 || crop.h <= 0) return null
|
|
229
|
+
// sourceCropStyle.ts:35 — a full-frame crop is the default and needs nothing.
|
|
230
|
+
if (crop.x === 0 && crop.y === 0 && crop.w === 1 && crop.h === 1) return null
|
|
231
|
+
|
|
232
|
+
const frameAspect = frameWidth / frameHeight
|
|
233
|
+
const cropAspect = (sourceWidth * crop.w) / (sourceHeight * crop.h)
|
|
234
|
+
|
|
235
|
+
// Contain-fit the crop region into the frame → its displayed size as a ratio
|
|
236
|
+
// of the frame's own dimensions. Verbatim from sourceCropStyle.ts:44-50.
|
|
237
|
+
let cropWRatio: number
|
|
238
|
+
let cropHRatio: number
|
|
239
|
+
if (cropAspect >= frameAspect) {
|
|
240
|
+
cropWRatio = 1
|
|
241
|
+
cropHRatio = frameAspect / cropAspect
|
|
242
|
+
} else {
|
|
243
|
+
cropHRatio = 1
|
|
244
|
+
cropWRatio = cropAspect / frameAspect
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
// The crop sub-rect inside the DECODED frame. Expressed against the coded
|
|
249
|
+
// dims rather than the item's `sourceWidth`/`sourceHeight` because the
|
|
250
|
+
// proxy is a re-encode at a different resolution — `sourceCrop` is stored
|
|
251
|
+
// as RATIOS of the source precisely so it survives that.
|
|
252
|
+
sx: crop.x * codedWidth,
|
|
253
|
+
sy: crop.y * codedHeight,
|
|
254
|
+
sw: crop.w * codedWidth,
|
|
255
|
+
sh: crop.h * codedHeight,
|
|
256
|
+
dx: ((1 - cropWRatio) / 2) * frameWidth,
|
|
257
|
+
dy: ((1 - cropHRatio) / 2) * frameHeight,
|
|
258
|
+
dw: cropWRatio * frameWidth,
|
|
259
|
+
dh: cropHRatio * frameHeight,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The crop plan when the item has one, the contain-fit default otherwise. */
|
|
264
|
+
export function drawPlanFor(
|
|
265
|
+
item: Pick<VisualItem, 'sourceCrop' | 'sourceWidth' | 'sourceHeight'>,
|
|
266
|
+
codedWidth: number,
|
|
267
|
+
codedHeight: number,
|
|
268
|
+
frameWidth: number,
|
|
269
|
+
frameHeight: number,
|
|
270
|
+
): DrawPlan {
|
|
271
|
+
const cropped = sourceCropDrawPlan({
|
|
272
|
+
crop: item.sourceCrop,
|
|
273
|
+
sourceWidth: item.sourceWidth,
|
|
274
|
+
sourceHeight: item.sourceHeight,
|
|
275
|
+
codedWidth,
|
|
276
|
+
codedHeight,
|
|
277
|
+
frameWidth,
|
|
278
|
+
frameHeight,
|
|
279
|
+
})
|
|
280
|
+
return cropped ?? containFitPlan(codedWidth, codedHeight, frameWidth, frameHeight)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Source placement (the loop reimplementation) ─────────────────────────────
|
|
284
|
+
|
|
285
|
+
export interface SourcePlacement {
|
|
286
|
+
/** Position inside the LOADED src, seconds — the `<video>.currentTime` analog. */
|
|
287
|
+
mediaS: number
|
|
288
|
+
/** Whole loop windows elapsed. 0 for a non-looping clip. */
|
|
289
|
+
wraps: number
|
|
290
|
+
/** `wraps * loopDur` — the legacy `loopOffsetRef.current`, derived rather than accumulated. */
|
|
291
|
+
loopOffset: number
|
|
292
|
+
/** Whether loop arithmetic actually applied (`item.loop` AND a usable window). */
|
|
293
|
+
looping: boolean
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Where inside the loaded source project time `t` lands, loop included.
|
|
298
|
+
*
|
|
299
|
+
* This is the legacy scrub site's arithmetic, made total:
|
|
300
|
+
*
|
|
301
|
+
* if (clip.loop && clipOutPoint != null) {
|
|
302
|
+
* const loopDur = clipOutPoint - inPoint
|
|
303
|
+
* const elapsed = currentTime - clip.start
|
|
304
|
+
* const loops = Math.floor(elapsed / loopDur)
|
|
305
|
+
* loopOffsetRef.current = loops * loopDur
|
|
306
|
+
* video.currentTime = inPoint + (elapsed % loopDur)
|
|
307
|
+
* } else {
|
|
308
|
+
* loopOffsetRef.current = 0
|
|
309
|
+
* video.currentTime = Math.max(inPoint, inPoint + (currentTime - clip.start))
|
|
310
|
+
* }
|
|
311
|
+
*
|
|
312
|
+
* **`loopOffset` is derived, not accumulated.** The legacy hook keeps a mutable
|
|
313
|
+
* `loopOffsetRef` that the wrap site increments (`loopOffsetRef.current +=
|
|
314
|
+
* loopDur`) and the scrub site recomputes — two writers, one of which can drift
|
|
315
|
+
* if a wrap is missed (a dropped frame, a backgrounded tab). Deriving it from
|
|
316
|
+
* `t` makes a wrap during playback and a scrub into the middle of the same loop
|
|
317
|
+
* land on identical media positions by construction, which is the property the
|
|
318
|
+
* three legacy sites were trying to maintain by hand.
|
|
319
|
+
*
|
|
320
|
+
* `outPoint == null` falls to the non-loop branch exactly as the legacy site's
|
|
321
|
+
* `clipOutPoint != null` guard does; a non-positive `loopDur` does too (the
|
|
322
|
+
* legacy expression would produce `Infinity`/`NaN` there).
|
|
323
|
+
*/
|
|
324
|
+
export function placeInSource(
|
|
325
|
+
item: Pick<VisualItem, 'start' | 'loop' | 'speed'>,
|
|
326
|
+
window: Pick<SourceWindow, 'inPoint' | 'outPoint'>,
|
|
327
|
+
t: number,
|
|
328
|
+
): SourcePlacement {
|
|
329
|
+
const inPoint = window.inPoint
|
|
330
|
+
const speed = item.speed ?? 1
|
|
331
|
+
// Per-clip speed S scales the SOURCE traversal, not project time: the elapsed
|
|
332
|
+
// project-seconds inside the clip are converted to source-seconds at S× before
|
|
333
|
+
// any placement (`mediaS = inPoint + S·(t − start)`), so the clip shows S× its
|
|
334
|
+
// content per project-second. S=1 is the legacy `inPoint + elapsed`.
|
|
335
|
+
const sourceElapsed = speed * Math.max(0, t - (item.start ?? 0))
|
|
336
|
+
const outPoint = window.outPoint
|
|
337
|
+
const loopDur = outPoint == null ? 0 : outPoint - inPoint
|
|
338
|
+
if (!item.loop || outPoint == null || loopDur <= 0) {
|
|
339
|
+
return { mediaS: inPoint + sourceElapsed, wraps: 0, loopOffset: 0, looping: false }
|
|
340
|
+
}
|
|
341
|
+
// `loopDur` is a SOURCE span (outPoint − inPoint) and speed does not touch it —
|
|
342
|
+
// only how fast we traverse it. One loop iteration therefore occupies
|
|
343
|
+
// `loopDur/speed` PROJECT-seconds, but once `elapsed` is in source-seconds the
|
|
344
|
+
// wrap arithmetic below is identical to the speed-1 case. `wraps`/`loopOffset`
|
|
345
|
+
// stay in source terms, consistent with a non-sped loop.
|
|
346
|
+
const wraps = Math.floor(sourceElapsed / loopDur)
|
|
347
|
+
return {
|
|
348
|
+
mediaS: inPoint + (sourceElapsed - wraps * loopDur),
|
|
349
|
+
wraps,
|
|
350
|
+
loopOffset: wraps * loopDur,
|
|
351
|
+
looping: true,
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Does a looping clip's project window end exactly ON a loop wrap?
|
|
357
|
+
*
|
|
358
|
+
* This decides which of the legacy hook's two loop-end behaviors applies when
|
|
359
|
+
* project time reaches `clip.end`:
|
|
360
|
+
*
|
|
361
|
+
* - **On a wrap** — `handleTimeUpdate`'s `video.currentTime >= outPoint`
|
|
362
|
+
* branch fires first, sees `projectT >= clip.end`, and falls THROUGH to the
|
|
363
|
+
* ordinary next-clip / end-of-project logic.
|
|
364
|
+
* - **Mid-loop** (the common case, since `end - start` is rarely an exact
|
|
365
|
+
* multiple of the loop) — the separate `if (clip.loop && t >= clip.end)`
|
|
366
|
+
* site fires instead: `video.pause()`, playhead parked at `clip.end`, every
|
|
367
|
+
* audio element paused, `setIsPlaying(false)`. Playback STOPS; it does not
|
|
368
|
+
* continue into the next clip.
|
|
369
|
+
*
|
|
370
|
+
* The second behavior looks like a bug and is reproduced anyway: it is what
|
|
371
|
+
* ships, the plan's acceptance asks for loop transport "indistinguishable from
|
|
372
|
+
* legacy", and quietly changing it would be a behavior change smuggled in under
|
|
373
|
+
* a refactor. `KNOWN-DIVERGENCES` is where a decision to drop it belongs.
|
|
374
|
+
*/
|
|
375
|
+
export function endsOnLoopBoundary(
|
|
376
|
+
item: Pick<VisualItem, 'start' | 'end' | 'loop'>,
|
|
377
|
+
window: Pick<SourceWindow, 'inPoint' | 'outPoint'>,
|
|
378
|
+
): boolean {
|
|
379
|
+
if (!item.loop || window.outPoint == null) return false
|
|
380
|
+
const loopDur = window.outPoint - window.inPoint
|
|
381
|
+
if (loopDur <= 0) return false
|
|
382
|
+
const span = (item.end ?? 0) - (item.start ?? 0)
|
|
383
|
+
if (span <= 0) return true
|
|
384
|
+
const remainder = span % loopDur
|
|
385
|
+
return remainder < LOOP_BOUNDARY_EPS_S || loopDur - remainder < LOOP_BOUNDARY_EPS_S
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Per-tick plan ────────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
/** The track-0 video item on screen at `t`, with everything the tick needs. */
|
|
391
|
+
export interface ActiveClip {
|
|
392
|
+
item: VisualItem
|
|
393
|
+
clipId: string
|
|
394
|
+
/** The editing proxy the engine may open, or `''` when this clip is not playable — see {@link engineSrcFor}. */
|
|
395
|
+
src: string
|
|
396
|
+
/** Why `src` is empty. Surfaces as the Preparing placeholder's reason. */
|
|
397
|
+
blocked?: string
|
|
398
|
+
window: SourceWindow
|
|
399
|
+
placement: SourcePlacement
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Everything one tick needs to know about the timeline at `t`. */
|
|
403
|
+
export interface TickPlan {
|
|
404
|
+
t: number
|
|
405
|
+
/** The active track-0 video item, or `null` for a gap / canvas project / past the last clip. */
|
|
406
|
+
active: ActiveClip | null
|
|
407
|
+
/** An `opaque` overlay is active — picture suppressed, audio kept (render semantics). */
|
|
408
|
+
opaque: boolean
|
|
409
|
+
/** The next track-0 video item starting after `t` — the prewarm target. */
|
|
410
|
+
next: { item: VisualItem; clipId: string; start: number } | null
|
|
411
|
+
/**
|
|
412
|
+
* The track-0 video item immediately BEFORE the active one — the retain
|
|
413
|
+
* target behind the playhead.
|
|
414
|
+
*
|
|
415
|
+
* Prewarming only forward was right when a cached source pinned a whole
|
|
416
|
+
* proxy, but it made the commonest editing gesture the expensive one: nudge
|
|
417
|
+
* the playhead back over a cut and the clip you just left had already been
|
|
418
|
+
* disposed, so it rebuilt from scratch — new decode worker, new demux — every
|
|
419
|
+
* time you crossed the seam. Naming it in the plan lets `retainFor` keep it
|
|
420
|
+
* alive instead, which is only affordable now that ranged loading made a
|
|
421
|
+
* retained source cost a sample index rather than a file.
|
|
422
|
+
*/
|
|
423
|
+
prev: { item: VisualItem; clipId: string; start: number } | null
|
|
424
|
+
/** No track-0 video items at all — the legacy `isCanvasProject`. */
|
|
425
|
+
canvas: boolean
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Track-0 video items, filtered and start-sorted — the legacy hook's `clips`
|
|
430
|
+
* memo verbatim (`(trackItems(project)[0] ?? []).filter(c => c.type === 'video')
|
|
431
|
+
* .sort((a, b) => a.start - b.start)`).
|
|
432
|
+
*/
|
|
433
|
+
export function track0VideoItems(project: Project): VisualItem[] {
|
|
434
|
+
// Enabled tracks only — this feeds playback, so a skipped base track yields
|
|
435
|
+
// no clips and the preview falls through to its no-primary-video path.
|
|
436
|
+
return (enabledTrackItems(project)[0] ?? [])
|
|
437
|
+
.filter((c) => c.type === 'video')
|
|
438
|
+
.slice()
|
|
439
|
+
.sort((a, b) => a.start - b.start)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The track `track0VideoItems` drew its clips from — the object that carries
|
|
444
|
+
* the `volume`/`muted` those clips inherit. `undefined` when the project has no
|
|
445
|
+
* enabled tracks at all, which folds to the clip's own settings.
|
|
446
|
+
*
|
|
447
|
+
* `enabledTracks` applies `enabledTrackItems`' same skip test and preserves
|
|
448
|
+
* the same positions, so `[0]` here is the track `[0]` there came out of.
|
|
449
|
+
*/
|
|
450
|
+
export function track0Track(project: Project): VisualTrack | undefined {
|
|
451
|
+
return enabledTracks(project)[0]
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* One clip as the HOST should see it: its own audio settings with its track's
|
|
456
|
+
* already folded in (`effectiveItemAudio` — volume multiplies, mute is
|
|
457
|
+
* either/or).
|
|
458
|
+
*
|
|
459
|
+
* A DERIVED object, never a write to the item. `SourceRequest` is built fresh
|
|
460
|
+
* per source every tick and nothing downstream reads `item` by identity (the
|
|
461
|
+
* host compares `start`, the source window's `inPoint`, `muted` and `volume`
|
|
462
|
+
* by value), so a spread is safe here — whereas assigning onto the item would
|
|
463
|
+
* hit the project's own object, which the renderer's `resolveProjectPaths` and
|
|
464
|
+
* the server's `_apply_project_edits` both mutate in place.
|
|
465
|
+
*
|
|
466
|
+
* Folding HERE, before the request exists, is what makes a TRACK mute behave
|
|
467
|
+
* exactly like a clip mute downstream: `retain`'s drop test reads
|
|
468
|
+
* `want.item.muted`, so the session respawns onto a wall clock the same way,
|
|
469
|
+
* and a track VOLUME change goes down the same live `setVolume` path a clip
|
|
470
|
+
* volume change does. Neither branch had to learn about tracks.
|
|
471
|
+
*/
|
|
472
|
+
function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): VisualItem {
|
|
473
|
+
const { volume, muted } = effectiveItemAudio(track, item)
|
|
474
|
+
return { ...item, volume, muted }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The transport's last instant.
|
|
479
|
+
*
|
|
480
|
+
* Two formulas, because the legacy hook has two and they legitimately differ:
|
|
481
|
+
*
|
|
482
|
+
* - **Canvas projects** (no track-0 video) run the `isCanvasProject` rAF,
|
|
483
|
+
* whose ceiling is `canvasMaxEndRef` = `max(overlayEnd, captionEnd)`. Note
|
|
484
|
+
* what is NOT in it: audio. A canvas project whose music outlasts its
|
|
485
|
+
* overlays stops at the overlays today.
|
|
486
|
+
* - **Video projects** use `projectEnd` = `max(videoEnd, overlayEnd,
|
|
487
|
+
* audioEnd)` — captions excluded, audio included — which timeline-core
|
|
488
|
+
* already ports verbatim (including its two documented faithfulness warts:
|
|
489
|
+
* last-clip-by-start rather than max, and track-0-video-only).
|
|
490
|
+
*
|
|
491
|
+
* Unifying them would be a behavior change in one mode or the other, so both
|
|
492
|
+
* are kept and the divergence is named here rather than smoothed over.
|
|
493
|
+
*/
|
|
494
|
+
export function transportEndFor(project: Project): number {
|
|
495
|
+
const clips = track0VideoItems(project)
|
|
496
|
+
if (clips.length > 0) return timelineProjectEnd(withEnabledItemTracks(project))
|
|
497
|
+
const overlayEnd = enabledTrackItems(project).slice(1)
|
|
498
|
+
.flat()
|
|
499
|
+
.reduce((m, i) => Math.max(m, i?.end ?? 0), 0)
|
|
500
|
+
const captionEnd = (project.captions?.segments ?? []).reduce(
|
|
501
|
+
(m: number, s) => Math.max(m, s.end ?? 0),
|
|
502
|
+
0,
|
|
503
|
+
)
|
|
504
|
+
return Math.max(overlayEnd, captionEnd)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The one source the engine is allowed to open for a clip — or why it can't.
|
|
509
|
+
*
|
|
510
|
+
* **Proxy-only playback is structural, not a preference** (SP1 requirement 4).
|
|
511
|
+
* `playbackSrcFor(item, 'preview')` answers a broader question than the engine
|
|
512
|
+
* can act on: its chain is `nobg_preview_src > proxySrc > normalizedSrc > src`,
|
|
513
|
+
* and three of those four are things this engine must never hand to its
|
|
514
|
+
* demuxer — the masters are 4K 10-bit HEVC and `nobg_preview_src` is VP9 WebM,
|
|
515
|
+
* while the demuxer is MP4-only in v1 and the decoder is configured for H.264.
|
|
516
|
+
*
|
|
517
|
+
* `eligibility.ts` keeps whole projects containing those out of engine mode in
|
|
518
|
+
* the first place, but eligibility is evaluated once per project-LOAD (plan
|
|
519
|
+
* decision 2, no mid-session mode-flapping), so a clip added or edited
|
|
520
|
+
* afterwards can still present one. That clip alone goes to `preparing`; the
|
|
521
|
+
* project does not fall back. Comparing against the resolved src rather than
|
|
522
|
+
* just reading `item.proxySrc` is what catches the second case: a
|
|
523
|
+
* `nobg_preview_src` appearing mid-session outranks the proxy in the chain, and
|
|
524
|
+
* silently decoding the proxy instead would show a preview with the background
|
|
525
|
+
* the user just removed still in it.
|
|
526
|
+
*/
|
|
527
|
+
export function engineSrcFor(
|
|
528
|
+
item: Pick<VisualItem, 'proxySrc'>,
|
|
529
|
+
window: Pick<SourceWindow, 'src'>,
|
|
530
|
+
): { src: string; blocked?: string } {
|
|
531
|
+
if (!item.proxySrc) return { src: '', blocked: 'no editing proxy yet' }
|
|
532
|
+
if (window.src !== item.proxySrc) {
|
|
533
|
+
return { src: '', blocked: 'a higher-precedence preview source the engine cannot decode' }
|
|
534
|
+
}
|
|
535
|
+
return { src: item.proxySrc }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Injected resolver — `resolveAt(project, t, {variant:'preview'})` in production. */
|
|
539
|
+
export type SceneResolver = (project: Project, t: number) => Scene
|
|
540
|
+
|
|
541
|
+
/** The production resolver: timeline-core's preview variant, no wrapper semantics. */
|
|
542
|
+
export const previewResolver: SceneResolver = (project, t) =>
|
|
543
|
+
resolveAt(withEnabledItemTracks(project), t, { variant: 'preview' })
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* What the timeline says at `t`. Pure: no clock, no host, no painter.
|
|
547
|
+
*
|
|
548
|
+
* Activation comes from `resolveAt`, so the engine inherits the resolver's
|
|
549
|
+
* half-open `start <= t < end` predicate and its src precedence — the same
|
|
550
|
+
* answers `sample_frame` and (post-adoption) render give. Two details are
|
|
551
|
+
* resolved here rather than in the resolver:
|
|
552
|
+
*
|
|
553
|
+
* - **Which track-0 video item wins** when two overlap. `resolveAt` returns
|
|
554
|
+
* them in document order; the legacy hook picks the first in START order.
|
|
555
|
+
* The earliest-start rule is reproduced so an overlapping pair resolves the
|
|
556
|
+
* same way it does today.
|
|
557
|
+
* - **`opaque`** is read off any active OVERLAY item on any track, matching
|
|
558
|
+
* render's `overlays.some(o => o.opaque)` (`segment-plan.js`). Track-0
|
|
559
|
+
* videos and images never carry it.
|
|
560
|
+
*/
|
|
561
|
+
export function planTick(
|
|
562
|
+
project: Project,
|
|
563
|
+
t: number,
|
|
564
|
+
clips: readonly VisualItem[],
|
|
565
|
+
resolver: SceneResolver = previewResolver,
|
|
566
|
+
): TickPlan {
|
|
567
|
+
const scene = resolver(project, t)
|
|
568
|
+
|
|
569
|
+
let active: ActiveClip | null = null
|
|
570
|
+
let opaque = false
|
|
571
|
+
for (const resolved of scene.items) {
|
|
572
|
+
if (resolved.kind === 'overlay' && resolved.item.opaque === true) opaque = true
|
|
573
|
+
if (resolved.trackIdx !== 0 || resolved.kind !== 'video' || !resolved.window) continue
|
|
574
|
+
// `ResolvedItem.item` is the project's own object by reference (documented
|
|
575
|
+
// in timeline-core's `ResolvedItem`), so this recovers the editor-side
|
|
576
|
+
// fields (`loop`, `volume`, `muted`) the resolver's structural view omits.
|
|
577
|
+
const item = resolved.item as unknown as VisualItem
|
|
578
|
+
if (active && (active.item.start ?? 0) <= (item.start ?? 0)) continue
|
|
579
|
+
const usable = engineSrcFor(item, resolved.window)
|
|
580
|
+
active = {
|
|
581
|
+
item,
|
|
582
|
+
clipId: item.id,
|
|
583
|
+
src: usable.src,
|
|
584
|
+
blocked: usable.blocked,
|
|
585
|
+
window: resolved.window,
|
|
586
|
+
placement: placeInSource(item, resolved.window, t),
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
let next: TickPlan['next'] = null
|
|
591
|
+
for (const clip of clips) {
|
|
592
|
+
if (clip.start > t) {
|
|
593
|
+
next = { item: clip, clipId: clip.id, start: clip.start }
|
|
594
|
+
break
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// `clips` is start-sorted, so the LAST one starting before `t` that is not
|
|
599
|
+
// the active clip is the one immediately behind it. Excluding the active clip
|
|
600
|
+
// by id matters when clips overlap: `active` resolves to the latest start
|
|
601
|
+
// among the overlapping set, and without the check `prev` would name that
|
|
602
|
+
// same clip and retain nothing extra.
|
|
603
|
+
let prev: TickPlan['prev'] = null
|
|
604
|
+
for (const clip of clips) {
|
|
605
|
+
if (clip.start >= t) break
|
|
606
|
+
if (active && clip.id === active.clipId) continue
|
|
607
|
+
prev = { item: clip, clipId: clip.id, start: clip.start }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return { t, active, opaque, next, prev, canvas: clips.length === 0 }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── Injected async surfaces ─────────────────────────────────────────────────
|
|
614
|
+
|
|
615
|
+
/** One clip's live decode session: a frame server for its src plus its own clock. */
|
|
616
|
+
export interface ClipSource {
|
|
617
|
+
clipId: string
|
|
618
|
+
src: string
|
|
619
|
+
frameServer: FrameServer
|
|
620
|
+
clock: MasterClock
|
|
621
|
+
timebase: ClipTimebase
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export type SourceState =
|
|
625
|
+
| { status: 'idle' }
|
|
626
|
+
| { status: 'loading' }
|
|
627
|
+
| { status: 'ready'; source: ClipSource }
|
|
628
|
+
| { status: 'failed'; reason: string }
|
|
629
|
+
|
|
630
|
+
/** What the scheduler asks the host to have ready. */
|
|
631
|
+
export interface SourceRequest {
|
|
632
|
+
clipId: string
|
|
633
|
+
/**
|
|
634
|
+
* The clip, with its track's volume/mute already folded into its own
|
|
635
|
+
* (`withTrackAudio`) — a derived object, not the project's item. The host
|
|
636
|
+
* reads `volume`/`muted` off it as the EFFECTIVE values and never needs to
|
|
637
|
+
* know a track was involved.
|
|
638
|
+
*/
|
|
639
|
+
item: VisualItem
|
|
640
|
+
/** The resolved preview src — the host does not re-derive it. */
|
|
641
|
+
src: string
|
|
642
|
+
/** Project time this clip's clock should be anchored at when it is built. */
|
|
643
|
+
anchorProjectS: number
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The async half of the engine, injected.
|
|
648
|
+
*
|
|
649
|
+
* `retain` is the ONLY lifecycle call, deliberately: the scheduler declares the
|
|
650
|
+
* exact set of clips whose decode sessions must exist right now (the active one
|
|
651
|
+
* plus, inside the prewarm lead, the next one) and the host reconciles. Anything
|
|
652
|
+
* not in the set is disposed — that is the spike's `load` rule
|
|
653
|
+
* (`spikes/playback-engine/src/player.ts`: terminate the worker and spawn a new
|
|
654
|
+
* one, never `reset()` a live decoder and never `configure()` it again) stated
|
|
655
|
+
* as a set difference rather than as a sequence of imperative teardown calls
|
|
656
|
+
* the scheduler could get out of order.
|
|
657
|
+
*/
|
|
658
|
+
export interface SourceHost {
|
|
659
|
+
/**
|
|
660
|
+
* Reconcile live sessions to exactly `requests`. Idempotent; safe every tick.
|
|
661
|
+
*
|
|
662
|
+
* CONTRACT: never call `Scheduler.sourceChanged` synchronously from inside
|
|
663
|
+
* this method. A session's readiness is an async fact by nature (a fetch, a
|
|
664
|
+
* demux, an `AudioWorklet` module load), and the scheduler reads `state()`
|
|
665
|
+
* immediately after this call within the same tick — a re-entrant notify
|
|
666
|
+
* would land mid-decision. `index.ts`'s host satisfies this by construction:
|
|
667
|
+
* every notify sits behind at least one `await`.
|
|
668
|
+
*/
|
|
669
|
+
retain(requests: readonly SourceRequest[]): void
|
|
670
|
+
state(clipId: string): SourceState
|
|
671
|
+
/**
|
|
672
|
+
* A wall-clock `MasterClock` anchored at `startProjectS`, for every stretch
|
|
673
|
+
* with no clip audio to derive time from: gaps, canvas projects, and clips
|
|
674
|
+
* stuck in `preparing`. The scheduler disposes what it is handed.
|
|
675
|
+
*/
|
|
676
|
+
fallbackClock(startProjectS: number): MasterClock
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ── Status ──────────────────────────────────────────────────────────────────
|
|
680
|
+
|
|
681
|
+
export type Transport = 'idle' | 'paused' | 'playing' | 'ended'
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* What the canvas shows.
|
|
685
|
+
* - `video` — painting decoded frames.
|
|
686
|
+
* - `black` — nothing on track 0 (gap, canvas project, past the last clip).
|
|
687
|
+
* The legacy `showVideo === false`.
|
|
688
|
+
* - `opaque` — an opaque overlay replaces the picture; audio keeps running.
|
|
689
|
+
* - `preparing` — the active clip has no usable media (proxy still encoding,
|
|
690
|
+
* load failed, decode failed). T7 draws "Preparing preview…".
|
|
691
|
+
*/
|
|
692
|
+
export type Picture = 'video' | 'black' | 'opaque' | 'preparing'
|
|
693
|
+
|
|
694
|
+
export interface EngineStatus {
|
|
695
|
+
transport: Transport
|
|
696
|
+
picture: Picture
|
|
697
|
+
/** The track-0 clip that owns the picture at the current instant, or `null`. */
|
|
698
|
+
clipId: string | null
|
|
699
|
+
/** Set when `picture === 'preparing'`: why. */
|
|
700
|
+
reason?: string
|
|
701
|
+
/** True between a paused seek and the frame it asked for landing on the canvas. */
|
|
702
|
+
seeking: boolean
|
|
703
|
+
/** Which clock is driving time — for T7's HUD. */
|
|
704
|
+
clock: 'audio' | 'fallback'
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export interface SchedulerDeps {
|
|
708
|
+
project: Project
|
|
709
|
+
host: SourceHost
|
|
710
|
+
painter?: Painter | null
|
|
711
|
+
resolver?: SceneResolver
|
|
712
|
+
/** Playhead, every tick. T6 bridges this to the editor's `clock.set`. */
|
|
713
|
+
onTime?: (projectS: number) => void
|
|
714
|
+
/** Called only when the status actually changes, never per tick. */
|
|
715
|
+
onStatusChange?: (status: EngineStatus) => void
|
|
716
|
+
onError?: (message: string) => void
|
|
717
|
+
/** Boundary−N seconds. Defaults to {@link PREWARM_LEAD_S}. */
|
|
718
|
+
prewarmLeadS?: number
|
|
719
|
+
/** Where playback starts. Defaults to 0. */
|
|
720
|
+
startProjectS?: number
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export interface Scheduler {
|
|
724
|
+
/** Bind (or unbind) the canvas. Re-paints the current frame when one is due. */
|
|
725
|
+
attach(painter: Painter | null): void
|
|
726
|
+
play(): void
|
|
727
|
+
pause(): void
|
|
728
|
+
/** External scrub. Continues playing if it was playing (the legacy scrub effect's contract). */
|
|
729
|
+
seek(projectS: number): void
|
|
730
|
+
/**
|
|
731
|
+
* Set the live transport rate R (default 1). Applied to the active clock now
|
|
732
|
+
* and to every session built afterward; per-clip `speed` is a rebuild edit
|
|
733
|
+
* (`setProject`), not this. End-of-project and seek read time off the clock,
|
|
734
|
+
* so they inherit R automatically.
|
|
735
|
+
*/
|
|
736
|
+
setRate(rate: number): void
|
|
737
|
+
/** One rAF step. No-op while `idle`; the facade pumps this only while `playing`. */
|
|
738
|
+
tick(): void
|
|
739
|
+
/** Project edits. Does NOT re-evaluate engine eligibility — that policy is T6's. */
|
|
740
|
+
setProject(project: Project): void
|
|
741
|
+
/** The host calls this when a clip's load resolves or fails. */
|
|
742
|
+
sourceChanged(clipId: string): void
|
|
743
|
+
status(): EngineStatus
|
|
744
|
+
/** Current playhead in project seconds. */
|
|
745
|
+
now(): number
|
|
746
|
+
dispose(): void
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ── The machine ─────────────────────────────────────────────────────────────
|
|
750
|
+
|
|
751
|
+
/** What the previous `apply` settled on — the change detector for the next one. */
|
|
752
|
+
interface AppliedSnapshot {
|
|
753
|
+
t: number
|
|
754
|
+
clipId: string | null
|
|
755
|
+
status: SourceState['status']
|
|
756
|
+
picture: Picture
|
|
757
|
+
wraps: number
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
class SchedulerImpl implements Scheduler {
|
|
761
|
+
private project: Project
|
|
762
|
+
private readonly host: SourceHost
|
|
763
|
+
private readonly resolver: SceneResolver
|
|
764
|
+
private readonly onTime?: (projectS: number) => void
|
|
765
|
+
private readonly onStatusChange?: (status: EngineStatus) => void
|
|
766
|
+
private readonly onError?: (message: string) => void
|
|
767
|
+
private readonly prewarmLeadS: number
|
|
768
|
+
|
|
769
|
+
private painter: Painter | null
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Always non-null. Ours to dispose when `clockOwner === null` (a wall-clock
|
|
773
|
+
* fallback we asked the host for); the host's otherwise (it lives and dies
|
|
774
|
+
* with the clip's decode session).
|
|
775
|
+
*/
|
|
776
|
+
private clock: MasterClock
|
|
777
|
+
private clockOwner: string | null = null
|
|
778
|
+
/** Identity of the session the clock came from — catches a rebuild under the same clip id. */
|
|
779
|
+
private clockSource: ClipSource | null = null
|
|
780
|
+
/**
|
|
781
|
+
* Live transport rate R (default 1). Pushed onto the active clock by `setRate`
|
|
782
|
+
* and re-applied to every clock the ownership swap adopts, so a rate set
|
|
783
|
+
* mid-clip survives boundaries, gaps and rebuilds.
|
|
784
|
+
*/
|
|
785
|
+
private transportRate = 1
|
|
786
|
+
|
|
787
|
+
private transport: Transport = 'idle'
|
|
788
|
+
private picture: Picture = 'black'
|
|
789
|
+
private pictureReason: string | undefined
|
|
790
|
+
private clipId: string | null = null
|
|
791
|
+
/** The session with a streaming decode-ahead session open, if any. */
|
|
792
|
+
private streamingSource: ClipSource | null = null
|
|
793
|
+
/** Bumped on every seek / boundary / dispose; a resolved seek frame paints only if it still matches. */
|
|
794
|
+
private seekGen = 0
|
|
795
|
+
private pendingSeeks = 0
|
|
796
|
+
/**
|
|
797
|
+
* `clipId@mediaUs` of the frame the canvas holds (or has been asked for)
|
|
798
|
+
* while PAUSED. The dedupe key that keeps a project edit from re-seeking the
|
|
799
|
+
* decoder for a picture that has not moved. `null` whenever the canvas
|
|
800
|
+
* content is unknown — after a clear, during playback, or on a fresh attach.
|
|
801
|
+
*/
|
|
802
|
+
private paintedKey: string | null = null
|
|
803
|
+
/** The looping clip the last applied tick was inside — the loop-end stop needs it after it goes inactive. */
|
|
804
|
+
private lastLoopClip: ActiveClip | null = null
|
|
805
|
+
|
|
806
|
+
private clips: VisualItem[]
|
|
807
|
+
/** The track `clips` came from — its `volume`/`muted` fold into each request. */
|
|
808
|
+
private clipsTrack: VisualTrack | undefined
|
|
809
|
+
private transportEnd: number
|
|
810
|
+
private applied: AppliedSnapshot | null = null
|
|
811
|
+
private lastStatusKey = ''
|
|
812
|
+
private disposed = false
|
|
813
|
+
|
|
814
|
+
constructor(deps: SchedulerDeps) {
|
|
815
|
+
this.project = deps.project
|
|
816
|
+
this.host = deps.host
|
|
817
|
+
this.resolver = deps.resolver ?? previewResolver
|
|
818
|
+
this.onTime = deps.onTime
|
|
819
|
+
this.onStatusChange = deps.onStatusChange
|
|
820
|
+
this.onError = deps.onError
|
|
821
|
+
this.prewarmLeadS = deps.prewarmLeadS ?? PREWARM_LEAD_S
|
|
822
|
+
this.painter = deps.painter ?? null
|
|
823
|
+
this.clips = track0VideoItems(deps.project)
|
|
824
|
+
this.clipsTrack = track0Track(deps.project)
|
|
825
|
+
this.transportEnd = transportEndFor(deps.project)
|
|
826
|
+
this.clock = this.host.fallbackClock(deps.startProjectS ?? 0)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ── public surface ────────────────────────────────────────────────────────
|
|
830
|
+
|
|
831
|
+
attach(painter: Painter | null): void {
|
|
832
|
+
this.painter = painter
|
|
833
|
+
// A canvas that just appeared is blank, whatever was on the last one.
|
|
834
|
+
this.paintedKey = null
|
|
835
|
+
if (this.disposed) return
|
|
836
|
+
// Re-establish the current frame. While playing the next tick does it for free.
|
|
837
|
+
if (painter && this.transport !== 'playing') this.apply(this.clock.now(), { force: true })
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
play(): void {
|
|
841
|
+
if (this.disposed) return
|
|
842
|
+
const t = this.clock.now()
|
|
843
|
+
// Legacy `togglePlay`, parked at or past the end: the gap branch finds no
|
|
844
|
+
// next clip, falls through its `if (t < projectEnd)` guard and RETURNS —
|
|
845
|
+
// pressing play at the end of a project does nothing at all. Reproduced so
|
|
846
|
+
// the button can't fake a running transport with nowhere to run.
|
|
847
|
+
if (t >= this.transportEnd) {
|
|
848
|
+
this.transport = 'ended'
|
|
849
|
+
this.publish()
|
|
850
|
+
return
|
|
851
|
+
}
|
|
852
|
+
this.transport = 'playing'
|
|
853
|
+
this.clock.play()
|
|
854
|
+
this.apply(t, { force: true })
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
pause(): void {
|
|
858
|
+
if (this.disposed) return
|
|
859
|
+
if (this.transport === 'playing') this.transport = 'paused'
|
|
860
|
+
this.clock.pause()
|
|
861
|
+
this.stopStream()
|
|
862
|
+
this.publish()
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
seek(projectS: number): void {
|
|
866
|
+
if (this.disposed) return
|
|
867
|
+
const t = Math.max(0, projectS)
|
|
868
|
+
if (t >= this.transportEnd) {
|
|
869
|
+
// Scrubbed past the end. Legacy: the scrub effect's no-next-clip branch
|
|
870
|
+
// pauses the active video and calls `setIsPlaying(false)` — "the picture
|
|
871
|
+
// goes dark but its audio keeps going" is the bug that comment describes,
|
|
872
|
+
// and stopping outright is its fix.
|
|
873
|
+
this.clock.pause()
|
|
874
|
+
this.stopStream()
|
|
875
|
+
this.transport = 'ended'
|
|
876
|
+
} else if (this.transport === 'ended') {
|
|
877
|
+
this.transport = 'paused'
|
|
878
|
+
}
|
|
879
|
+
this.apply(t, { force: true, seeked: true })
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
setRate(rate: number): void {
|
|
883
|
+
if (this.disposed) return
|
|
884
|
+
// Idempotent: the shuttle calls setRate(1) on stop/reverse, and re-anchoring
|
|
885
|
+
// (worse, flushing) the active clock for an unchanged rate is pure churn.
|
|
886
|
+
if (rate === this.transportRate) return
|
|
887
|
+
this.transportRate = rate
|
|
888
|
+
// The active clock takes it immediately (re-anchor + ring flush — no jump);
|
|
889
|
+
// clocks built afterward pick it up in `apply`'s ownership swap.
|
|
890
|
+
this.clock.setTransportRate(rate)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
tick(): void {
|
|
894
|
+
if (this.disposed || this.transport === 'idle') return
|
|
895
|
+
const t = this.clock.now()
|
|
896
|
+
|
|
897
|
+
if (this.transport === 'playing' && t >= this.transportEnd) {
|
|
898
|
+
// End of project. Legacy arrives here two ways and both land on the same
|
|
899
|
+
// number: the gap clock's `t >= gapTargetRef` tail with `gapTargetRef =
|
|
900
|
+
// projectEnd` (trailing overlays and audio having played out — the
|
|
901
|
+
// "continue advancing time via the gap clock" branch of the last-clip
|
|
902
|
+
// case), and the canvas rAF's `next >= maxEnd` clamp. Both emit the
|
|
903
|
+
// ceiling exactly, then stop.
|
|
904
|
+
this.clock.pause()
|
|
905
|
+
this.clock.seek(this.transportEnd)
|
|
906
|
+
this.transport = 'ended'
|
|
907
|
+
this.apply(this.transportEnd, { force: true })
|
|
908
|
+
return
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
this.apply(t, {})
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
setProject(project: Project): void {
|
|
915
|
+
if (this.disposed) return
|
|
916
|
+
this.project = project
|
|
917
|
+
this.clips = track0VideoItems(project)
|
|
918
|
+
this.clipsTrack = track0Track(project)
|
|
919
|
+
this.transportEnd = transportEndFor(project)
|
|
920
|
+
// This is how a `preparing` clip resolves: the SSE that delivered
|
|
921
|
+
// `proxySrc` produced a new project object, `retain` reports a different
|
|
922
|
+
// `src` for the same clip id, and the host rebuilds that ONE session. No
|
|
923
|
+
// mode change and no reload — the engine never reverts a running project to
|
|
924
|
+
// the legacy player because one clip's media was late.
|
|
925
|
+
//
|
|
926
|
+
// Engine ELIGIBILITY is deliberately not re-evaluated here (plan decision
|
|
927
|
+
// 2: evaluated once per project-load, no mid-session mode-flapping). That
|
|
928
|
+
// policy, including "initially-ineligible stays legacy for the session",
|
|
929
|
+
// belongs to T6.
|
|
930
|
+
this.apply(this.clock.now(), { force: true })
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
sourceChanged(clipId: string): void {
|
|
934
|
+
if (this.disposed) return
|
|
935
|
+
// Only the clip currently on screen can change what is painted; a prewarmed
|
|
936
|
+
// clip landing early changes nothing until its boundary arrives.
|
|
937
|
+
if (clipId !== this.clipId) return
|
|
938
|
+
this.apply(this.clock.now(), { force: true })
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
status(): EngineStatus {
|
|
942
|
+
return {
|
|
943
|
+
transport: this.transport,
|
|
944
|
+
picture: this.picture,
|
|
945
|
+
clipId: this.clipId,
|
|
946
|
+
reason: this.pictureReason,
|
|
947
|
+
seeking: this.pendingSeeks > 0,
|
|
948
|
+
clock: this.clock.kind,
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
now(): number {
|
|
953
|
+
return this.clock.now()
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
dispose(): void {
|
|
957
|
+
if (this.disposed) return
|
|
958
|
+
this.disposed = true
|
|
959
|
+
this.seekGen++
|
|
960
|
+
this.stopStream()
|
|
961
|
+
if (this.clockOwner === null) this.clock.dispose()
|
|
962
|
+
this.clockOwner = null
|
|
963
|
+
this.clockSource = null
|
|
964
|
+
this.host.retain([])
|
|
965
|
+
this.painter = null
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// ── the tick ──────────────────────────────────────────────────────────────
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* The single decision point. Everything above funnels here with a project
|
|
972
|
+
* time and a reason; nothing below it reads a clock.
|
|
973
|
+
*
|
|
974
|
+
* `force` re-runs the side effects (repaint, clear) even when nothing the
|
|
975
|
+
* snapshot tracks moved — used by the four events whose entire point is that
|
|
976
|
+
* something OUTSIDE the snapshot did: `seek`, `setProject`, `attach`,
|
|
977
|
+
* `sourceChanged`. It deliberately does NOT restart a live decode stream;
|
|
978
|
+
* only a genuine discontinuity (`seeked`, a boundary, a loop wrap, a rebuilt
|
|
979
|
+
* session) does that, so a project edit during playback — an overlay drag
|
|
980
|
+
* spreads the project object every frame — cannot stutter the picture.
|
|
981
|
+
*/
|
|
982
|
+
private apply(t: number, opts: { force?: boolean; seeked?: boolean }): void {
|
|
983
|
+
if (this.disposed) return
|
|
984
|
+
if (this.transport === 'idle') this.transport = 'paused'
|
|
985
|
+
|
|
986
|
+
const plan = planTick(this.project, t, this.clips, this.resolver)
|
|
987
|
+
const nextClipId = plan.active?.clipId ?? null
|
|
988
|
+
|
|
989
|
+
// ── 1. Loop-end stop — the legacy hook's THIRD loopOffset site ──────────
|
|
990
|
+
// A looping clip whose project window ends mid-loop stops the transport at
|
|
991
|
+
// `clip.end` rather than advancing to whatever comes next. Detected on the
|
|
992
|
+
// tick that leaves the clip, since `resolveAt` is half-open and simply
|
|
993
|
+
// stops returning it at `end`. See `endsOnLoopBoundary` for the other case.
|
|
994
|
+
const leaving = this.lastLoopClip
|
|
995
|
+
if (
|
|
996
|
+
leaving &&
|
|
997
|
+
this.transport === 'playing' &&
|
|
998
|
+
!opts.seeked &&
|
|
999
|
+
nextClipId !== leaving.clipId &&
|
|
1000
|
+
t >= (leaving.item.end ?? 0) &&
|
|
1001
|
+
!endsOnLoopBoundary(leaving.item, leaving.window)
|
|
1002
|
+
) {
|
|
1003
|
+
const stopAt = leaving.item.end ?? 0
|
|
1004
|
+
this.lastLoopClip = null
|
|
1005
|
+
this.clock.pause()
|
|
1006
|
+
this.clock.seek(stopAt)
|
|
1007
|
+
this.transport = 'paused'
|
|
1008
|
+
this.apply(stopAt, { force: true })
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// ── 2. Release the outgoing session BEFORE the host can dispose it ──────
|
|
1013
|
+
// `retain` below drops everything not in the new set, and a dropped session
|
|
1014
|
+
// takes its clock and its worker with it. Anything still pointing at the
|
|
1015
|
+
// outgoing session has to let go here, while it is definitely alive.
|
|
1016
|
+
if (nextClipId !== this.clipId) {
|
|
1017
|
+
this.stopStream()
|
|
1018
|
+
if (this.clockOwner !== null) this.clock.pause()
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ── 3. Declare the live-session set (prewarm lives here) ───────────────
|
|
1022
|
+
this.retainFor(plan, t)
|
|
1023
|
+
|
|
1024
|
+
const state: SourceState = plan.active
|
|
1025
|
+
? this.host.state(plan.active.clipId)
|
|
1026
|
+
: { status: 'idle' }
|
|
1027
|
+
const source = state.status === 'ready' ? state.source : null
|
|
1028
|
+
|
|
1029
|
+
// ── 4. Picture ─────────────────────────────────────────────────────────
|
|
1030
|
+
let picture: Picture
|
|
1031
|
+
let reason: string | undefined
|
|
1032
|
+
if (!plan.active) {
|
|
1033
|
+
picture = 'black'
|
|
1034
|
+
} else if (!source) {
|
|
1035
|
+
// ONE state for three causes — proxy not yet encoded, proxy failed to
|
|
1036
|
+
// load, proxy failed to DECODE mid-session. The plan is explicit that
|
|
1037
|
+
// they route identically: this clip's range shows the Preparing
|
|
1038
|
+
// placeholder while project time keeps advancing (gap semantics), and the
|
|
1039
|
+
// engine never hands the whole project back to the legacy player.
|
|
1040
|
+
picture = 'preparing'
|
|
1041
|
+
reason =
|
|
1042
|
+
state.status === 'failed'
|
|
1043
|
+
? state.reason
|
|
1044
|
+
: (plan.active.blocked ?? 'preparing preview')
|
|
1045
|
+
} else if (plan.opaque) {
|
|
1046
|
+
// Registry disposition: `opaque` unifies onto RENDER semantics now that
|
|
1047
|
+
// there is a compositing stage. The overlay replaces the picture; the
|
|
1048
|
+
// clip's audio — and therefore the clock derived from it — keeps running.
|
|
1049
|
+
picture = 'opaque'
|
|
1050
|
+
} else {
|
|
1051
|
+
picture = 'video'
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// ── 5. Clock ownership ─────────────────────────────────────────────────
|
|
1055
|
+
const owner = source ? plan.active!.clipId : null
|
|
1056
|
+
const ownerChanged = owner !== this.clockOwner || source !== this.clockSource
|
|
1057
|
+
if (ownerChanged) {
|
|
1058
|
+
if (this.clockOwner === null) this.clock.dispose()
|
|
1059
|
+
const wasPlaying = this.transport === 'playing'
|
|
1060
|
+
this.clock = source ? source.clock : this.host.fallbackClock(t)
|
|
1061
|
+
this.clockOwner = owner
|
|
1062
|
+
this.clockSource = source
|
|
1063
|
+
// Anchored at `t`, NOT at the incoming clip's `start`.
|
|
1064
|
+
//
|
|
1065
|
+
// Legacy snaps (`lastTimeRef.current = next.start; onTimeUpdate(next.start)`)
|
|
1066
|
+
// because on that path project time is DERIVED from the freshly loaded
|
|
1067
|
+
// <video>'s currentTime, so the playhead had to be told where the cut
|
|
1068
|
+
// was. Here project time is primary and media position is derived from
|
|
1069
|
+
// it, so there is nothing to snap — and anchoring at `t` removes the
|
|
1070
|
+
// ≤1-frame backwards step the snap introduces at every cut.
|
|
1071
|
+
this.clock.seek(t)
|
|
1072
|
+
if (wasPlaying) this.clock.play()
|
|
1073
|
+
// A freshly adopted clock defaults to 1×; carry the live transport rate
|
|
1074
|
+
// onto it so a rate set mid-clip survives the boundary/gap swap. Guarded
|
|
1075
|
+
// so R===1 stays a strict no-op (no spurious re-anchor on the default
|
|
1076
|
+
// path). Applied AFTER seek/play, whose `restartAt` re-bases the ramp.
|
|
1077
|
+
if (this.transportRate !== 1) this.clock.setTransportRate(this.transportRate)
|
|
1078
|
+
this.seekGen++
|
|
1079
|
+
} else if (opts.seeked) {
|
|
1080
|
+
this.clock.seek(t)
|
|
1081
|
+
this.seekGen++
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const clipChanged = nextClipId !== this.clipId
|
|
1085
|
+
this.clipId = nextClipId
|
|
1086
|
+
const pictureChanged = picture !== this.picture
|
|
1087
|
+
this.picture = picture
|
|
1088
|
+
this.pictureReason = reason
|
|
1089
|
+
|
|
1090
|
+
// ── 6. Media session ───────────────────────────────────────────────────
|
|
1091
|
+
if (source && plan.active) {
|
|
1092
|
+
const mediaUs = containerTsUsFor(
|
|
1093
|
+
source.frameServer.video.firstPresentationTsUs,
|
|
1094
|
+
plan.active.placement.mediaS,
|
|
1095
|
+
)
|
|
1096
|
+
const wrapped = (this.applied?.wraps ?? 0) !== plan.active.placement.wraps
|
|
1097
|
+
// A discontinuity in MEDIA time — the only thing a decode session cares
|
|
1098
|
+
// about. `force` is absent on purpose (see the method doc).
|
|
1099
|
+
const discontinuity = ownerChanged || clipChanged || wrapped || opts.seeked === true
|
|
1100
|
+
|
|
1101
|
+
if (this.transport === 'playing') {
|
|
1102
|
+
if (discontinuity || this.streamingSource !== source) {
|
|
1103
|
+
// Loop wrap: the media pointer jumps back to the window start while
|
|
1104
|
+
// project time keeps advancing — the legacy wrap site's
|
|
1105
|
+
// `video.currentTime = clipInPoint`, expressed as a fresh decode
|
|
1106
|
+
// session rather than a seek on a live one (the spike's rule).
|
|
1107
|
+
this.stopStream()
|
|
1108
|
+
source.frameServer.startStream(mediaUs)
|
|
1109
|
+
this.streamingSource = source
|
|
1110
|
+
// A wrap resets the PICTURE's media position to the loop window's
|
|
1111
|
+
// start while project time keeps climbing; the audio clock's ring
|
|
1112
|
+
// must restart at that same media position too, or it keeps
|
|
1113
|
+
// decoding from the pre-wrap mapping (`MasterClock.seek`'s `mediaS`
|
|
1114
|
+
// param exists exactly for this).
|
|
1115
|
+
if (wrapped) source.clock.seek(t, plan.active.placement.mediaS)
|
|
1116
|
+
}
|
|
1117
|
+
// The stream owns the canvas while playing; whatever a paused seek had
|
|
1118
|
+
// put there is long gone.
|
|
1119
|
+
this.paintedKey = null
|
|
1120
|
+
this.pullFrame(source, plan.active, mediaUs)
|
|
1121
|
+
} else {
|
|
1122
|
+
this.stopStream()
|
|
1123
|
+
// Repaint only when the canvas does not already hold this exact frame.
|
|
1124
|
+
// Without this guard every project spread — an overlay drag emits one
|
|
1125
|
+
// per pointer event — would fire a fresh decoder seek for a picture
|
|
1126
|
+
// that has not moved.
|
|
1127
|
+
const key = `${plan.active.clipId}@${Math.round(mediaUs)}`
|
|
1128
|
+
if (picture === 'video' && key !== this.paintedKey) {
|
|
1129
|
+
this.paintedKey = key
|
|
1130
|
+
this.paintFromSeek(source, plan.active, mediaUs)
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
} else {
|
|
1134
|
+
this.stopStream()
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// ── 7. Surface ─────────────────────────────────────────────────────────
|
|
1138
|
+
if (picture !== 'video' && (pictureChanged || opts.force)) {
|
|
1139
|
+
this.painter?.clear()
|
|
1140
|
+
this.paintedKey = null
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
this.lastLoopClip = plan.active?.placement.looping ? plan.active : null
|
|
1144
|
+
this.applied = {
|
|
1145
|
+
t,
|
|
1146
|
+
clipId: nextClipId,
|
|
1147
|
+
status: state.status,
|
|
1148
|
+
picture,
|
|
1149
|
+
wraps: plan.active?.placement.wraps ?? 0,
|
|
1150
|
+
}
|
|
1151
|
+
this.onTime?.(t)
|
|
1152
|
+
this.publish()
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Declare the live-session set: the active clip, the next one once its
|
|
1157
|
+
* boundary is inside the prewarm lead, and the previous one. Everything else
|
|
1158
|
+
* the host disposes — terminate-and-respawn stated as a set difference.
|
|
1159
|
+
*
|
|
1160
|
+
* The window is deliberately asymmetric. FORWARD retention is conditional on
|
|
1161
|
+
* `prewarmLeadS` because playback reaches the next clip on a schedule, so
|
|
1162
|
+
* there is a right moment to start; BACKWARD retention is unconditional
|
|
1163
|
+
* because a scrub back across the cut has no schedule at all — by the time
|
|
1164
|
+
* you could predict it, it has already happened, and predicting it wrongly is
|
|
1165
|
+
* exactly the rebuild this exists to avoid. Both extra sessions usually cost
|
|
1166
|
+
* nothing beyond a clock: `FrameServer` is refcounted by SRC, and on a
|
|
1167
|
+
* silence-trimmed timeline prev/active/next are three windows into ONE proxy.
|
|
1168
|
+
*
|
|
1169
|
+
* Every request's `item` carries its TRACK's volume/mute already folded in
|
|
1170
|
+
* (`withTrackAudio`). The plan's own items are untouched — only what crosses
|
|
1171
|
+
* into the host is derived — so the picture side keeps reading the project's
|
|
1172
|
+
* real objects.
|
|
1173
|
+
*/
|
|
1174
|
+
private retainFor(plan: TickPlan, t: number): void {
|
|
1175
|
+
const requests: SourceRequest[] = []
|
|
1176
|
+
if (plan.active && plan.active.src) {
|
|
1177
|
+
requests.push({
|
|
1178
|
+
clipId: plan.active.clipId,
|
|
1179
|
+
item: withTrackAudio(this.clipsTrack, plan.active.item),
|
|
1180
|
+
src: plan.active.src,
|
|
1181
|
+
anchorProjectS: t,
|
|
1182
|
+
})
|
|
1183
|
+
}
|
|
1184
|
+
if (plan.next && plan.next.start - t <= this.prewarmLeadS) {
|
|
1185
|
+
this.pushRetain(requests, plan.next)
|
|
1186
|
+
}
|
|
1187
|
+
if (plan.prev) {
|
|
1188
|
+
this.pushRetain(requests, plan.prev)
|
|
1189
|
+
}
|
|
1190
|
+
this.host.retain(requests)
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Add a non-active clip to the retained set, if it has an engine-usable src.
|
|
1195
|
+
*
|
|
1196
|
+
* The same proxy-only gate the active clip goes through, over the same
|
|
1197
|
+
* timeline-core window the resolver itself computes — never a second,
|
|
1198
|
+
* drifting copy of the precedence chain.
|
|
1199
|
+
*/
|
|
1200
|
+
private pushRetain(
|
|
1201
|
+
requests: SourceRequest[],
|
|
1202
|
+
clip: { item: VisualItem; clipId: string; start: number },
|
|
1203
|
+
): void {
|
|
1204
|
+
const { src } = engineSrcFor(clip.item, sourceWindow(clip.item, 'preview'))
|
|
1205
|
+
if (!src) return
|
|
1206
|
+
requests.push({
|
|
1207
|
+
clipId: clip.clipId,
|
|
1208
|
+
item: withTrackAudio(this.clipsTrack, clip.item),
|
|
1209
|
+
src,
|
|
1210
|
+
anchorProjectS: clip.start,
|
|
1211
|
+
})
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Playback path: paint whatever frame is due at the media clock. */
|
|
1215
|
+
private pullFrame(source: ClipSource, active: ActiveClip, mediaUs: number): void {
|
|
1216
|
+
const { frame } = source.frameServer.nextFrameFor(mediaUs)
|
|
1217
|
+
if (!frame) return
|
|
1218
|
+
// Under an opaque overlay the frame is still PULLED, then closed unpainted.
|
|
1219
|
+
// Not painting is the point; not pulling would let the decode-ahead buffer
|
|
1220
|
+
// fill, stall the pipeline behind it, and leave a stale frame to slam onto
|
|
1221
|
+
// the canvas the instant the overlay ends.
|
|
1222
|
+
if (this.picture !== 'video' || !this.painter) {
|
|
1223
|
+
frame.close()
|
|
1224
|
+
return
|
|
1225
|
+
}
|
|
1226
|
+
this.paintFrame(frame, source, active)
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** Paused path: one frame-accurate seek, painted when it lands. */
|
|
1230
|
+
private paintFromSeek(source: ClipSource, active: ActiveClip, mediaUs: number): void {
|
|
1231
|
+
if (!this.painter) return
|
|
1232
|
+
const gen = this.seekGen
|
|
1233
|
+
this.pendingSeeks++
|
|
1234
|
+
const { frame } = source.frameServer.seek(mediaUs)
|
|
1235
|
+
void frame.then((f) => {
|
|
1236
|
+
this.pendingSeeks--
|
|
1237
|
+
if (!f) {
|
|
1238
|
+
// Nothing landed on the canvas, so the dedupe key must not claim it did
|
|
1239
|
+
// — otherwise a retry (the source rebuilt, the clip re-entered) would be
|
|
1240
|
+
// suppressed forever.
|
|
1241
|
+
if (gen === this.seekGen) this.paintedKey = null
|
|
1242
|
+
this.publish()
|
|
1243
|
+
return
|
|
1244
|
+
}
|
|
1245
|
+
// Superseded by a newer scrub, a boundary or a dispose: the frame is ours
|
|
1246
|
+
// to close and must not reach the canvas.
|
|
1247
|
+
if (this.disposed || gen !== this.seekGen || !this.painter || this.picture !== 'video') {
|
|
1248
|
+
f.close()
|
|
1249
|
+
this.publish()
|
|
1250
|
+
return
|
|
1251
|
+
}
|
|
1252
|
+
this.paintFrame(f, source, active)
|
|
1253
|
+
this.publish()
|
|
1254
|
+
})
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
private paintFrame(frame: VideoFrame, source: ClipSource, active: ActiveClip): void {
|
|
1258
|
+
const painter = this.painter
|
|
1259
|
+
if (!painter) {
|
|
1260
|
+
frame.close()
|
|
1261
|
+
return
|
|
1262
|
+
}
|
|
1263
|
+
try {
|
|
1264
|
+
const size = painter.size()
|
|
1265
|
+
// `drawImage` reads a VideoFrame in its DISPLAY coordinates (pixel aspect
|
|
1266
|
+
// applied); the track's `coded` dims are the fallback for a frame that
|
|
1267
|
+
// does not report them.
|
|
1268
|
+
const w = frame.displayWidth || source.frameServer.video.coded.width
|
|
1269
|
+
const h = frame.displayHeight || source.frameServer.video.coded.height
|
|
1270
|
+
painter.paint(frame, drawPlanFor(active.item, w, h, size.width, size.height))
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
this.onError?.(`paint: ${err instanceof Error ? err.message : String(err)}`)
|
|
1273
|
+
} finally {
|
|
1274
|
+
// The frame-server contract: whoever receives a frame closes it. Holding
|
|
1275
|
+
// one past the paint exhausts the decoder's surface pool.
|
|
1276
|
+
frame.close()
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
private stopStream(): void {
|
|
1281
|
+
const source = this.streamingSource
|
|
1282
|
+
if (!source) return
|
|
1283
|
+
this.streamingSource = null
|
|
1284
|
+
source.frameServer.stopStream()
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
private publish(): void {
|
|
1288
|
+
const status = this.status()
|
|
1289
|
+
const key = [
|
|
1290
|
+
status.transport,
|
|
1291
|
+
status.picture,
|
|
1292
|
+
status.clipId,
|
|
1293
|
+
status.reason ?? '',
|
|
1294
|
+
status.seeking,
|
|
1295
|
+
status.clock,
|
|
1296
|
+
].join('|')
|
|
1297
|
+
if (key === this.lastStatusKey) return
|
|
1298
|
+
this.lastStatusKey = key
|
|
1299
|
+
this.onStatusChange?.(status)
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Media position (seconds inside the loaded src) → container µs.
|
|
1305
|
+
*
|
|
1306
|
+
* The loop-aware counterpart of `audio-clock.ts`'s `mediaTsUsForProjectTime`,
|
|
1307
|
+
* which folds the project→media mapping and the track origin together and is
|
|
1308
|
+
* therefore only linear for a non-looping clip. `placeInSource` has already
|
|
1309
|
+
* done the (possibly wrapped) project→media step, so all that is left here is
|
|
1310
|
+
* the track's own t=0 origin.
|
|
1311
|
+
*
|
|
1312
|
+
* The origin passed in is the VIDEO track's (`frameServer.video
|
|
1313
|
+
* .firstPresentationTsUs`), never the clock's — `ClipTimebase
|
|
1314
|
+
* .firstPresentationTsUs` is the AUDIO track's origin, and demux is explicit
|
|
1315
|
+
* that the two tracks of one file need not share one. Crossing them would
|
|
1316
|
+
* offset every painted frame by their difference.
|
|
1317
|
+
*/
|
|
1318
|
+
function containerTsUsFor(videoFirstPresentationTsUs: number, mediaS: number): number {
|
|
1319
|
+
return videoFirstPresentationTsUs + mediaS * 1_000_000
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
export function createScheduler(deps: SchedulerDeps): Scheduler {
|
|
1323
|
+
return new SchedulerImpl(deps)
|
|
1324
|
+
}
|