@bycrux/editor 0.12.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fade-envelope shapes (Vegas Pro's first three: linear, logarithmic,
|
|
3
|
+
* exponential) — the pure math shared by three consumers that must never
|
|
4
|
+
* disagree about what a given shape sounds/looks like: the envelope curve
|
|
5
|
+
* drawn on an audio bar (`draw.ts`'s `drawFadeEnvelope`), the waveform's own
|
|
6
|
+
* amplitude scaling under a fade (`waveforms.ts`'s `drawWaveformBars`), and
|
|
7
|
+
* the rendered mix (`mix-audio.js`'s ffmpeg `afade` filter, via a name
|
|
8
|
+
* mapping kept in that file since ffmpeg's vocabulary — `tri`/`exp`/`log` —
|
|
9
|
+
* is render-only and has no reason to leak into the editor).
|
|
10
|
+
*
|
|
11
|
+
* `p` is always position ACROSS THE FADE, 0 at the silent edge (the clip's
|
|
12
|
+
* own start for a fade-in, its own end for a fade-out) and 1 at the fade's
|
|
13
|
+
* full-volume inner edge — never raw pixels or seconds, so one function
|
|
14
|
+
* serves both a fade-in and a fade-out by construction.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type FadeCurve = 'linear' | 'log' | 'exp'
|
|
18
|
+
|
|
19
|
+
/** Exponential was the only shape before curves existed (`drawFadeEnvelope`'s
|
|
20
|
+
* quadratic ease) — keeping it the default means every project on disk today
|
|
21
|
+
* looks exactly as it did until an operator picks a different shape. */
|
|
22
|
+
export const DEFAULT_FADE_CURVE: FadeCurve = 'exp'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Gain at position `p` (0 = silent edge, 1 = full-volume edge), for one of
|
|
26
|
+
* the three shapes. Clamped to [0, 1] first so a caller doesn't have to —
|
|
27
|
+
* every consumer here computes `p` from a division that can overshoot by a
|
|
28
|
+
* hair at the exact edges.
|
|
29
|
+
*
|
|
30
|
+
* - `linear`: a straight ramp — Vegas' "Linear".
|
|
31
|
+
* - `exp`: `t²` — slow to leave the silent edge, fast into the full-volume
|
|
32
|
+
* one (concave up). This is the shape the old single quadratic ease
|
|
33
|
+
* approximated, so it is the DEFAULT (above) rather than `linear`.
|
|
34
|
+
* - `log`: `t(2-t)` — the mirror of `exp`, fast to leave silence and slow
|
|
35
|
+
* into full volume (concave down). Vegas' "Fast" logarithmic curve family;
|
|
36
|
+
* picked over a true `log()` curve (which blows up at t=0) for the same
|
|
37
|
+
* reason `exp` uses `t²` rather than an exponential blowing up at t=1 —
|
|
38
|
+
* both need to be exactly 0 and 1 at their endpoints with no asymptote to
|
|
39
|
+
* clip.
|
|
40
|
+
*/
|
|
41
|
+
export function fadeGain(p: number, curve: FadeCurve): number {
|
|
42
|
+
const t = p < 0 ? 0 : p > 1 ? 1 : p
|
|
43
|
+
if (curve === 'linear') return t
|
|
44
|
+
if (curve === 'exp') return t * t
|
|
45
|
+
return t * (2 - t)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A gain-at-surface-x function for one audio bar, folding both fade regions
|
|
50
|
+
* (each with its own curve) into one lookup — the shape `drawWaveformBars`
|
|
51
|
+
* and `drawFadeEnvelope`'s sampling both want: call it with a column's x and
|
|
52
|
+
* get back how much amplitude survives there.
|
|
53
|
+
*
|
|
54
|
+
* `spanX`/`spanWidth` are the bar's TRUE (unclamped) span in surface x —
|
|
55
|
+
* same contract as `AudioItemDrawArgs.fadeSpanX`/`fadeSpanWidth` in draw.ts —
|
|
56
|
+
* so the returned function keeps working under horizontal scroll without the
|
|
57
|
+
* caller re-deriving anything.
|
|
58
|
+
*
|
|
59
|
+
* Where the two fades overlap (a bar shorter than fadeInPx + fadeOutPx), the
|
|
60
|
+
* lower of the two gains wins — `Math.min` — matching the clamp `applyAudioFade`
|
|
61
|
+
* already enforces (fadeIn + fadeOut never exceeds the bar's own duration) but
|
|
62
|
+
* defensively correct even if that invariant is ever violated on disk.
|
|
63
|
+
*/
|
|
64
|
+
export function makeFadeGainAt(
|
|
65
|
+
spanX: number,
|
|
66
|
+
spanWidth: number,
|
|
67
|
+
fadeInPx: number,
|
|
68
|
+
fadeOutPx: number,
|
|
69
|
+
inCurve: FadeCurve,
|
|
70
|
+
outCurve: FadeCurve,
|
|
71
|
+
): (x: number) => number {
|
|
72
|
+
return (x: number) => {
|
|
73
|
+
const local = x - spanX
|
|
74
|
+
let g = 1
|
|
75
|
+
if (fadeInPx > 0 && local < fadeInPx) g = Math.min(g, fadeGain(local / fadeInPx, inCurve))
|
|
76
|
+
if (fadeOutPx > 0 && local > spanWidth - fadeOutPx) {
|
|
77
|
+
g = Math.min(g, fadeGain((spanWidth - local) / fadeOutPx, outCurve))
|
|
78
|
+
}
|
|
79
|
+
return g < 0 ? 0 : g > 1 ? 1 : g
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One point of a curve's icon polyline, in SVG-style coordinates (y grows
|
|
84
|
+
* DOWNWARD) — see `fadeCurveIconPoints`. */
|
|
85
|
+
export interface FadeCurveIconPoint {
|
|
86
|
+
x: number
|
|
87
|
+
y: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Sampled points for a small ICON depicting `curve`'s shape — the fade-shape
|
|
92
|
+
* picker (Timeline.tsx) draws these as an inline SVG polyline so each menu
|
|
93
|
+
* option shows what the shape actually looks like instead of naming it.
|
|
94
|
+
* Reuses `fadeGain` directly (the SAME function the real envelope and
|
|
95
|
+
* waveform scaling sample), so the icon is a faithful miniature of the
|
|
96
|
+
* actual curve, never a decorative approximation drawn by hand.
|
|
97
|
+
*
|
|
98
|
+
* `p` (0→1) maps to `x` (0→`width`, left→right); gain (0→1) maps to `y`
|
|
99
|
+
* (`height`→0, bottom→top) — SVG's y-axis grows downward, so full gain (1)
|
|
100
|
+
* is the SMALLEST y. Silent is therefore the bottom-left corner, full volume
|
|
101
|
+
* the top-right — Vegas' own fade-icon convention, and the same corners
|
|
102
|
+
* `drawFadeEnvelope` uses for a fade-in's silent/full ends.
|
|
103
|
+
*/
|
|
104
|
+
export function fadeCurveIconPoints(curve: FadeCurve, width: number, height: number, segments = 24): FadeCurveIconPoint[] {
|
|
105
|
+
const points: FadeCurveIconPoint[] = []
|
|
106
|
+
for (let i = 0; i <= segments; i++) {
|
|
107
|
+
const p = i / segments
|
|
108
|
+
points.push({ x: p * width, y: height - fadeGain(p, curve) * height })
|
|
109
|
+
}
|
|
110
|
+
return points
|
|
111
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas filmstrip rendering (SP5 T7) — lazy tile-sheet fetch by visible
|
|
3
|
+
* range, drawn as the UPPER BAND of each clip (`clip-bands.ts` splits it with
|
|
4
|
+
* the waveform).
|
|
5
|
+
*
|
|
6
|
+
* Mirrors `waveforms.ts` (T6)'s shape and conventions throughout: a small
|
|
7
|
+
* fetch-state store held for the lifetime of a mounted `TimelineCanvas`,
|
|
8
|
+
* idle/loading/ready/error per key, quiet failure with retry-on-next-call,
|
|
9
|
+
* `onReady` as the redraw signal. Two differences follow directly from what a
|
|
10
|
+
* filmstrip actually is:
|
|
11
|
+
*
|
|
12
|
+
* - Peaks are one fetch per clip; a filmstrip is TWO fetch layers — the
|
|
13
|
+
* index (JSON: which sheets exist, which tile is at which `t`) and the
|
|
14
|
+
* sheet IMAGES themselves (JPEGs, decoded for `drawImage`). Both are
|
|
15
|
+
* cached here, independently, so a clip whose index is ready but whose
|
|
16
|
+
* sheet hasn't finished decoding degrades to "no tiles yet" rather than an
|
|
17
|
+
* error.
|
|
18
|
+
*
|
|
19
|
+
* ── Fetch policy (SP5 plan decision 6 + T7 task) ──────────────────────────
|
|
20
|
+
* Proxy-only: input is `item.proxySrc` ONLY, never `item.src` — no proxy, no
|
|
21
|
+
* filmstrip, no fallback decode of the original, no error, no spinner (same
|
|
22
|
+
* shape as T6's clip waveforms). Fetch identity includes `proxySrc`, so an
|
|
23
|
+
* SSE-delivered proxy arriving mid-session re-keys and fetches automatically.
|
|
24
|
+
* On top of that, the index fetch is gated on the clip actually intersecting
|
|
25
|
+
* the viewport's visible time range.
|
|
26
|
+
*
|
|
27
|
+
* There is no longer a ZOOM gate. Filmstrips used to fetch and draw only above
|
|
28
|
+
* 160 px/s, because a cell was sized at one tile-interval and below that
|
|
29
|
+
* threshold every tile rendered as a sliver of itself. Cells are now sized by
|
|
30
|
+
* the frames band instead (see `clipTiles`), so zooming out yields fewer,
|
|
31
|
+
* full-size frames rather than many squeezed ones and the threshold has
|
|
32
|
+
* nothing left to protect. The cost this trades away is real: opening a
|
|
33
|
+
* project now runs the `filmstrip` step for every visible clip instead of only
|
|
34
|
+
* once someone zooms in. It is cached per proxy after the first run.
|
|
35
|
+
*
|
|
36
|
+
* ── Alignment ──────────────────────────────────────────────────────────
|
|
37
|
+
* Filmstrip tiles are indexed by ABSOLUTE source-file time (the step tiles
|
|
38
|
+
* the whole proxy, unwindowed — unlike peaks, `GetFilmstripArgs` has no
|
|
39
|
+
* `start`/`duration`). A trimmed clip's timeline time is mapped to source
|
|
40
|
+
* time via `clipTimeToSourceTime`, the same inPoint-anchored alignment T6's
|
|
41
|
+
* `clipSourceWindow` uses, assuming the same 1:1 timeline-to-source rate T6
|
|
42
|
+
* assumes (no speed ramps).
|
|
43
|
+
*/
|
|
44
|
+
import type { VisualItem } from '../../../schema'
|
|
45
|
+
import type { FilmstripIndex, FilmstripSheet, GetFilmstripArgs } from '../../../types'
|
|
46
|
+
import { clipBands } from './clip-bands'
|
|
47
|
+
import type { DrawContext, Rect } from './draw'
|
|
48
|
+
import { timeToX, visibleRange, xToTime, type Viewport } from './viewport'
|
|
49
|
+
import { FETCH_RETRY_COOLDOWN_MS } from './waveforms'
|
|
50
|
+
|
|
51
|
+
// ── Cell sizing ──────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Aspect ratio of one filmstrip cell, width ÷ height. Square: the footage this
|
|
55
|
+
* edits is 9:16, and a band-height-tall cell at the source's own aspect would
|
|
56
|
+
* be a ~27px sliver at every zoom. A square cell center-cropped out of the
|
|
57
|
+
* frame (see `centerCropTo`) trades the top and bottom of the picture for a
|
|
58
|
+
* thumbnail wide enough to recognize a shot from.
|
|
59
|
+
*/
|
|
60
|
+
export const FILMSTRIP_CELL_ASPECT = 1
|
|
61
|
+
|
|
62
|
+
/** Floor on a cell's pixel width, so a degenerate (zero/negative) band height
|
|
63
|
+
* can't turn the draw loop into a huge iteration. Normal cells are sized from
|
|
64
|
+
* the frames band, which is tens of px tall. */
|
|
65
|
+
const MIN_CELL_PX = 4
|
|
66
|
+
|
|
67
|
+
// ── Source-time alignment ────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Map a timeline time within `item`'s span to the corresponding SOURCE-file
|
|
71
|
+
* time. Filmstrip tiles are indexed by absolute source time (the whole proxy,
|
|
72
|
+
* unwindowed), so — unlike `waveforms.ts`'s `clipSourceWindow`, which can
|
|
73
|
+
* return "no window" — this is always defined: `item.inPoint ?? 0` anchors
|
|
74
|
+
* the clip's own start, and the speed factor carries every other timeline time
|
|
75
|
+
* out from there.
|
|
76
|
+
*
|
|
77
|
+
* `·(item.speed ?? 1)` converts elapsed TIMELINE-seconds into SOURCE-seconds:
|
|
78
|
+
* a clip at speed S consumes S× the source per project second. This mirrors
|
|
79
|
+
* `timeline-core`'s `seekTime`, the canonical form the playback engine and the
|
|
80
|
+
* renderer both apply. Strict no-op at S=1, so a project with no speeds set
|
|
81
|
+
* draws exactly as it did.
|
|
82
|
+
*
|
|
83
|
+
* It was 1:1 before, which was correct only while speed did not exist. Once it
|
|
84
|
+
* did, the filmstrip and the per-clip waveform told two different stories
|
|
85
|
+
* about the same clip: the waveform windows to `inPoint..outPoint`, which is
|
|
86
|
+
* speed-correct by construction, while the frames marched through the source
|
|
87
|
+
* at 1× — running off the end of a slowed clip and freezing on the last tile
|
|
88
|
+
* it could find, or repeating themselves on a sped-up one.
|
|
89
|
+
*/
|
|
90
|
+
export function clipTimeToSourceTime(item: VisualItem, timelineTime: number): number {
|
|
91
|
+
const inPoint = item.inPoint ?? 0
|
|
92
|
+
return inPoint + (item.speed ?? 1) * (timelineTime - item.start)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Tile selection ───────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** One tile, flattened out of its sheet with the sheet's index folded in —
|
|
98
|
+
* the shape `nearestTile`'s binary search and the draw loop both work over,
|
|
99
|
+
* independent of how many sheets an index actually has. */
|
|
100
|
+
export interface FlatTile {
|
|
101
|
+
t: number
|
|
102
|
+
sheetIdx: number
|
|
103
|
+
row: number
|
|
104
|
+
col: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Flatten every sheet's tiles into one ascending-`t` list. Sheets and their
|
|
108
|
+
* tiles are already produced in time order by the uniform-grid `filmstrip`
|
|
109
|
+
* step, so concatenation in sheet order preserves the ordering
|
|
110
|
+
* `nearestTile`'s binary search relies on. */
|
|
111
|
+
export function flattenTiles(index: FilmstripIndex): FlatTile[] {
|
|
112
|
+
const out: FlatTile[] = []
|
|
113
|
+
index.sheets.forEach((sheet, sheetIdx) => {
|
|
114
|
+
for (const tile of sheet.tiles) out.push({ t: tile.t, sheetIdx, row: tile.row, col: tile.col })
|
|
115
|
+
})
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The tile whose `t` is closest to `sourceTime`, by binary search over the
|
|
121
|
+
* ascending list `flattenTiles` produces. `null` only for an empty index.
|
|
122
|
+
* Clamps naturally at both ends: a `sourceTime` before the first tile or
|
|
123
|
+
* after the last returns that end tile, exactly like a scrub bar's nearest
|
|
124
|
+
* frame at either edge of the source.
|
|
125
|
+
*/
|
|
126
|
+
export function nearestTile(tiles: readonly FlatTile[], sourceTime: number): FlatTile | null {
|
|
127
|
+
if (tiles.length === 0) return null
|
|
128
|
+
let lo = 0
|
|
129
|
+
let hi = tiles.length - 1
|
|
130
|
+
while (lo < hi) {
|
|
131
|
+
const mid = (lo + hi) >> 1
|
|
132
|
+
if (tiles[mid].t < sourceTime) lo = mid + 1
|
|
133
|
+
else hi = mid
|
|
134
|
+
}
|
|
135
|
+
if (lo > 0) {
|
|
136
|
+
const prev = tiles[lo - 1]
|
|
137
|
+
const curr = tiles[lo]
|
|
138
|
+
if (Math.abs(prev.t - sourceTime) <= Math.abs(curr.t - sourceTime)) return prev
|
|
139
|
+
}
|
|
140
|
+
return tiles[lo]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Source-rect math ─────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export interface TileSourceRect { sx: number; sy: number; sw: number; sh: number }
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The tile's sub-rect within its sheet IMAGE (not the nominal `tileWidth` —
|
|
149
|
+
* the loaded image's own natural width/height divided by the grid, so
|
|
150
|
+
* rounding in the step's actual output never drifts from what's drawn).
|
|
151
|
+
*/
|
|
152
|
+
export function tileSourceRect(
|
|
153
|
+
sheet: FilmstripSheet,
|
|
154
|
+
tile: { row: number; col: number },
|
|
155
|
+
sheetImageWidth: number,
|
|
156
|
+
sheetImageHeight: number,
|
|
157
|
+
): TileSourceRect {
|
|
158
|
+
const sw = sheet.cols > 0 ? sheetImageWidth / sheet.cols : sheetImageWidth
|
|
159
|
+
const sh = sheet.rows > 0 ? sheetImageHeight / sheet.rows : sheetImageHeight
|
|
160
|
+
return { sx: tile.col * sw, sy: tile.row * sh, sw, sh }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Narrow `src` to the largest centered sub-rect matching `destAspect`
|
|
165
|
+
* (width ÷ height), so a tile drawn into that destination fills it without
|
|
166
|
+
* distortion. Vertical 9:16 footage into a square cell loses the top and
|
|
167
|
+
* bottom; wide footage into the same cell loses the sides.
|
|
168
|
+
*
|
|
169
|
+
* Applied per CELL rather than once per tile because the cells at a clip's
|
|
170
|
+
* left and right edges are clipped narrower than a full one — cropping to the
|
|
171
|
+
* destination's actual aspect keeps those partial cells undistorted too,
|
|
172
|
+
* which cropping to a fixed square would not.
|
|
173
|
+
*
|
|
174
|
+
* A non-positive aspect or a degenerate source returns `src` unchanged: the
|
|
175
|
+
* caller's own `width <= 0` guards already skip those, and silently returning
|
|
176
|
+
* the uncropped rect is safer than emitting NaNs into `drawImage`.
|
|
177
|
+
*/
|
|
178
|
+
export function centerCropTo(src: TileSourceRect, destAspect: number): TileSourceRect {
|
|
179
|
+
if (!(destAspect > 0) || src.sw <= 0 || src.sh <= 0) return src
|
|
180
|
+
const srcAspect = src.sw / src.sh
|
|
181
|
+
if (srcAspect > destAspect) {
|
|
182
|
+
const sw = src.sh * destAspect
|
|
183
|
+
return { sx: src.sx + (src.sw - sw) / 2, sy: src.sy, sw, sh: src.sh }
|
|
184
|
+
}
|
|
185
|
+
const sh = src.sw / destAspect
|
|
186
|
+
return { sx: src.sx, sy: src.sy + (src.sh - sh) / 2, sw: src.sw, sh }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Injectable image loading ─────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
export interface LoadedSheetImage {
|
|
192
|
+
image: CanvasImageSource
|
|
193
|
+
width: number
|
|
194
|
+
height: number
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type SheetImageLoader = (url: string) => Promise<LoadedSheetImage>
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Default loader: a plain `Image()` element. Kept deliberately simple (no
|
|
201
|
+
* `fetch`+`createImageBitmap` round trip) since `img.src` already handles
|
|
202
|
+
* the host's URL scheme (auth cookies, relative paths) the same way every
|
|
203
|
+
* other image in the editor loads. Tests inject a fake via
|
|
204
|
+
* `FilmstripQueryContext.loader` — jsdom never fires a real `load` event.
|
|
205
|
+
*/
|
|
206
|
+
export const defaultSheetImageLoader: SheetImageLoader = (url) =>
|
|
207
|
+
new Promise((resolve, reject) => {
|
|
208
|
+
const img = new Image()
|
|
209
|
+
img.onload = () => resolve({ image: img, width: img.naturalWidth || img.width, height: img.naturalHeight || img.height })
|
|
210
|
+
img.onerror = () => reject(new Error(`filmstrip sheet failed to load: ${url}`))
|
|
211
|
+
img.src = url
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// ── Draw primitives ──────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/** One tile positioned at its destination rect, ready for `ctx.drawImage`. */
|
|
217
|
+
export interface FilmstripTileDraw {
|
|
218
|
+
image: CanvasImageSource
|
|
219
|
+
sx: number
|
|
220
|
+
sy: number
|
|
221
|
+
sw: number
|
|
222
|
+
sh: number
|
|
223
|
+
rect: Rect
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Paint the tile strip. Destination rects already sit in the clip's frames
|
|
227
|
+
* band (`clipTiles` places them), so this just blits — composed with the
|
|
228
|
+
* waveform inside `drawTimelineContent`'s single `drawContent` hook (draw.ts),
|
|
229
|
+
* which paints the two bands into disjoint halves of the clip. */
|
|
230
|
+
export function drawFilmstripTiles(ctx: DrawContext, tiles: readonly FilmstripTileDraw[]): void {
|
|
231
|
+
for (const tile of tiles) {
|
|
232
|
+
if (tile.rect.width <= 0 || tile.rect.height <= 0) continue
|
|
233
|
+
ctx.drawImage(tile.image, tile.sx, tile.sy, tile.sw, tile.sh, tile.rect.x, tile.rect.y, tile.rect.width, tile.rect.height)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Fetch-state store ─────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
type EntryStatus = 'loading' | 'ready' | 'error'
|
|
240
|
+
|
|
241
|
+
interface IndexEntry {
|
|
242
|
+
status: EntryStatus
|
|
243
|
+
data: FilmstripIndex | null
|
|
244
|
+
tiles: FlatTile[]
|
|
245
|
+
erroredAt?: number
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface ImageEntry {
|
|
249
|
+
status: EntryStatus
|
|
250
|
+
image: LoadedSheetImage | null
|
|
251
|
+
erroredAt?: number
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Everything a lookup needs for one call. One instance is built per paint
|
|
256
|
+
* (viewport/onReady can both have moved since the last one); the store
|
|
257
|
+
* itself is what persists across paints.
|
|
258
|
+
*
|
|
259
|
+
* `onReady` invalidates the content layer, the only consumer of filmstrip
|
|
260
|
+
* data. Note that only the call which ORIGINATES a fetch gets its `onReady`
|
|
261
|
+
* attached to that fetch's promise (see `resolveIndex`/`resolveSheetImage`
|
|
262
|
+
* below) — every other clip waiting on the same sheet is repainted by that one
|
|
263
|
+
* callback, since a content redraw repaints them all.
|
|
264
|
+
*/
|
|
265
|
+
export interface FilmstripQueryContext {
|
|
266
|
+
projectId: string
|
|
267
|
+
/** Absent → no filmstrips anywhere (host adapter doesn't implement it). */
|
|
268
|
+
getFilmstrip?: (args: GetFilmstripArgs) => Promise<FilmstripIndex>
|
|
269
|
+
/** Resolves a sheet's host path to a displayable URL — the SAME mechanism
|
|
270
|
+
* `WaveformChunk.path` uses (`adapter.fileUrl`, threaded through Timeline
|
|
271
|
+
* as `resolveFilePath`). Absent → no filmstrips (nothing to load images
|
|
272
|
+
* through). */
|
|
273
|
+
fileUrl?: (path: string) => string
|
|
274
|
+
viewport: Viewport
|
|
275
|
+
onReady: () => void
|
|
276
|
+
/** Injectable image decoder; defaults to `defaultSheetImageLoader`. */
|
|
277
|
+
loader?: SheetImageLoader
|
|
278
|
+
/** Grid params passed straight through to `getFilmstrip` and folded into
|
|
279
|
+
* the fetch/cache key — omit to use the step's own defaults. */
|
|
280
|
+
maxTiles?: number
|
|
281
|
+
minInterval?: number
|
|
282
|
+
tileWidth?: number
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** What `drawTimelineContent` (draw.ts) needs from T7, kept as an interface
|
|
286
|
+
* so draw.ts only takes a type-only dependency on this module — mirrors
|
|
287
|
+
* T6's `WaveformSceneLookup`. */
|
|
288
|
+
export interface FilmstripSceneLookup {
|
|
289
|
+
clipTiles(item: VisualItem, rect: Rect): FilmstripTileDraw[] | null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export class FilmstripStore {
|
|
293
|
+
private indexEntries = new Map<string, IndexEntry>()
|
|
294
|
+
private imageEntries = new Map<string, ImageEntry>()
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Background tile strip for a clip's rect. `null` whenever there's nothing
|
|
298
|
+
* to draw: non-video items, no proxy yet, no adapter support, off the
|
|
299
|
+
* visible range, a zero-height frames band, or the index/sheets not ready
|
|
300
|
+
* yet — all graceful, never an error state the caller has to handle
|
|
301
|
+
* specially.
|
|
302
|
+
*/
|
|
303
|
+
clipTiles(item: VisualItem, rect: Rect, ctx: FilmstripQueryContext): FilmstripTileDraw[] | null {
|
|
304
|
+
if (item.type !== 'video' || !item.proxySrc || !ctx.getFilmstrip || !ctx.fileUrl) return null
|
|
305
|
+
if (rect.width <= 0 || rect.height <= 0) return null
|
|
306
|
+
const range = visibleRange(ctx.viewport)
|
|
307
|
+
if (item.end < range.start || item.start > range.end) return null
|
|
308
|
+
|
|
309
|
+
const band = clipBands(rect).frames
|
|
310
|
+
if (band.height <= 0) return null
|
|
311
|
+
|
|
312
|
+
const resolved = this.resolveIndex(item.proxySrc, ctx)
|
|
313
|
+
if (!resolved || resolved.tiles.length === 0) return null
|
|
314
|
+
const { data: index, tiles } = resolved
|
|
315
|
+
|
|
316
|
+
// Cell width comes from the BAND HEIGHT, not from the index's tile
|
|
317
|
+
// interval — that is what makes the strip re-derive itself as you zoom.
|
|
318
|
+
// Each cell is one fixed-aspect thumbnail, so the clip's pixel width alone
|
|
319
|
+
// decides how many frames fit: zoom in and the same span grows more cells,
|
|
320
|
+
// each landing on a nearer tile; zoom out and it collapses to a few
|
|
321
|
+
// widely-spaced frames. Sizing cells by `interval * pxPerSecond` instead
|
|
322
|
+
// (the original) pinned one cell per tile at every zoom, which meant cells
|
|
323
|
+
// narrower than a tile when zoomed out and stretched frames when zoomed in.
|
|
324
|
+
const cellPx = Math.max(MIN_CELL_PX, band.height * FILMSTRIP_CELL_ASPECT)
|
|
325
|
+
|
|
326
|
+
// Only the portion of the clip's cells actually on screen ever touches a
|
|
327
|
+
// sheet — a long base-track clip can have hundreds of cells; a sliver of
|
|
328
|
+
// it visible after a scroll should load one sheet, not all of them.
|
|
329
|
+
const clipLeftX = timeToX(item.start, ctx.viewport)
|
|
330
|
+
const clipRightX = timeToX(item.end, ctx.viewport)
|
|
331
|
+
const drawLeft = Math.max(rect.x, clipLeftX)
|
|
332
|
+
const drawRight = Math.min(rect.x + rect.width, clipRightX)
|
|
333
|
+
if (drawRight <= drawLeft) return null
|
|
334
|
+
|
|
335
|
+
// Cell grid aligned to the CLIP's own start (not the visible window), so
|
|
336
|
+
// panning never shifts which frame lands in which cell.
|
|
337
|
+
const firstCellIdx = Math.floor((drawLeft - clipLeftX) / cellPx)
|
|
338
|
+
const out: FilmstripTileDraw[] = []
|
|
339
|
+
for (let x = clipLeftX + firstCellIdx * cellPx; x < drawRight; x += cellPx) {
|
|
340
|
+
const cellLeft = Math.max(x, drawLeft)
|
|
341
|
+
const cellRight = Math.min(x + cellPx, drawRight)
|
|
342
|
+
if (cellRight <= cellLeft) continue
|
|
343
|
+
|
|
344
|
+
const cellCenterTime = xToTime((cellLeft + cellRight) / 2, ctx.viewport)
|
|
345
|
+
const sourceTime = clipTimeToSourceTime(item, cellCenterTime)
|
|
346
|
+
const tile = nearestTile(tiles, sourceTime)
|
|
347
|
+
if (!tile) continue
|
|
348
|
+
|
|
349
|
+
const sheet = index.sheets[tile.sheetIdx]
|
|
350
|
+
const img = this.resolveSheetImage(sheet.path, ctx)
|
|
351
|
+
if (!img) continue // sheet not decoded yet — this cell just stays blank until onReady fires
|
|
352
|
+
|
|
353
|
+
const dest: Rect = { x: cellLeft, y: band.y, width: cellRight - cellLeft, height: band.height }
|
|
354
|
+
const src = centerCropTo(tileSourceRect(sheet, tile, img.width, img.height), dest.width / dest.height)
|
|
355
|
+
out.push({ image: img.image, sx: src.sx, sy: src.sy, sw: src.sw, sh: src.sh, rect: dest })
|
|
356
|
+
}
|
|
357
|
+
return out.length > 0 ? out : null
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
/** Look up the index for `src`, kicking off a fetch when absent. Never
|
|
362
|
+
* re-fetches while loading or once ready; an errored entry is retried on
|
|
363
|
+
* the next call that needs it (same quiet-retry shape as T6). */
|
|
364
|
+
private resolveIndex(src: string, ctx: FilmstripQueryContext): { data: FilmstripIndex; tiles: FlatTile[] } | null {
|
|
365
|
+
const key = `${ctx.projectId}|${src}|${ctx.maxTiles ?? ''}|${ctx.minInterval ?? ''}|${ctx.tileWidth ?? ''}`
|
|
366
|
+
const entry = this.indexEntries.get(key)
|
|
367
|
+
if (entry?.status === 'ready' && entry.data) return { data: entry.data, tiles: entry.tiles }
|
|
368
|
+
// Fetch when there's no entry yet OR the last attempt errored — an
|
|
369
|
+
// 'error' entry is neither ready nor in flight, so (unlike a bare
|
|
370
|
+
// `!entry` check) it must still be retriable on the next call.
|
|
371
|
+
//
|
|
372
|
+
// An errored entry stays retriable, but not on the very next paint: the
|
|
373
|
+
// overlay layer repaints at ~60Hz during playback, which would turn a
|
|
374
|
+
// persistently-failing fetch into a request storm.
|
|
375
|
+
const inCooldown = entry?.status === 'error'
|
|
376
|
+
&& Date.now() - (entry.erroredAt ?? 0) < FETCH_RETRY_COOLDOWN_MS
|
|
377
|
+
if (entry?.status !== 'loading' && !inCooldown) {
|
|
378
|
+
const fetcher = ctx.getFilmstrip
|
|
379
|
+
if (fetcher) {
|
|
380
|
+
this.indexEntries.set(key, { status: 'loading', data: null, tiles: [] })
|
|
381
|
+
fetcher({ projectId: ctx.projectId, src, maxTiles: ctx.maxTiles, minInterval: ctx.minInterval, tileWidth: ctx.tileWidth })
|
|
382
|
+
.then(data => {
|
|
383
|
+
this.indexEntries.set(key, { status: 'ready', data, tiles: flattenTiles(data) })
|
|
384
|
+
ctx.onReady()
|
|
385
|
+
})
|
|
386
|
+
.catch(() => {
|
|
387
|
+
this.indexEntries.set(key, { status: 'error', data: null, tiles: [], erroredAt: Date.now() })
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Look up a decoded sheet image by its host path, kicking off a load when
|
|
395
|
+
* absent. Same quiet-retry shape as `resolveIndex`. */
|
|
396
|
+
private resolveSheetImage(path: string, ctx: FilmstripQueryContext): LoadedSheetImage | null {
|
|
397
|
+
const entry = this.imageEntries.get(path)
|
|
398
|
+
if (entry?.status === 'ready' && entry.image) return entry.image
|
|
399
|
+
const inCooldown = entry?.status === 'error'
|
|
400
|
+
&& Date.now() - (entry.erroredAt ?? 0) < FETCH_RETRY_COOLDOWN_MS
|
|
401
|
+
if (entry?.status !== 'loading' && !inCooldown) {
|
|
402
|
+
const fileUrl = ctx.fileUrl
|
|
403
|
+
if (fileUrl) {
|
|
404
|
+
const loader = ctx.loader ?? defaultSheetImageLoader
|
|
405
|
+
this.imageEntries.set(path, { status: 'loading', image: null })
|
|
406
|
+
loader(fileUrl(path))
|
|
407
|
+
.then(image => {
|
|
408
|
+
this.imageEntries.set(path, { status: 'ready', image })
|
|
409
|
+
ctx.onReady()
|
|
410
|
+
})
|
|
411
|
+
.catch(() => {
|
|
412
|
+
this.imageEntries.set(path, { status: 'error', image: null, erroredAt: Date.now() })
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return null
|
|
417
|
+
}
|
|
418
|
+
}
|