@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,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas waveform rendering (SP5 T6) — turns fetched `PeaksData` into pixel
|
|
3
|
+
* columns and paints them as the content layer `drawClipRect`/`drawAudioItem`
|
|
4
|
+
* (draw.ts) call between the fill/border and the label.
|
|
5
|
+
*
|
|
6
|
+
* ── Two render targets, one pipeline ─────────────────────────────────────
|
|
7
|
+
* - Audio lanes: a full-bar waveform, replacing the retired DOM path's fixed
|
|
8
|
+
* PNG chunks (`AudioWaveformLayer.tsx`, since deleted).
|
|
9
|
+
* `audioTrackSourceWindow` / `sourceTimeToBarFraction` below
|
|
10
|
+
* are ports of that component's trim-alignment math (lines 75–89): same
|
|
11
|
+
* inputs, same positioning, just expressed as 0–1 fractions instead of CSS
|
|
12
|
+
* percentages so the canvas painter can multiply by `rect.width` directly.
|
|
13
|
+
* - Visual-track clips: the LOWER HALF of the clip rect (`clip-bands.ts`),
|
|
14
|
+
* with the filmstrip taking the upper half. This began as a 10px strip
|
|
15
|
+
* under a "new UI, stay understated" brief; it grew because the strip was
|
|
16
|
+
* too small to cut against — at half a clip the waveform is the primary
|
|
17
|
+
* thing you read on the base track, so it gets a darkened band and enough
|
|
18
|
+
* alpha to be legible rather than merely present.
|
|
19
|
+
*
|
|
20
|
+
* ── Input-selection + fetch policy (SP5 plan decision) ───────────────────
|
|
21
|
+
* Clips fetch from `item.proxySrc` ONLY — no fallback to `item.src` — so a
|
|
22
|
+
* clip with no proxy yet simply renders no waveform (graceful, no spinner,
|
|
23
|
+
* no error) until the import pipeline delivers one. Audio lanes fetch from
|
|
24
|
+
* `track.src` (audio tracks have no proxies), windowed to the track's own
|
|
25
|
+
* trim.
|
|
26
|
+
*
|
|
27
|
+
* Clips are NOT windowed at fetch time. The editing proxy is the WHOLE
|
|
28
|
+
* source, never trimmed server-side, so it is shared, unwindowed data every
|
|
29
|
+
* clip pointing at that `proxySrc` can reuse regardless of which clip, or
|
|
30
|
+
* which trim, is asking — `clipColumns` fetches (and caches) it ONCE, keyed
|
|
31
|
+
* by src alone, and slices the clip's own [inPoint, outPoint] window out of
|
|
32
|
+
* it locally. Before this, the fetch itself was windowed and keyed by
|
|
33
|
+
* `(item.id, window)`, so splitting a clip (new ids) or trimming it (a new
|
|
34
|
+
* window) changed the key, forced a re-decode, and flashed the waveform
|
|
35
|
+
* blank for a frame — even though the underlying proxy data hadn't changed
|
|
36
|
+
* at all.
|
|
37
|
+
*
|
|
38
|
+
|
|
39
|
+
* ── Zoom-crisp resolution (bucket scheme) ─────────────────────────────────
|
|
40
|
+
* `resolveBucket` picks 50/200/800 from the current px/second. `resolve()`
|
|
41
|
+
* (private, on `WaveformPeaksStore`) fetches the needed bucket only when
|
|
42
|
+
* nothing at that bucket-or-higher is already ready/loading — so zooming
|
|
43
|
+
* OUT never re-fetches (a higher bucket already cached renders fine
|
|
44
|
+
* downsampled; see `resamplePeaksToColumns`), and zooming IN fetches exactly
|
|
45
|
+
* once per bucket crossed, keeping the previous bucket on screen until the
|
|
46
|
+
* new one resolves.
|
|
47
|
+
*/
|
|
48
|
+
import type { AudioTrack, VisualItem } from '../../../schema'
|
|
49
|
+
import type { GetWaveformPeaksArgs, PeaksData, PeaksResolution } from '../../../types'
|
|
50
|
+
import { clipBands } from './clip-bands'
|
|
51
|
+
import type { DrawContext, Rect } from './draw'
|
|
52
|
+
import { timeToX, type Viewport } from './viewport'
|
|
53
|
+
|
|
54
|
+
// ── Resolution buckets ───────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export const PEAKS_BUCKETS: PeaksResolution[] = [50, 200, 800]
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolution bucket for the current zoom: 50 while `pxPerSecond` ≤ 50, 200
|
|
60
|
+
* while ≤ 200, 800 above that. Blockiness stays ≤1px up to 800px/s; beyond
|
|
61
|
+
* that — up to the viewport's `MAX_PX_PER_SECOND` (2000) — mild blockiness
|
|
62
|
+
* is accepted since there is no higher bucket to fetch.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveBucket(pxPerSecond: number): PeaksResolution {
|
|
65
|
+
if (pxPerSecond <= 50) return 50
|
|
66
|
+
if (pxPerSecond <= 200) return 200
|
|
67
|
+
return 800
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Trim-alignment math (ported from AudioWaveformLayer.tsx:75-89) ───────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The source-time window `getWaveformPeaks` should fetch for an audio
|
|
74
|
+
* track's lane waveform, and the window `sourceTimeToBarFraction` positions
|
|
75
|
+
* against. `duration <= 0` is the fully-collapsed-trim case — callers skip
|
|
76
|
+
* drawing rather than fetch/render a degenerate window.
|
|
77
|
+
*/
|
|
78
|
+
export function audioTrackSourceWindow(track: AudioTrack): { start: number; duration: number } {
|
|
79
|
+
const start = track.inPoint ?? 0
|
|
80
|
+
// outPoint present → exact window. Absent → for 1:1 audio the clip's timeline
|
|
81
|
+
// span IS its source-window duration; using end-start avoids treating
|
|
82
|
+
// sourceDuration (a length) as an absolute out-point, which goes negative for
|
|
83
|
+
// a split clip whose right half has inPoint>0 and no outPoint (blank waveform).
|
|
84
|
+
const duration = track.outPoint != null
|
|
85
|
+
? Math.max(0, track.outPoint - start)
|
|
86
|
+
: Math.max(0, track.end - track.start)
|
|
87
|
+
return { start, duration }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fraction of the bar [0,1] a source-time point maps to, given the track's
|
|
92
|
+
* trim window. Ported from `AudioWaveformLayer.tsx`'s `leftPct` (lines
|
|
93
|
+
* 88–89: `(chunk.start - inPt) / visibleSpan`) — identical formula, just
|
|
94
|
+
* returned as a 0–1 fraction instead of `${...}%` so the canvas painter
|
|
95
|
+
* multiplies by `rect.width` instead of writing `element.style.left`.
|
|
96
|
+
*/
|
|
97
|
+
export function sourceTimeToBarFraction(sourceTime: number, window: { start: number; duration: number }): number {
|
|
98
|
+
if (window.duration <= 0) return 0
|
|
99
|
+
return (sourceTime - window.start) / window.duration
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The source-time window to fetch for a clip's per-item waveform.
|
|
104
|
+
* `undefined` means "no windowing — fetch the whole proxy": with neither
|
|
105
|
+
* `inPoint` nor `outPoint` set there is no trim to narrow to, and the clip's
|
|
106
|
+
* own project-timeline duration is not a reliable stand-in for the proxy's
|
|
107
|
+
* source duration, so the request omits `start`/`duration` entirely rather
|
|
108
|
+
* than guess. Present `inPoint`/`outPoint` narrow it exactly, mirroring the
|
|
109
|
+
* audio-lane window above. A degenerate `outPoint <= inPoint` yields
|
|
110
|
+
* `duration: 0`; callers skip drawing on that, same as the audio path.
|
|
111
|
+
*/
|
|
112
|
+
export function clipSourceWindow(item: VisualItem): { start: number; duration: number } | undefined {
|
|
113
|
+
if (item.inPoint == null && item.outPoint == null) return undefined
|
|
114
|
+
const start = item.inPoint ?? 0
|
|
115
|
+
const outPoint = item.outPoint ?? item.sourceDuration ?? start + Math.max(0, item.end - item.start)
|
|
116
|
+
return { start, duration: Math.max(0, outPoint - start) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Resample: peaks → pixel columns ───────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export interface WaveformColumn {
|
|
122
|
+
/** Normalized to roughly [-1, 1] (int16 value / 32768). */
|
|
123
|
+
min: number
|
|
124
|
+
max: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `PeaksData.peaks` values are in int16 range. */
|
|
128
|
+
const INT16_SCALE = 32768
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resample an interleaved `[min, max, min, max, ...]` peaks array to exactly
|
|
132
|
+
* `columns` pixel-column min/max pairs. One formula covers both zoom
|
|
133
|
+
* regimes the bucket scheme describes:
|
|
134
|
+
*
|
|
135
|
+
* samples-per-px > 1 (zoomed out relative to the data): each column
|
|
136
|
+
* aggregates (min of mins, max of maxes) over more than one source sample.
|
|
137
|
+
*
|
|
138
|
+
* samples-per-px < 1 (zoomed in past the data's resolution): consecutive
|
|
139
|
+
* columns share the same source sample (`Math.floor` repeats it), giving
|
|
140
|
+
* the accepted "mild blockiness" at the top of the zoom range rather than
|
|
141
|
+
* a smoothed/interpolated curve — matching the bucket scheme's own
|
|
142
|
+
* blockiness language rather than inventing a shape the data doesn't have.
|
|
143
|
+
*
|
|
144
|
+
* Pure: no knowledge of seconds, buckets, or DOM — just an array and a
|
|
145
|
+
* column count, so it is trivial to hand known fixtures in tests.
|
|
146
|
+
*/
|
|
147
|
+
export function resamplePeaksToColumns(peaks: number[], columns: number): WaveformColumn[] {
|
|
148
|
+
const totalSamples = Math.floor(peaks.length / 2)
|
|
149
|
+
if (columns <= 0 || totalSamples <= 0) return []
|
|
150
|
+
|
|
151
|
+
const samplesPerColumn = totalSamples / columns
|
|
152
|
+
const result: WaveformColumn[] = []
|
|
153
|
+
for (let i = 0; i < columns; i++) {
|
|
154
|
+
let startIdx = Math.floor(i * samplesPerColumn)
|
|
155
|
+
if (startIdx >= totalSamples) startIdx = totalSamples - 1
|
|
156
|
+
let endIdx = Math.floor((i + 1) * samplesPerColumn)
|
|
157
|
+
if (endIdx <= startIdx) endIdx = startIdx + 1
|
|
158
|
+
if (endIdx > totalSamples) endIdx = totalSamples
|
|
159
|
+
|
|
160
|
+
let min = Infinity
|
|
161
|
+
let max = -Infinity
|
|
162
|
+
for (let s = startIdx; s < endIdx; s++) {
|
|
163
|
+
const mn = peaks[s * 2]
|
|
164
|
+
const mx = peaks[s * 2 + 1]
|
|
165
|
+
if (mn < min) min = mn
|
|
166
|
+
if (mx > max) max = mx
|
|
167
|
+
}
|
|
168
|
+
result.push({ min: min / INT16_SCALE, max: max / INT16_SCALE })
|
|
169
|
+
}
|
|
170
|
+
return result
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Drawing primitives ───────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
export const WAVEFORM_COLORS = {
|
|
176
|
+
/** Translucent white so the bars read over any of `TRACK_PALETTE`'s six clip
|
|
177
|
+
* hues without needing a per-palette variant. Raised from the original
|
|
178
|
+
* 0.35: at 10px the strip was a texture and low alpha kept it from
|
|
179
|
+
* competing with the label, but across half a clip it is the thing you cut
|
|
180
|
+
* against and needs to read as a waveform. */
|
|
181
|
+
clip: 'rgba(255,255,255,0.6)',
|
|
182
|
+
/** Darkens the waveform band relative to the clip fill so the lower half
|
|
183
|
+
* reads as its own lane rather than bars floating on the clip — the
|
|
184
|
+
* separation CapCut gets from a distinct band colour. */
|
|
185
|
+
clipBand: 'rgba(0,0,0,0.22)',
|
|
186
|
+
/** Sits inside `AudioTrackRow`'s emerald bar; brighter than the fill so it
|
|
187
|
+
* reads as drawn "on top of" it, echoing `TIMELINE_COLORS.audioRing`. */
|
|
188
|
+
audioLane: 'rgba(167,243,208,0.75)',
|
|
189
|
+
/** Muted video-track clip bars: dimmer than `clip`, but still readable so
|
|
190
|
+
* the waveform shape survives on top of the darker muted scrim below. */
|
|
191
|
+
clipMuted: 'rgba(255,255,255,0.3)',
|
|
192
|
+
/** Muted band: a distinctly DARKER scrim than `clipBand` (not the old faint
|
|
193
|
+
* lighten, which read almost identical to an unmuted clip), so a muted
|
|
194
|
+
* clip's audio region visibly dims and reads as "off" at a glance. */
|
|
195
|
+
clipBandMuted: 'rgba(0,0,0,0.45)',
|
|
196
|
+
} as const
|
|
197
|
+
|
|
198
|
+
/** The shape both waveform sets satisfy — `WAVEFORM_COLORS`'s keys widened
|
|
199
|
+
* from its `as const` literals to plain `string`. Carried on
|
|
200
|
+
* `TimelinePalette.waveform` (draw.ts) so a draw pass resolves waveform
|
|
201
|
+
* colours from the same object as everything else it paints. */
|
|
202
|
+
export type WaveformColors = { [K in keyof typeof WAVEFORM_COLORS]: string }
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `WAVEFORM_COLORS` for a LIGHT clip fill.
|
|
206
|
+
*
|
|
207
|
+
* The dark set is white bars over a saturated clip hue, sitting in a band
|
|
208
|
+
* DARKENED relative to that hue. Light inverts both halves, and they have to
|
|
209
|
+
* invert together or the bars stop separating from their own band:
|
|
210
|
+
*
|
|
211
|
+
* - the bars go near-black, since `LIGHT_TRACK_PALETTE`'s fills are pale
|
|
212
|
+
* tints and white bars would be gone;
|
|
213
|
+
* - the band goes LIGHTER than the clip fill rather than darker. Keeping the
|
|
214
|
+
* black scrim would have worked — dark bars on a mid-grey band do read —
|
|
215
|
+
* but it makes the busiest part of the clip its heaviest, and washing the
|
|
216
|
+
* band toward white instead gives the dark bars the maximum contrast while
|
|
217
|
+
* still marking the waveform half as its own lane.
|
|
218
|
+
*
|
|
219
|
+
* The mute pair keeps its meaning by keeping its DIRECTION: dark mutes by
|
|
220
|
+
* making the band darker still, light mutes by washing it further out. Either
|
|
221
|
+
* way a muted clip's audio region visibly recedes from an unmuted one.
|
|
222
|
+
*/
|
|
223
|
+
export const LIGHT_WAVEFORM_COLORS: WaveformColors = {
|
|
224
|
+
clip: 'rgba(15,23,42,0.6)',
|
|
225
|
+
clipBand: 'rgba(255,255,255,0.45)',
|
|
226
|
+
/** Sits inside the light emerald bar; DARKER than the fill so it reads as
|
|
227
|
+
* drawn "on top of" it — the mirror of dark's "brighter than the fill",
|
|
228
|
+
* and echoing `LIGHT_TIMELINE_COLORS.audioRing` the same way. */
|
|
229
|
+
audioLane: 'rgba(6,95,70,0.75)',
|
|
230
|
+
clipMuted: 'rgba(15,23,42,0.3)',
|
|
231
|
+
clipBandMuted: 'rgba(255,255,255,0.72)',
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Paint one row of min/max columns as vertical bars centered in `rect`,
|
|
235
|
+
* amplitude scaled to `rect.height / 2`. The shared primitive both
|
|
236
|
+
* `drawClipWaveform` and `drawAudioLaneWaveform` style differently.
|
|
237
|
+
*
|
|
238
|
+
* `gainAt`, when given, is sampled at each column's CENTER x and scales
|
|
239
|
+
* that column's `min`/`max` before the bar is measured — the Vegas
|
|
240
|
+
* behaviour of a waveform shrinking to zero through a fade-out and growing
|
|
241
|
+
* from zero through a fade-in, using the fade's own shape curve
|
|
242
|
+
* (`fade-curve.ts`'s `fadeGain`, via `makeFadeGainAt`). Absent — the
|
|
243
|
+
* default — draws exactly as before: full amplitude everywhere, the
|
|
244
|
+
* contract every existing caller (clip waveforms, an audio bar with no
|
|
245
|
+
* fade) still gets. */
|
|
246
|
+
export function drawWaveformBars(
|
|
247
|
+
ctx: DrawContext,
|
|
248
|
+
rect: Rect,
|
|
249
|
+
columns: WaveformColumn[],
|
|
250
|
+
color: string,
|
|
251
|
+
gainAt?: (x: number) => number,
|
|
252
|
+
): void {
|
|
253
|
+
if (rect.width <= 0 || rect.height <= 0 || columns.length === 0) return
|
|
254
|
+
const centerY = rect.y + rect.height / 2
|
|
255
|
+
const halfHeight = rect.height / 2
|
|
256
|
+
const colWidth = rect.width / columns.length
|
|
257
|
+
|
|
258
|
+
ctx.fillStyle = color
|
|
259
|
+
for (let i = 0; i < columns.length; i++) {
|
|
260
|
+
const { min, max } = columns[i]
|
|
261
|
+
const x = rect.x + i * colWidth
|
|
262
|
+
const gain = gainAt ? gainAt(x + colWidth / 2) : 1
|
|
263
|
+
const hi = Math.max(min, max) * gain
|
|
264
|
+
const lo = Math.min(min, max) * gain
|
|
265
|
+
const top = centerY - hi * halfHeight
|
|
266
|
+
const bottom = centerY - lo * halfHeight
|
|
267
|
+
ctx.fillRect(x, top, Math.max(1, colWidth), Math.max(1, bottom - top))
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The lower half of a clip rect the waveform draws into — `clip-bands.ts` owns
|
|
272
|
+
* the split, so this is a pass-through kept as the name the painter and its
|
|
273
|
+
* tests already reach for. */
|
|
274
|
+
export function clipWaveformBand(rect: Rect): Rect {
|
|
275
|
+
return clipBands(rect).waveform
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Paint the clip's waveform half: a darkened band, then the bars centered in
|
|
279
|
+
* it. The band is painted even where the bars are silent, so a quiet passage
|
|
280
|
+
* reads as "no audio here" rather than as missing data. `muted` swaps in the
|
|
281
|
+
* grayed `clipMuted`/`clipBandMuted` pair — the same mute cue `drawAudioItem`
|
|
282
|
+
* already gives audio-lane bars, extended to video-track clip waveforms. */
|
|
283
|
+
export function drawClipWaveform(
|
|
284
|
+
ctx: DrawContext,
|
|
285
|
+
rect: Rect,
|
|
286
|
+
columns: WaveformColumn[],
|
|
287
|
+
muted = false,
|
|
288
|
+
colors: WaveformColors = WAVEFORM_COLORS,
|
|
289
|
+
): void {
|
|
290
|
+
const band = clipWaveformBand(rect)
|
|
291
|
+
if (band.height <= 0 || band.width <= 0) return
|
|
292
|
+
ctx.fillStyle = muted ? colors.clipBandMuted : colors.clipBand
|
|
293
|
+
ctx.fillRect(band.x, band.y, band.width, band.height)
|
|
294
|
+
drawWaveformBars(ctx, band, columns, muted ? colors.clipMuted : colors.clip)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Full-bar audio-lane waveform. `rect` is already the fetched window's
|
|
298
|
+
* bar-relative sub-rect (see `WaveformPeaksStore.audioColumns`), so this
|
|
299
|
+
* paints edge-to-edge of whatever span the data actually covers.
|
|
300
|
+
*
|
|
301
|
+
* `gainAt` — see `drawWaveformBars` — is a lookup over the BAR's own true
|
|
302
|
+
* span in surface x (built once per bar by the painter, from the track's
|
|
303
|
+
* fades and curves), not over `rect`, which can be a scrolled/clamped
|
|
304
|
+
* sub-span of it; passing it straight through is what lets a column keep
|
|
305
|
+
* the right gain regardless of which slice of the bar `rect` happens to be. */
|
|
306
|
+
export function drawAudioLaneWaveform(
|
|
307
|
+
ctx: DrawContext,
|
|
308
|
+
rect: Rect,
|
|
309
|
+
columns: WaveformColumn[],
|
|
310
|
+
gainAt?: (x: number) => number,
|
|
311
|
+
colors: WaveformColors = WAVEFORM_COLORS,
|
|
312
|
+
): void {
|
|
313
|
+
drawWaveformBars(ctx, rect, columns, colors.audioLane, gainAt)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Fetch-state store ────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
/** How long an errored fetch stays in cooldown before the next call may retry
|
|
319
|
+
* it. Both canvas stores are polled from paint (the overlay layer repaints at
|
|
320
|
+
* ~60Hz during playback; the content layer once per pointer move during a
|
|
321
|
+
* drag), so retry-on-next-call alone turns a persistent failure into a
|
|
322
|
+
* request storm. Shared with filmstrips.ts. */
|
|
323
|
+
export const FETCH_RETRY_COOLDOWN_MS = 5000
|
|
324
|
+
|
|
325
|
+
type EntryStatus = 'loading' | 'ready' | 'error'
|
|
326
|
+
|
|
327
|
+
interface Entry {
|
|
328
|
+
status: EntryStatus
|
|
329
|
+
data: PeaksData | null
|
|
330
|
+
erroredAt?: number
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface WaveformQueryContext {
|
|
334
|
+
projectId: string
|
|
335
|
+
/** Absent → no waveforms anywhere (host adapter doesn't implement it). */
|
|
336
|
+
getWaveformPeaks?: (args: GetWaveformPeaksArgs) => Promise<PeaksData>
|
|
337
|
+
pxPerSecond: number
|
|
338
|
+
/** Needed by `clipColumns` to recover a clip's FULL on-screen span
|
|
339
|
+
* (`item.start`..`item.end` mapped through this, unclamped) — the `rect`
|
|
340
|
+
* it's handed is already clamped to the visible surface and inset by the
|
|
341
|
+
* clip-body gutter (see `visibleClipSampleRange`), so `pxPerSecond` alone
|
|
342
|
+
* isn't enough; `scrollSeconds` is what says WHICH slice of a long clip is
|
|
343
|
+
* currently on screen. Not consulted by `audioColumns`, which positions
|
|
344
|
+
* its fetched window within the bar rect it's handed directly rather than
|
|
345
|
+
* against the clip's own full span. */
|
|
346
|
+
viewport: Viewport
|
|
347
|
+
/** Called once when new data lands for a key that was previously
|
|
348
|
+
* loading/absent — the caller's cue to `requestRedraw('content')`. */
|
|
349
|
+
onReady: () => void
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** What `drawTimelineContent` (draw.ts) needs from T6, kept as an interface
|
|
353
|
+
* so draw.ts only takes a type-only dependency on this module. */
|
|
354
|
+
export interface WaveformSceneLookup {
|
|
355
|
+
clipColumns(item: VisualItem, rect: Rect): WaveformColumn[] | null
|
|
356
|
+
audioColumns(track: AudioTrack, rect: Rect): { rect: Rect; columns: WaveformColumn[] } | null
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The on-screen [left, left+width) span of a start..end timeline range,
|
|
361
|
+
* unclamped — a clip's or audio track's own true screen extent, NOT the
|
|
362
|
+
* surface-clamped rect the row/lane painter actually draws into. Shared by
|
|
363
|
+
* `visibleSpanFraction` below and (for audio bars) `audioColumns` directly,
|
|
364
|
+
* which needs it a second time to POSITION the visible slice it draws.
|
|
365
|
+
*
|
|
366
|
+
* `null` for a degenerate (non-positive) span — nothing to divide by.
|
|
367
|
+
*/
|
|
368
|
+
function fullSpanX(start: number, end: number, viewport: Viewport): { left: number; width: number } | null {
|
|
369
|
+
const left = timeToX(start, viewport)
|
|
370
|
+
const width = timeToX(end, viewport) - left
|
|
371
|
+
return width > 0 ? { left, width } : null
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Fraction [f0, f1] of a start..end timeline range's own FULL on-screen span
|
|
376
|
+
* (see `fullSpanX`) that the CLAMPED `rect` it was actually handed to draw
|
|
377
|
+
* into covers. Shared by `visibleClipSampleRange` (where this fraction maps
|
|
378
|
+
* directly onto the fetched peaks array) and `audioColumns` (where it maps
|
|
379
|
+
* onto the trim WINDOW first — one step further removed from the sample
|
|
380
|
+
* array, because the fetched data can itself be a clamped sub-range of that
|
|
381
|
+
* window; see `sourceTimeToBarFraction`).
|
|
382
|
+
*
|
|
383
|
+
* Before either caller accounted for this, they resampled/positioned using
|
|
384
|
+
* the WHOLE clip/track's data against whatever width `rect` happened to be —
|
|
385
|
+
* correct only while the whole clip/track fit on screen, and pinned to "the
|
|
386
|
+
* whole thing" at every scroll position once it didn't (long clip or track:
|
|
387
|
+
* the waveform never changed as you scrolled through it, only the filmstrip
|
|
388
|
+
* or ruler above it did).
|
|
389
|
+
*
|
|
390
|
+
* `null` for a degenerate (zero-width) full span — nothing to divide by.
|
|
391
|
+
*/
|
|
392
|
+
function visibleSpanFraction(start: number, end: number, rect: Rect, viewport: Viewport): { f0: number; f1: number } | null {
|
|
393
|
+
const span = fullSpanX(start, end, viewport)
|
|
394
|
+
if (!span) return null
|
|
395
|
+
// Clamped to [0, 1]: `rect` is the drawn body, which can only ever be a
|
|
396
|
+
// sub-span of the full extent, but float rounding at the very edges could
|
|
397
|
+
// otherwise nudge a fraction a hair outside that range.
|
|
398
|
+
const f0 = Math.max(0, Math.min(1, (rect.x - span.left) / span.width))
|
|
399
|
+
const f1 = Math.max(0, Math.min(1, (rect.x + rect.width - span.left) / span.width))
|
|
400
|
+
return { f0, f1 }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The [s0, s1) sample-index sub-range of a clip's peaks that the CLAMPED,
|
|
405
|
+
* gutter-inset `rect` actually shows on screen. Mirrors `filmstrips.ts`'s
|
|
406
|
+
* `clipTiles`, which derives its tile positions from the clip's full
|
|
407
|
+
* on-screen span rather than from whatever clamped rect the row painter
|
|
408
|
+
* happened to hand it.
|
|
409
|
+
*
|
|
410
|
+
* `null` for a degenerate (zero-width) full span or empty peaks — nothing to
|
|
411
|
+
* divide by; the caller falls back to treating the whole array as visible.
|
|
412
|
+
*/
|
|
413
|
+
export function visibleClipSampleRange(
|
|
414
|
+
item: VisualItem,
|
|
415
|
+
rect: Rect,
|
|
416
|
+
viewport: Viewport,
|
|
417
|
+
totalSamples: number,
|
|
418
|
+
): { s0: number; s1: number } | null {
|
|
419
|
+
if (totalSamples <= 0) return null
|
|
420
|
+
const frac = visibleSpanFraction(item.start, item.end, rect, viewport)
|
|
421
|
+
if (!frac) return null
|
|
422
|
+
|
|
423
|
+
const s0 = Math.min(totalSamples, Math.floor(frac.f0 * totalSamples))
|
|
424
|
+
const s1 = Math.max(s0, Math.min(totalSamples, Math.ceil(frac.f1 * totalSamples)))
|
|
425
|
+
return { s0, s1 }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Slice a WHOLE-proxy peaks array down to a clip's own trim window —
|
|
430
|
+
* `[window.start, window.start + window.duration]` expressed as a fraction
|
|
431
|
+
* of the proxy's own total duration — before any viewport slicing happens.
|
|
432
|
+
* `clipColumns` applies two nested fractions in sequence: proxy → clip trim
|
|
433
|
+
* window (this function) → visible viewport slice (`visibleClipSampleRange`,
|
|
434
|
+
* applied afterward, on the RESULT of this one).
|
|
435
|
+
*
|
|
436
|
+
* `null` when `proxyDuration` isn't usable (`<= 0`) — nothing to divide by,
|
|
437
|
+
* and the caller falls back to fetching this clip's own windowed peaks
|
|
438
|
+
* rather than guess a fraction against an unknown total.
|
|
439
|
+
*/
|
|
440
|
+
function sliceToSourceWindow(
|
|
441
|
+
peaks: number[],
|
|
442
|
+
proxyDuration: number,
|
|
443
|
+
window: { start: number; duration: number },
|
|
444
|
+
): number[] | null {
|
|
445
|
+
if (!(proxyDuration > 0)) return null
|
|
446
|
+
const totalSamples = Math.floor(peaks.length / 2)
|
|
447
|
+
if (totalSamples <= 0) return peaks
|
|
448
|
+
const f0 = Math.max(0, Math.min(1, window.start / proxyDuration))
|
|
449
|
+
const f1 = Math.max(0, Math.min(1, (window.start + window.duration) / proxyDuration))
|
|
450
|
+
const s0 = Math.min(totalSamples, Math.floor(f0 * totalSamples))
|
|
451
|
+
const s1 = Math.max(s0, Math.min(totalSamples, Math.ceil(f1 * totalSamples)))
|
|
452
|
+
return peaks.slice(s0 * 2, s1 * 2)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Fetch state, keyed as `projectId|ownerId|src|start|duration|bucket` — the
|
|
457
|
+
* same shape the montaj adapter's own peaks cache uses
|
|
458
|
+
* (`projectId:src:start:duration:bucket`, see `montajAdapter.ts`'s
|
|
459
|
+
* `getWaveformPeaks`), plus an owner id since one store instance serves
|
|
460
|
+
* every clip and audio track on the surface.
|
|
461
|
+
*
|
|
462
|
+
* `ownerId`/`start`/`duration` are OMITTED from a CLIP's key — see
|
|
463
|
+
* `clipColumns` — because the editing proxy is shared, unwindowed data every
|
|
464
|
+
* clip pointing at that `proxySrc` can reuse regardless of which clip or
|
|
465
|
+
* which trim is asking; splitting or trimming a clip must not change its
|
|
466
|
+
* key. Audio-lane fetches keep the FULL key (owner + window): audio tracks
|
|
467
|
+
* have their own per-take sources, never a shared proxy, so there is nothing
|
|
468
|
+
* to gain by dropping either field there and doing so would only make an
|
|
469
|
+
* audio track's own trim stop invalidating its own cache entry.
|
|
470
|
+
*
|
|
471
|
+
* One instance lives for the lifetime of a mounted `TimelineCanvas` (held in
|
|
472
|
+
* a ref), so fetches already in flight or resolved survive across redraws.
|
|
473
|
+
*
|
|
474
|
+
* Rendering always prefers the highest-resolution READY entry across ALL
|
|
475
|
+
* three buckets for a key, not just the one matching the current zoom's
|
|
476
|
+
* target bucket — that is what makes zoom-OUT free (a previously-fetched
|
|
477
|
+
* 800-bucket entry renders fine downsampled by `resamplePeaksToColumns`, no
|
|
478
|
+
* new fetch) while zoom-IN still fetches the next bucket up exactly once.
|
|
479
|
+
*/
|
|
480
|
+
export class WaveformPeaksStore {
|
|
481
|
+
private entries = new Map<string, Entry>()
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Clip waveforms fetch from `item.proxySrc` ONLY (never `item.src`) and
|
|
485
|
+
* only for `type: 'video'` items — images/overlays have no audio. Absent
|
|
486
|
+
* proxy, absent adapter method, or a non-positive window all resolve to
|
|
487
|
+
* `null` (no waveform yet), never an error.
|
|
488
|
+
*
|
|
489
|
+
* The fetch itself is UNWINDOWED — the whole proxy — and keyed by src
|
|
490
|
+
* alone (see `WaveformPeaksStore`'s own doc), so every clip, and every
|
|
491
|
+
* split/trim of every clip, sharing one `proxySrc` resolves from the SAME
|
|
492
|
+
* cache entry: no re-decode, no blank-frame flash when a clip is split or
|
|
493
|
+
* trimmed. The clip's own [inPoint, outPoint] window is then sliced out of
|
|
494
|
+
* that shared data LOCALLY (`sliceToSourceWindow`), and the existing
|
|
495
|
+
* viewport slice (`visibleClipSampleRange`) applied on top of THAT — two
|
|
496
|
+
* nested fractions, proxy → clip window → visible span.
|
|
497
|
+
*/
|
|
498
|
+
clipColumns(item: VisualItem, rect: Rect, ctx: WaveformQueryContext): WaveformColumn[] | null {
|
|
499
|
+
if (item.type !== 'video' || !item.proxySrc || !ctx.getWaveformPeaks) return null
|
|
500
|
+
if (rect.width <= 0) return null
|
|
501
|
+
const window = clipSourceWindow(item)
|
|
502
|
+
if (window && window.duration <= 0) return null
|
|
503
|
+
|
|
504
|
+
const whole = this.resolve({ src: item.proxySrc }, ctx)
|
|
505
|
+
if (!whole) return null
|
|
506
|
+
|
|
507
|
+
const columns = Math.max(1, Math.round(rect.width))
|
|
508
|
+
let peaks = whole.peaks
|
|
509
|
+
if (window) {
|
|
510
|
+
// Prefer the fetch's OWN duration — since this is an unwindowed
|
|
511
|
+
// request, it should already be the proxy's true total length —
|
|
512
|
+
// falling back to the item's own record of it only if that's
|
|
513
|
+
// unusable (e.g. a step that doesn't report duration on an
|
|
514
|
+
// unwindowed fetch).
|
|
515
|
+
const proxyDuration = whole.duration > 0 ? whole.duration : (item.sourceDuration ?? 0)
|
|
516
|
+
const sliced = sliceToSourceWindow(whole.peaks, proxyDuration, window)
|
|
517
|
+
if (sliced) {
|
|
518
|
+
peaks = sliced
|
|
519
|
+
} else {
|
|
520
|
+
// Duration unknowable from either the fetch or the item — no way to
|
|
521
|
+
// map the trim window onto the whole-proxy peaks reliably. Falls
|
|
522
|
+
// back to the pre-fix behaviour: fetch (and cache) this clip's OWN
|
|
523
|
+
// windowed peaks, keyed by id + window. No cross-clip sharing in
|
|
524
|
+
// this rare case, but still the correct picture rather than a
|
|
525
|
+
// guess.
|
|
526
|
+
const windowed = this.resolve({ ownerId: item.id, src: item.proxySrc, start: window.start, duration: window.duration }, ctx)
|
|
527
|
+
if (!windowed) return null
|
|
528
|
+
peaks = windowed.peaks
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const totalSamples = Math.floor(peaks.length / 2)
|
|
533
|
+
const span = visibleClipSampleRange(item, rect, ctx.viewport, totalSamples)
|
|
534
|
+
// No full span to divide by (e.g. zero pxPerSecond) — fall back to the
|
|
535
|
+
// pre-fix behaviour, the whole (already window-sliced) clip resampled
|
|
536
|
+
// into `columns`, rather than drawing nothing.
|
|
537
|
+
if (!span) return resamplePeaksToColumns(peaks, columns)
|
|
538
|
+
const slice = peaks.slice(span.s0 * 2, span.s1 * 2)
|
|
539
|
+
return resamplePeaksToColumns(slice, columns)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Audio-lane waveforms fetch from `track.src` (audio tracks have no
|
|
544
|
+
* proxies), windowed to the track's own trim (`audioTrackSourceWindow`) —
|
|
545
|
+
* NOT to whatever is currently visible, so scrolling never re-fetches.
|
|
546
|
+
* Returns the VISIBLE slice's own bar-relative sub-rect alongside its
|
|
547
|
+
* columns, so it draws in the right place rather than stretched across
|
|
548
|
+
* whatever rect the caller happened to hand in.
|
|
549
|
+
*
|
|
550
|
+
* Two things had to be reconciled to get there, both expressed as
|
|
551
|
+
* fractions of the track's trim WINDOW so they compose with one `Math.max`/
|
|
552
|
+
* `Math.min`:
|
|
553
|
+
* - `dataX0..dataX1` (ported from before this fix): the fraction of the
|
|
554
|
+
* window the FETCHED data actually covers — the step can return less
|
|
555
|
+
* than requested (e.g. clamped near the end of a source file).
|
|
556
|
+
* - `visible.f0..f1` (new): the fraction of the track's own FULL
|
|
557
|
+
* on-screen span (`track.start`..`track.end` through `viewport`,
|
|
558
|
+
* unclamped) that the CLAMPED `rect` we were handed to draw into
|
|
559
|
+
* actually shows. Before this existed, `rect` was taken at face value
|
|
560
|
+
* as "the whole bar" — fine while the bar fit on screen, but once a
|
|
561
|
+
* long track ran off both edges, the ENTIRE window got squeezed into
|
|
562
|
+
* whatever sliver was visible, unchanged as you scrolled through it
|
|
563
|
+
* (the same bug `visibleClipSampleRange` fixed for clip waveforms).
|
|
564
|
+
* Only their overlap is fetched data AND currently visible — anything else
|
|
565
|
+
* is either data we don't have or off screen, neither worth drawing.
|
|
566
|
+
*/
|
|
567
|
+
audioColumns(track: AudioTrack, rect: Rect, ctx: WaveformQueryContext): { rect: Rect; columns: WaveformColumn[] } | null {
|
|
568
|
+
if (!ctx.getWaveformPeaks || rect.width <= 0) return null
|
|
569
|
+
const window = audioTrackSourceWindow(track)
|
|
570
|
+
if (window.duration <= 0) return null
|
|
571
|
+
|
|
572
|
+
const data = this.resolve({ ownerId: track.id, src: track.src, start: window.start, duration: window.duration }, ctx)
|
|
573
|
+
if (!data) return null
|
|
574
|
+
|
|
575
|
+
const dataX0 = sourceTimeToBarFraction(data.start, window)
|
|
576
|
+
const dataX1 = sourceTimeToBarFraction(data.start + data.duration, window)
|
|
577
|
+
// A degenerate/absent full span (e.g. `track.end <= track.start`) falls
|
|
578
|
+
// back to "the whole window is visible" — the pre-fix assumption — rather
|
|
579
|
+
// than drawing nothing over what may still be a perfectly good `rect`.
|
|
580
|
+
const visible = visibleSpanFraction(track.start, track.end, rect, ctx.viewport) ?? { f0: 0, f1: 1 }
|
|
581
|
+
|
|
582
|
+
const f0 = Math.max(dataX0, visible.f0)
|
|
583
|
+
const f1 = Math.min(dataX1, visible.f1)
|
|
584
|
+
if (f1 <= f0) return null
|
|
585
|
+
|
|
586
|
+
// Position the visible slice against the bar's TRUE full on-screen span,
|
|
587
|
+
// not the clamped `rect` — that's what lets it land at the right x once
|
|
588
|
+
// the bar runs off an edge. Falls back to `rect` itself alongside the
|
|
589
|
+
// same fallback `visible` used above.
|
|
590
|
+
const full = fullSpanX(track.start, track.end, ctx.viewport)
|
|
591
|
+
const subRect: Rect = full
|
|
592
|
+
? { x: full.left + f0 * full.width, y: rect.y, width: Math.max(0, (f1 - f0) * full.width), height: rect.height }
|
|
593
|
+
: { x: rect.x + f0 * rect.width, y: rect.y, width: Math.max(0, (f1 - f0) * rect.width), height: rect.height }
|
|
594
|
+
if (subRect.width <= 0) return null
|
|
595
|
+
|
|
596
|
+
// Slice the fetched peaks down to just the visible fraction of the
|
|
597
|
+
// fetched span (`f0..f1` relative to `dataX0..dataX1`) before resampling
|
|
598
|
+
// — only ever the columns actually drawn get computed, mirroring
|
|
599
|
+
// `visibleClipSampleRange`.
|
|
600
|
+
const totalSamples = Math.floor(data.peaks.length / 2)
|
|
601
|
+
const dataSpan = dataX1 - dataX0
|
|
602
|
+
const df0 = dataSpan > 0 ? (f0 - dataX0) / dataSpan : 0
|
|
603
|
+
const df1 = dataSpan > 0 ? (f1 - dataX0) / dataSpan : 1
|
|
604
|
+
const s0 = Math.max(0, Math.min(totalSamples, Math.floor(df0 * totalSamples)))
|
|
605
|
+
const s1 = Math.max(s0, Math.min(totalSamples, Math.ceil(df1 * totalSamples)))
|
|
606
|
+
const slice = data.peaks.slice(s0 * 2, s1 * 2)
|
|
607
|
+
|
|
608
|
+
const columns = resamplePeaksToColumns(slice, Math.max(1, Math.round(subRect.width)))
|
|
609
|
+
return { rect: subRect, columns }
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Look up the best already-fetched data for `key` and, if nothing at the
|
|
614
|
+
* needed bucket or higher is ready/loading, kick off a fetch for it.
|
|
615
|
+
* Errors are swallowed onto the entry (`status: 'error'`) — no throw, no
|
|
616
|
+
* console noise, no retry loop of our own; because an errored entry is
|
|
617
|
+
* neither `ready` nor `loading`, the NEXT call that still needs this key
|
|
618
|
+
* (e.g. the next redraw for an unrelated reason) will attempt the fetch
|
|
619
|
+
* again, same as the adapter's own evict-on-reject cache does one layer
|
|
620
|
+
* down.
|
|
621
|
+
*/
|
|
622
|
+
private resolve(
|
|
623
|
+
key: { ownerId?: string; src: string; start?: number; duration?: number },
|
|
624
|
+
ctx: WaveformQueryContext,
|
|
625
|
+
): PeaksData | null {
|
|
626
|
+
const bucket = resolveBucket(ctx.pxPerSecond)
|
|
627
|
+
const base = `${ctx.projectId}|${key.ownerId ?? ''}|${key.src}|${key.start ?? ''}|${key.duration ?? ''}`
|
|
628
|
+
|
|
629
|
+
let best: PeaksData | null = null
|
|
630
|
+
let covered = false
|
|
631
|
+
for (const b of PEAKS_BUCKETS) {
|
|
632
|
+
const entry = this.entries.get(`${base}|${b}`)
|
|
633
|
+
if (entry?.status === 'ready' && entry.data) {
|
|
634
|
+
// Compare by the DATA's actual samplesPerSecond, not the requested
|
|
635
|
+
// bucket label — the step's total-samples clamp can step a request
|
|
636
|
+
// down, and the clamp amount depends on window length, not which
|
|
637
|
+
// bucket was asked for, so the requested label alone isn't a
|
|
638
|
+
// reliable resolution ordering.
|
|
639
|
+
if (!best || entry.data.samplesPerSecond > best.samplesPerSecond) best = entry.data
|
|
640
|
+
}
|
|
641
|
+
if (b >= bucket && (entry?.status === 'ready' || entry?.status === 'loading')) covered = true
|
|
642
|
+
if (b >= bucket && entry?.status === 'error'
|
|
643
|
+
&& Date.now() - (entry.erroredAt ?? 0) < FETCH_RETRY_COOLDOWN_MS) covered = true
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!covered) {
|
|
647
|
+
const fetchKey = `${base}|${bucket}`
|
|
648
|
+
const fetcher = ctx.getWaveformPeaks
|
|
649
|
+
if (fetcher) {
|
|
650
|
+
this.entries.set(fetchKey, { status: 'loading', data: null })
|
|
651
|
+
fetcher({ projectId: ctx.projectId, src: key.src, samplesPerSecond: bucket, start: key.start, duration: key.duration })
|
|
652
|
+
.then(data => {
|
|
653
|
+
this.entries.set(fetchKey, { status: 'ready', data })
|
|
654
|
+
ctx.onReady()
|
|
655
|
+
})
|
|
656
|
+
.catch(() => {
|
|
657
|
+
this.entries.set(fetchKey, { status: 'error', data: null, erroredAt: Date.now() })
|
|
658
|
+
})
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return best
|
|
663
|
+
}
|
|
664
|
+
}
|