@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,946 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP5 T6 — canvas waveform rendering: the ported DOM trim-alignment math, the
|
|
3
|
+
* resample-to-pixel-columns algorithm, the bucketed fetch-state store, and
|
|
4
|
+
* the draw hooks wired into `drawClipRect`/`drawAudioItem`/`drawTimelineContent`
|
|
5
|
+
* (draw.ts).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
8
|
+
import type { AudioTrack, VisualItem } from '../../../../schema'
|
|
9
|
+
import type { Project } from '../../../../types'
|
|
10
|
+
import type { PeaksData } from '../../../../types'
|
|
11
|
+
import {
|
|
12
|
+
audioTrackSourceWindow,
|
|
13
|
+
clipSourceWindow,
|
|
14
|
+
clipWaveformBand,
|
|
15
|
+
drawAudioLaneWaveform,
|
|
16
|
+
drawClipWaveform,
|
|
17
|
+
drawWaveformBars,
|
|
18
|
+
FETCH_RETRY_COOLDOWN_MS,
|
|
19
|
+
resamplePeaksToColumns,
|
|
20
|
+
resolveBucket,
|
|
21
|
+
sourceTimeToBarFraction,
|
|
22
|
+
WaveformPeaksStore,
|
|
23
|
+
WAVEFORM_COLORS,
|
|
24
|
+
type WaveformColumn,
|
|
25
|
+
type WaveformQueryContext,
|
|
26
|
+
} from '../waveforms'
|
|
27
|
+
import {
|
|
28
|
+
computeTimelineLayout,
|
|
29
|
+
drawAudioItem,
|
|
30
|
+
drawClipRect,
|
|
31
|
+
drawTimelineContent,
|
|
32
|
+
type DrawContext,
|
|
33
|
+
type Rect,
|
|
34
|
+
type TimelineScene,
|
|
35
|
+
} from '../draw'
|
|
36
|
+
import type { Viewport } from '../viewport'
|
|
37
|
+
import { clipBands } from '../clip-bands'
|
|
38
|
+
|
|
39
|
+
// ── Recording context (mirrors draw.test.ts's convention) ────────────────
|
|
40
|
+
|
|
41
|
+
interface RecordedCall { method: string; args: unknown[] }
|
|
42
|
+
|
|
43
|
+
function recordingContext() {
|
|
44
|
+
const calls: RecordedCall[] = []
|
|
45
|
+
const props: Record<string, unknown> = {}
|
|
46
|
+
const proxy = new Proxy({}, {
|
|
47
|
+
get(_t, prop: string) {
|
|
48
|
+
if (prop in props) return props[prop]
|
|
49
|
+
if (prop === 'createLinearGradient') {
|
|
50
|
+
return (...args: unknown[]) => {
|
|
51
|
+
calls.push({ method: 'createLinearGradient', args })
|
|
52
|
+
return { addColorStop: (...s: unknown[]) => calls.push({ method: 'addColorStop', args: s }) } as unknown as CanvasGradient
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return (...args: unknown[]) => { calls.push({ method: prop, args }) }
|
|
56
|
+
},
|
|
57
|
+
set(_t, prop: string, value: unknown) {
|
|
58
|
+
props[prop] = value
|
|
59
|
+
calls.push({ method: `set:${prop}`, args: [value] })
|
|
60
|
+
return true
|
|
61
|
+
},
|
|
62
|
+
}) as unknown as DrawContext
|
|
63
|
+
return { ctx: proxy, calls, of: (m: string) => calls.filter(c => c.method === m) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Fixtures ─────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function clip(over: Partial<VisualItem> = {}): VisualItem {
|
|
69
|
+
return { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 2, ...over }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function audio(over: Partial<AudioTrack> = {}): AudioTrack {
|
|
73
|
+
return { id: 'a0', src: '/media/voice.mp3', start: 0, end: 2, ...over }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rect(over: Partial<Rect> = {}): Rect {
|
|
77
|
+
return { x: 0, y: 0, width: 100, height: 40, ...over }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function peaksFixture(over: Partial<PeaksData> = {}): PeaksData {
|
|
81
|
+
return { samplesPerSecond: 50, start: 0, duration: 2, peaks: [-100, 100, -200, 200, -50, 50, -300, 300], ...over }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function queryCtx(over: Partial<WaveformQueryContext> = {}): WaveformQueryContext {
|
|
85
|
+
return {
|
|
86
|
+
projectId: 'p1',
|
|
87
|
+
getWaveformPeaks: vi.fn().mockResolvedValue(peaksFixture()),
|
|
88
|
+
pxPerSecond: 30,
|
|
89
|
+
// Wide enough that the default `clip()` (0..2s at 30px/s = 60px) sits
|
|
90
|
+
// fully inside it, so tests that don't care about scroll/clamping see
|
|
91
|
+
// the whole clip's peaks, matching pre-fix behaviour.
|
|
92
|
+
viewport: { pxPerSecond: 30, scrollSeconds: 0, widthPx: 1000 },
|
|
93
|
+
onReady: vi.fn(),
|
|
94
|
+
...over,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Flush the microtask queue so a mocked fetcher's `.then()`/`.catch()` runs. */
|
|
99
|
+
function flush(): Promise<void> {
|
|
100
|
+
return new Promise(resolve => setTimeout(resolve, 0))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Bucket selection ───────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe('resolveBucket', () => {
|
|
106
|
+
it('picks 50 up to and including 50 px/s', () => {
|
|
107
|
+
expect(resolveBucket(1)).toBe(50)
|
|
108
|
+
expect(resolveBucket(49.9)).toBe(50)
|
|
109
|
+
expect(resolveBucket(50)).toBe(50)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('picks 200 above 50 and up to 200 px/s', () => {
|
|
113
|
+
expect(resolveBucket(50.1)).toBe(200)
|
|
114
|
+
expect(resolveBucket(199.9)).toBe(200)
|
|
115
|
+
expect(resolveBucket(200)).toBe(200)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('picks 800 above 200 px/s, including up to the viewport max', () => {
|
|
119
|
+
expect(resolveBucket(200.1)).toBe(800)
|
|
120
|
+
expect(resolveBucket(800)).toBe(800)
|
|
121
|
+
expect(resolveBucket(2000)).toBe(800)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ── Trim-alignment math (ported from AudioWaveformLayer.tsx:75-89) ───────
|
|
126
|
+
|
|
127
|
+
describe('audioTrackSourceWindow', () => {
|
|
128
|
+
it('uses inPoint/outPoint when both are present', () => {
|
|
129
|
+
expect(audioTrackSourceWindow(audio({ inPoint: 3, outPoint: 8 }))).toEqual({ start: 3, duration: 5 })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('falls back to end-start (1:1 audio: the clip′s timeline span IS its source-window duration) when outPoint is absent', () => {
|
|
133
|
+
expect(audioTrackSourceWindow(audio({ start: 5, end: 9 }))).toEqual({ start: 0, duration: 4 })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('yields a POSITIVE duration for a split clip whose right half has inPoint > 0 and no outPoint (regression: used to go negative)', () => {
|
|
137
|
+
// Real case: a "Recall drilled" clip split so its right half starts
|
|
138
|
+
// partway into the source (inPoint 3.198) with no outPoint set. The old
|
|
139
|
+
// formula misused `sourceDuration` (a LENGTH) as an absolute out-point,
|
|
140
|
+
// computing (end-start = 1.82) - inPoint(3.198) = -1.378 → duration <= 0
|
|
141
|
+
// → `audioColumns` bailed → the split clip's right half showed a blank
|
|
142
|
+
// waveform even though real peaks existed for it.
|
|
143
|
+
const w = audioTrackSourceWindow(audio({ inPoint: 3.198, outPoint: undefined, start: 6.85, end: 8.67 }))
|
|
144
|
+
expect(w.duration).toBeCloseTo(1.82)
|
|
145
|
+
expect(w.duration).toBeGreaterThan(0)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('clamps a collapsed/inverted trim to duration 0 rather than going negative, on either branch', () => {
|
|
149
|
+
// outPoint present, but before inPoint.
|
|
150
|
+
expect(audioTrackSourceWindow(audio({ inPoint: 8, outPoint: 3 }))).toEqual({ start: 8, duration: 0 })
|
|
151
|
+
// outPoint absent, but end before start.
|
|
152
|
+
expect(audioTrackSourceWindow(audio({ inPoint: 8, outPoint: undefined, start: 8, end: 3 }))).toEqual({ start: 8, duration: 0 })
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('sourceTimeToBarFraction', () => {
|
|
157
|
+
const window = { start: 5, duration: 10 }
|
|
158
|
+
|
|
159
|
+
it('maps the window start to 0 and the window end to 1 (ported leftPct/widthPct math)', () => {
|
|
160
|
+
expect(sourceTimeToBarFraction(5, window)).toBe(0)
|
|
161
|
+
expect(sourceTimeToBarFraction(15, window)).toBe(1)
|
|
162
|
+
expect(sourceTimeToBarFraction(10, window)).toBeCloseTo(0.5)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('extrapolates outside [start, start+duration] the same linear way the DOM percent math does', () => {
|
|
166
|
+
expect(sourceTimeToBarFraction(0, window)).toBeCloseTo(-0.5)
|
|
167
|
+
expect(sourceTimeToBarFraction(20, window)).toBeCloseTo(1.5)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('guards a non-positive duration to 0 rather than dividing by zero', () => {
|
|
171
|
+
expect(sourceTimeToBarFraction(5, { start: 5, duration: 0 })).toBe(0)
|
|
172
|
+
expect(sourceTimeToBarFraction(5, { start: 5, duration: -2 })).toBe(0)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('clipSourceWindow', () => {
|
|
177
|
+
it('returns undefined (fetch the whole proxy) when neither inPoint nor outPoint is set', () => {
|
|
178
|
+
expect(clipSourceWindow(clip())).toBeUndefined()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('uses inPoint/outPoint when both are present', () => {
|
|
182
|
+
expect(clipSourceWindow(clip({ inPoint: 1, outPoint: 4 }))).toEqual({ start: 1, duration: 3 })
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('defaults outPoint to sourceDuration when only inPoint is set', () => {
|
|
186
|
+
expect(clipSourceWindow(clip({ inPoint: 1, sourceDuration: 6 }))).toEqual({ start: 1, duration: 5 })
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('falls back to the clip′s own project-timeline duration when only inPoint is set and no sourceDuration', () => {
|
|
190
|
+
// No outPoint/sourceDuration to anchor the end of the window, so it's
|
|
191
|
+
// assumed to run for the clip's own on-timeline duration (3s) starting
|
|
192
|
+
// at inPoint — i.e. outPoint = inPoint + (end - start) = 1 + 3 = 4.
|
|
193
|
+
expect(clipSourceWindow(clip({ inPoint: 1, start: 0, end: 3 }))).toEqual({ start: 1, duration: 3 })
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('clamps a collapsed/inverted trim to duration 0 rather than going negative', () => {
|
|
197
|
+
expect(clipSourceWindow(clip({ inPoint: 5, outPoint: 2 }))).toEqual({ start: 5, duration: 0 })
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// ── Resample: peaks → pixel columns ───────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
describe('resamplePeaksToColumns', () => {
|
|
204
|
+
it('aggregates min/max when there are more samples than columns', () => {
|
|
205
|
+
// 8 samples, 4 columns → 2 samples/column.
|
|
206
|
+
const peaks = [-10, 10, -20, 5, -5, 30, -40, 40, -1, 1, -2, 50, -60, 2, -3, 3]
|
|
207
|
+
const cols = resamplePeaksToColumns(peaks, 4)
|
|
208
|
+
expect(cols).toHaveLength(4)
|
|
209
|
+
expect(cols[0]).toEqual({ min: -20 / 32768, max: 10 / 32768 })
|
|
210
|
+
expect(cols[1]).toEqual({ min: -40 / 32768, max: 40 / 32768 })
|
|
211
|
+
expect(cols[2]).toEqual({ min: -2 / 32768, max: 50 / 32768 })
|
|
212
|
+
expect(cols[3]).toEqual({ min: -60 / 32768, max: 3 / 32768 })
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('repeats samples when there are more columns than samples (zoomed in past native resolution)', () => {
|
|
216
|
+
// 2 samples, 5 columns → 0.4 samples/column.
|
|
217
|
+
const peaks = [-100, 200, -300, 400]
|
|
218
|
+
const cols = resamplePeaksToColumns(peaks, 5)
|
|
219
|
+
expect(cols).toEqual([
|
|
220
|
+
{ min: -100 / 32768, max: 200 / 32768 },
|
|
221
|
+
{ min: -100 / 32768, max: 200 / 32768 },
|
|
222
|
+
{ min: -100 / 32768, max: 200 / 32768 },
|
|
223
|
+
{ min: -300 / 32768, max: 400 / 32768 },
|
|
224
|
+
{ min: -300 / 32768, max: 400 / 32768 },
|
|
225
|
+
])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('maps 1:1 with no drift at the window edges when samples equal columns', () => {
|
|
229
|
+
const peaks = [-1, 1, -2, 2, -3, 3]
|
|
230
|
+
const cols = resamplePeaksToColumns(peaks, 3)
|
|
231
|
+
expect(cols).toEqual([
|
|
232
|
+
{ min: -1 / 32768, max: 1 / 32768 },
|
|
233
|
+
{ min: -2 / 32768, max: 2 / 32768 },
|
|
234
|
+
{ min: -3 / 32768, max: 3 / 32768 },
|
|
235
|
+
])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('returns an empty array for zero columns or empty peaks', () => {
|
|
239
|
+
expect(resamplePeaksToColumns([1, 2, 3, 4], 0)).toEqual([])
|
|
240
|
+
expect(resamplePeaksToColumns([], 4)).toEqual([])
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// ── Draw primitives ─────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
describe('drawWaveformBars', () => {
|
|
247
|
+
it('emits one fillRect per column, each within the rect′s x/y bounds', () => {
|
|
248
|
+
const r = recordingContext()
|
|
249
|
+
const bandRect = rect({ x: 10, y: 20, width: 40, height: 20 })
|
|
250
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }, { min: -0.5, max: 0.5 }, { min: 0, max: 0 }, { min: -1, max: 0.2 }]
|
|
251
|
+
drawWaveformBars(r.ctx, bandRect, columns, WAVEFORM_COLORS.clip)
|
|
252
|
+
|
|
253
|
+
const fills = r.of('fillRect')
|
|
254
|
+
expect(fills).toHaveLength(4)
|
|
255
|
+
for (const call of fills) {
|
|
256
|
+
const [x, y, w, h] = call.args as number[]
|
|
257
|
+
expect(x).toBeGreaterThanOrEqual(bandRect.x)
|
|
258
|
+
expect(x + w).toBeLessThanOrEqual(bandRect.x + bandRect.width + 0.001)
|
|
259
|
+
expect(y).toBeGreaterThanOrEqual(bandRect.y - 0.001)
|
|
260
|
+
expect(y + h).toBeLessThanOrEqual(bandRect.y + bandRect.height + 0.001)
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('draws nothing for a degenerate rect or an empty column list', () => {
|
|
265
|
+
const r = recordingContext()
|
|
266
|
+
drawWaveformBars(r.ctx, rect({ width: 0 }), [{ min: -1, max: 1 }], WAVEFORM_COLORS.clip)
|
|
267
|
+
drawWaveformBars(r.ctx, rect(), [], WAVEFORM_COLORS.clip)
|
|
268
|
+
expect(r.of('fillRect')).toHaveLength(0)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// ── Fade gain scaling (Vegas behaviour: the waveform shrinks to zero
|
|
272
|
+
// through a fade-out and grows from zero through a fade-in) ──────────
|
|
273
|
+
|
|
274
|
+
it('with no gainAt, draws exactly as before (full amplitude, unaffected)', () => {
|
|
275
|
+
const r = recordingContext()
|
|
276
|
+
const bandRect = rect({ x: 0, y: 0, width: 40, height: 20 })
|
|
277
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }, { min: -1, max: 1 }]
|
|
278
|
+
drawWaveformBars(r.ctx, bandRect, columns, WAVEFORM_COLORS.clip)
|
|
279
|
+
const [, y0, , h0] = r.of('fillRect')[0].args as number[]
|
|
280
|
+
// Full amplitude: top at rect.y (0), full band height (20).
|
|
281
|
+
expect(y0).toBeCloseTo(0)
|
|
282
|
+
expect(h0).toBeCloseTo(20)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('a column under gain 0 collapses to a hairline at the center — ~0 height, not full amplitude', () => {
|
|
286
|
+
const r = recordingContext()
|
|
287
|
+
const bandRect = rect({ x: 0, y: 0, width: 40, height: 20 })
|
|
288
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }]
|
|
289
|
+
drawWaveformBars(r.ctx, bandRect, columns, WAVEFORM_COLORS.clip, () => 0)
|
|
290
|
+
const [, y, , h] = r.of('fillRect')[0].args as number[]
|
|
291
|
+
const centerY = bandRect.y + bandRect.height / 2
|
|
292
|
+
// Collapsed to the center line: top sits at (or just above, the 1px
|
|
293
|
+
// floor) the vertical center, nowhere near the full 20px band height.
|
|
294
|
+
expect(y).toBeCloseTo(centerY, 0)
|
|
295
|
+
expect(h).toBeLessThan(2)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('gain 0.5 halves the amplitude relative to gain 1', () => {
|
|
299
|
+
const full = recordingContext()
|
|
300
|
+
const half = recordingContext()
|
|
301
|
+
const bandRect = rect({ x: 0, y: 0, width: 40, height: 20 })
|
|
302
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }]
|
|
303
|
+
drawWaveformBars(full.ctx, bandRect, columns, WAVEFORM_COLORS.clip, () => 1)
|
|
304
|
+
drawWaveformBars(half.ctx, bandRect, columns, WAVEFORM_COLORS.clip, () => 0.5)
|
|
305
|
+
const [, , , hFull] = full.of('fillRect')[0].args as number[]
|
|
306
|
+
const [, , , hHalf] = half.of('fillRect')[0].args as number[]
|
|
307
|
+
expect(hHalf).toBeCloseTo(hFull / 2, 1)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('samples gainAt PER COLUMN — a fade partway across the bar leaves untouched columns at full height', () => {
|
|
311
|
+
const r = recordingContext()
|
|
312
|
+
const bandRect = rect({ x: 0, y: 0, width: 40, height: 20 })
|
|
313
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }, { min: -1, max: 1 }]
|
|
314
|
+
// Silence the first column only (x < 20), full gain elsewhere.
|
|
315
|
+
drawWaveformBars(r.ctx, bandRect, columns, WAVEFORM_COLORS.clip, x => (x < 20 ? 0 : 1))
|
|
316
|
+
const fills = r.of('fillRect').map(c => c.args as number[])
|
|
317
|
+
expect(fills[0][3]).toBeLessThan(2) // first column: collapsed
|
|
318
|
+
expect(fills[1][3]).toBeCloseTo(20) // second column: untouched, full height
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
describe('drawAudioLaneWaveform gainAt passthrough', () => {
|
|
323
|
+
it('threads gainAt straight through to drawWaveformBars', () => {
|
|
324
|
+
const r = recordingContext()
|
|
325
|
+
const bandRect = rect({ x: 0, y: 0, width: 20, height: 20 })
|
|
326
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }]
|
|
327
|
+
drawAudioLaneWaveform(r.ctx, bandRect, columns, () => 0)
|
|
328
|
+
const [, , , h] = r.of('fillRect')[0].args as number[]
|
|
329
|
+
expect(h).toBeLessThan(2)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('omitting gainAt draws at full amplitude, same as before the fade-shape feature', () => {
|
|
333
|
+
const r = recordingContext()
|
|
334
|
+
const bandRect = rect({ x: 0, y: 0, width: 20, height: 20 })
|
|
335
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }]
|
|
336
|
+
drawAudioLaneWaveform(r.ctx, bandRect, columns)
|
|
337
|
+
const [, y, , h] = r.of('fillRect')[0].args as number[]
|
|
338
|
+
expect(y).toBeCloseTo(0)
|
|
339
|
+
expect(h).toBeCloseTo(20)
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
describe('clipWaveformBand', () => {
|
|
344
|
+
it('takes the LOWER HALF of the clip, not the thin bottom strip it started as', () => {
|
|
345
|
+
const r = rect({ x: 0, y: 0, width: 80, height: 120 })
|
|
346
|
+
const band = clipWaveformBand(r)
|
|
347
|
+
expect(band.height).toBe(59) // (120 - 2px inset) / 2
|
|
348
|
+
expect(band.y + band.height).toBeLessThanOrEqual(r.y + r.height)
|
|
349
|
+
expect(band.width).toBe(r.width)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('is exactly the band `clip-bands.ts` hands the filmstrip the other half of', () => {
|
|
353
|
+
// Same source of truth for both painters — this is what keeps the waveform
|
|
354
|
+
// from creeping up over the frames.
|
|
355
|
+
const r = rect({ height: 120 })
|
|
356
|
+
expect(clipWaveformBand(r)).toEqual(clipBands(r).waveform)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('shrinks (never grows past) a very short row so the band stays inside it', () => {
|
|
360
|
+
const band = clipWaveformBand(rect({ height: 6 }))
|
|
361
|
+
expect(band.height).toBeLessThanOrEqual(6)
|
|
362
|
+
expect(band.height).toBeGreaterThanOrEqual(0)
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
describe('drawClipWaveform muted variant', () => {
|
|
367
|
+
it('uses the normal clip/band colors when not muted', () => {
|
|
368
|
+
const r = recordingContext()
|
|
369
|
+
const bandRect = rect({ height: 40 })
|
|
370
|
+
drawClipWaveform(r.ctx, bandRect, [{ min: -1, max: 1 }])
|
|
371
|
+
|
|
372
|
+
const fillStyles = r.calls.filter(c => c.method === 'set:fillStyle').map(c => c.args[0])
|
|
373
|
+
expect(fillStyles).toContain(WAVEFORM_COLORS.clipBand)
|
|
374
|
+
expect(fillStyles).toContain(WAVEFORM_COLORS.clip)
|
|
375
|
+
expect(fillStyles).not.toContain(WAVEFORM_COLORS.clipBandMuted)
|
|
376
|
+
expect(fillStyles).not.toContain(WAVEFORM_COLORS.clipMuted)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('swaps in the grayed clipMuted/clipBandMuted pair when muted', () => {
|
|
380
|
+
const r = recordingContext()
|
|
381
|
+
const bandRect = rect({ height: 40 })
|
|
382
|
+
drawClipWaveform(r.ctx, bandRect, [{ min: -1, max: 1 }], true)
|
|
383
|
+
|
|
384
|
+
const fillStyles = r.calls.filter(c => c.method === 'set:fillStyle').map(c => c.args[0])
|
|
385
|
+
expect(fillStyles).toContain(WAVEFORM_COLORS.clipBandMuted)
|
|
386
|
+
expect(fillStyles).toContain(WAVEFORM_COLORS.clipMuted)
|
|
387
|
+
expect(fillStyles).not.toContain(WAVEFORM_COLORS.clipBand)
|
|
388
|
+
expect(fillStyles).not.toContain(WAVEFORM_COLORS.clip)
|
|
389
|
+
})
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
// ── drawClipRect / drawAudioItem content-layer hook ───────────────────────
|
|
393
|
+
|
|
394
|
+
describe('drawClipRect content hook', () => {
|
|
395
|
+
it('calls drawContent between the fill/border and the label, and a waveform painted there stays within the clip rect', () => {
|
|
396
|
+
const r = recordingContext()
|
|
397
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }, { min: -1, max: 1 }]
|
|
398
|
+
const clipRect = rect({ x: 5, y: 5, width: 60, height: 40 })
|
|
399
|
+
drawClipRect(r.ctx, {
|
|
400
|
+
rect: clipRect,
|
|
401
|
+
palette: { fill: '#111', fillSelected: '#222', ring: '#333', border: '#444', text: '#fff' },
|
|
402
|
+
selected: false,
|
|
403
|
+
label: 'video',
|
|
404
|
+
drawContent: (ctx, rc) => drawClipWaveform(ctx, rc, columns),
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
const fills = r.of('fillRect')
|
|
408
|
+
// [0]=the clip body fill, [1]=the darkened waveform band, then N bars. The
|
|
409
|
+
// clip's outline is a stroked path now, not a fillRect.
|
|
410
|
+
const waveformFills = fills.slice(2)
|
|
411
|
+
expect(waveformFills.length).toBe(columns.length)
|
|
412
|
+
for (const call of waveformFills) {
|
|
413
|
+
const [x, , w] = call.args as number[]
|
|
414
|
+
expect(x).toBeGreaterThanOrEqual(clipRect.x)
|
|
415
|
+
expect(x + w).toBeLessThanOrEqual(clipRect.x + clipRect.width + 0.001)
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('draws nothing extra when drawContent is absent', () => {
|
|
420
|
+
const r = recordingContext()
|
|
421
|
+
drawClipRect(r.ctx, {
|
|
422
|
+
rect: rect(),
|
|
423
|
+
palette: { fill: '#111', fillSelected: '#222', ring: '#333', border: '#444', text: '#fff' },
|
|
424
|
+
selected: false,
|
|
425
|
+
label: 'video',
|
|
426
|
+
})
|
|
427
|
+
// The body fill only — the outline is stroked, not filled.
|
|
428
|
+
expect(r.of('fillRect')).toHaveLength(1)
|
|
429
|
+
})
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
describe('drawAudioItem content hook', () => {
|
|
433
|
+
it('clips drawContent to the bar the same way fades/label are clipped', () => {
|
|
434
|
+
const r = recordingContext()
|
|
435
|
+
const barRect = rect({ x: 5, y: 5, width: 60, height: 32 })
|
|
436
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }, { min: -0.3, max: 0.3 }, { min: -1, max: 1 }]
|
|
437
|
+
drawAudioItem(r.ctx, {
|
|
438
|
+
rect: barRect,
|
|
439
|
+
selected: false,
|
|
440
|
+
muted: false,
|
|
441
|
+
label: 'audio',
|
|
442
|
+
drawContent: (ctx, rc) => drawAudioLaneWaveform(ctx, rc, columns),
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// A clip() call must precede the waveform's fillRects so it's constrained
|
|
446
|
+
// to the bar's bounds.
|
|
447
|
+
const clipIdx = r.calls.findIndex(c => c.method === 'clip')
|
|
448
|
+
const firstFillAfterClip = r.calls.findIndex((c, i) => i > clipIdx && c.method === 'fillRect')
|
|
449
|
+
expect(clipIdx).toBeGreaterThanOrEqual(0)
|
|
450
|
+
expect(firstFillAfterClip).toBeGreaterThan(clipIdx)
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
// ── Fetch-state store ──────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
describe('WaveformPeaksStore.clipColumns', () => {
|
|
457
|
+
it('never fetches when the item has no proxySrc (graceful, no spinner)', () => {
|
|
458
|
+
const store = new WaveformPeaksStore()
|
|
459
|
+
const fetcher = vi.fn()
|
|
460
|
+
const result = store.clipColumns(clip(), rect(), queryCtx({ getWaveformPeaks: fetcher }))
|
|
461
|
+
expect(result).toBeNull()
|
|
462
|
+
expect(fetcher).not.toHaveBeenCalled()
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('never fetches for a non-video item even with a src set', () => {
|
|
466
|
+
const store = new WaveformPeaksStore()
|
|
467
|
+
const fetcher = vi.fn()
|
|
468
|
+
const result = store.clipColumns(clip({ type: 'image' } as Partial<VisualItem> as VisualItem), rect(), queryCtx({ getWaveformPeaks: fetcher }))
|
|
469
|
+
expect(result).toBeNull()
|
|
470
|
+
expect(fetcher).not.toHaveBeenCalled()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('fires a fetch once a proxySrc arrives (SSE import completion), since the key changes', async () => {
|
|
474
|
+
const store = new WaveformPeaksStore()
|
|
475
|
+
const fetcher = vi.fn().mockResolvedValue(peaksFixture())
|
|
476
|
+
const barRect = rect()
|
|
477
|
+
|
|
478
|
+
const before = store.clipColumns(clip(), barRect, queryCtx({ getWaveformPeaks: fetcher }))
|
|
479
|
+
expect(before).toBeNull()
|
|
480
|
+
expect(fetcher).not.toHaveBeenCalled()
|
|
481
|
+
|
|
482
|
+
const after = store.clipColumns(clip({ proxySrc: 'proxy.mp4' }), barRect, queryCtx({ getWaveformPeaks: fetcher }))
|
|
483
|
+
expect(after).toBeNull() // loading — not ready yet on this same call
|
|
484
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
485
|
+
expect(fetcher.mock.calls[0][0]).toMatchObject({ projectId: 'p1', src: 'proxy.mp4', samplesPerSecond: 50 })
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('returns resampled columns once the fetch resolves, and calls onReady exactly once', async () => {
|
|
489
|
+
const store = new WaveformPeaksStore()
|
|
490
|
+
const onReady = vi.fn()
|
|
491
|
+
const fetcher = vi.fn().mockResolvedValue(peaksFixture())
|
|
492
|
+
const item = clip({ proxySrc: 'proxy.mp4' })
|
|
493
|
+
const barRect = rect({ width: 50 })
|
|
494
|
+
|
|
495
|
+
store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, onReady }))
|
|
496
|
+
await flush()
|
|
497
|
+
expect(onReady).toHaveBeenCalledTimes(1)
|
|
498
|
+
|
|
499
|
+
const cols = store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, onReady }))
|
|
500
|
+
expect(cols).not.toBeNull()
|
|
501
|
+
expect(cols!.length).toBe(50)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('swallows a rejected fetch quietly (no throw) and retries on a later call once cooldown elapses', async () => {
|
|
505
|
+
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(0)
|
|
506
|
+
const store = new WaveformPeaksStore()
|
|
507
|
+
const onReady = vi.fn()
|
|
508
|
+
const fetcher = vi.fn()
|
|
509
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
510
|
+
.mockResolvedValueOnce(peaksFixture())
|
|
511
|
+
const item = clip({ proxySrc: 'proxy.mp4' })
|
|
512
|
+
const barRect = rect()
|
|
513
|
+
|
|
514
|
+
expect(() => store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, onReady }))).not.toThrow()
|
|
515
|
+
await flush()
|
|
516
|
+
expect(onReady).not.toHaveBeenCalled()
|
|
517
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
518
|
+
|
|
519
|
+
// Next call (e.g. the next redraw for an unrelated reason) retries once
|
|
520
|
+
// cooldown has elapsed — an errored entry is neither ready nor loading.
|
|
521
|
+
nowSpy.mockReturnValue(FETCH_RETRY_COOLDOWN_MS + 1)
|
|
522
|
+
store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, onReady }))
|
|
523
|
+
await flush()
|
|
524
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
525
|
+
expect(onReady).toHaveBeenCalledTimes(1)
|
|
526
|
+
nowSpy.mockRestore()
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('does not retry a still-cooling-down error on the very next call (paint-rate request storm)', async () => {
|
|
530
|
+
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(0)
|
|
531
|
+
const store = new WaveformPeaksStore()
|
|
532
|
+
const onReady = vi.fn()
|
|
533
|
+
const fetcher = vi.fn().mockRejectedValueOnce(new Error('boom'))
|
|
534
|
+
const item = clip({ proxySrc: 'proxy.mp4' })
|
|
535
|
+
const barRect = rect()
|
|
536
|
+
|
|
537
|
+
store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, onReady }))
|
|
538
|
+
await flush()
|
|
539
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
540
|
+
|
|
541
|
+
// Same mocked Date.now() as the error — still in cooldown, no refetch.
|
|
542
|
+
store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, onReady }))
|
|
543
|
+
await flush()
|
|
544
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
545
|
+
nowSpy.mockRestore()
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// ── Shared whole-proxy fetch (split/trim flash fix) ─────────────────────
|
|
549
|
+
// The editing proxy is the WHOLE source, never windowed server-side, so
|
|
550
|
+
// its peaks are identical no matter which clip — or which trim of which
|
|
551
|
+
// clip — is asking. `clipColumns` fetches it ONCE, keyed by src alone, and
|
|
552
|
+
// slices each clip's own [inPoint, outPoint] window out of it locally. A
|
|
553
|
+
// split (new item ids) or a trim (a new window) must not change the fetch
|
|
554
|
+
// key, or the waveform re-decodes and flashes blank for a frame.
|
|
555
|
+
|
|
556
|
+
it('a clip′s own window changing (a trim) reuses the SAME cached whole-proxy fetch — no re-fetch', async () => {
|
|
557
|
+
const store = new WaveformPeaksStore()
|
|
558
|
+
const fetcher = vi.fn().mockResolvedValue(peaksFixture())
|
|
559
|
+
const barRect = rect()
|
|
560
|
+
|
|
561
|
+
store.clipColumns(clip({ proxySrc: 'proxy.mp4', inPoint: 0, outPoint: 2 }), barRect, queryCtx({ getWaveformPeaks: fetcher }))
|
|
562
|
+
await flush()
|
|
563
|
+
const cols = store.clipColumns(clip({ proxySrc: 'proxy.mp4', inPoint: 0.3, outPoint: 1.7 }), barRect, queryCtx({ getWaveformPeaks: fetcher }))
|
|
564
|
+
|
|
565
|
+
// ONE fetch total — trimming the window did not change the cache key.
|
|
566
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
567
|
+
expect(fetcher.mock.calls[0][0].start).toBeUndefined()
|
|
568
|
+
expect(fetcher.mock.calls[0][0].duration).toBeUndefined()
|
|
569
|
+
expect(cols).not.toBeNull()
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('two clips with DIFFERENT ids sharing one proxySrc (a split) both resolve from the ONE cached fetch, each correctly window-sliced', async () => {
|
|
573
|
+
const store = new WaveformPeaksStore()
|
|
574
|
+
// 100-sample "whole proxy" standing in for a 10s source. The two halves
|
|
575
|
+
// have clearly distinct values so their resampled columns are easy to
|
|
576
|
+
// tell apart and check exactly.
|
|
577
|
+
const peaks: number[] = []
|
|
578
|
+
for (let i = 0; i < 100; i++) peaks.push(-(i + 1), i + 1)
|
|
579
|
+
const fetcher = vi.fn().mockResolvedValue(peaksFixture({ duration: 10, peaks }))
|
|
580
|
+
|
|
581
|
+
const firstHalf = clip({ id: 'first', proxySrc: 'shared.mp4', inPoint: 0, outPoint: 5, start: 0, end: 5 })
|
|
582
|
+
const secondHalf = clip({ id: 'second', proxySrc: 'shared.mp4', inPoint: 5, outPoint: 10, start: 0, end: 5 })
|
|
583
|
+
const vp = { pxPerSecond: 10, scrollSeconds: 0, widthPx: 1000 }
|
|
584
|
+
// Exactly the 5s clip's full on-screen span at 10px/s — the viewport
|
|
585
|
+
// slice this hands to `visibleClipSampleRange` is then a no-op, isolating
|
|
586
|
+
// the clip-WINDOW slicing this test is actually about.
|
|
587
|
+
const fullRect = rect({ x: 0, width: 50 })
|
|
588
|
+
|
|
589
|
+
store.clipColumns(firstHalf, fullRect, queryCtx({ getWaveformPeaks: fetcher, viewport: vp }))
|
|
590
|
+
await flush()
|
|
591
|
+
|
|
592
|
+
const firstCols = store.clipColumns(firstHalf, fullRect, queryCtx({ getWaveformPeaks: fetcher, viewport: vp }))
|
|
593
|
+
const secondCols = store.clipColumns(secondHalf, fullRect, queryCtx({ getWaveformPeaks: fetcher, viewport: vp }))
|
|
594
|
+
|
|
595
|
+
// ONE fetch total, for the WHOLE proxy — the second clip's different id
|
|
596
|
+
// and different window both reused it instead of triggering their own.
|
|
597
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
598
|
+
expect(fetcher.mock.calls[0][0]).toMatchObject({ src: 'shared.mp4' })
|
|
599
|
+
expect(fetcher.mock.calls[0][0].start).toBeUndefined()
|
|
600
|
+
expect(fetcher.mock.calls[0][0].duration).toBeUndefined()
|
|
601
|
+
|
|
602
|
+
// Each clip still gets its OWN correctly-windowed slice of that one fetch.
|
|
603
|
+
expect(firstCols).not.toBeNull()
|
|
604
|
+
expect(secondCols).not.toBeNull()
|
|
605
|
+
expect(firstCols).not.toEqual(secondCols)
|
|
606
|
+
expect(firstCols).toEqual(resamplePeaksToColumns(peaks.slice(0, 100), 50))
|
|
607
|
+
expect(secondCols).toEqual(resamplePeaksToColumns(peaks.slice(100, 200), 50))
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('falls back to a windowed per-clip fetch when the whole-proxy duration is unknowable', async () => {
|
|
611
|
+
const store = new WaveformPeaksStore()
|
|
612
|
+
const fetcher = vi.fn()
|
|
613
|
+
.mockResolvedValueOnce(peaksFixture({ duration: 0 })) // whole-proxy attempt: unusable duration
|
|
614
|
+
.mockResolvedValueOnce(peaksFixture({ start: 3, duration: 2, peaks: [-9, 9, -8, 8] })) // windowed fallback
|
|
615
|
+
const item = clip({ proxySrc: 'proxy.mp4', inPoint: 3, outPoint: 5 }) // no sourceDuration either
|
|
616
|
+
const barRect = rect()
|
|
617
|
+
const ctx = () => queryCtx({ getWaveformPeaks: fetcher })
|
|
618
|
+
|
|
619
|
+
store.clipColumns(item, barRect, ctx()) // triggers the whole-proxy fetch
|
|
620
|
+
await flush() // resolves with an unusable duration: 0
|
|
621
|
+
store.clipColumns(item, barRect, ctx()) // duration known-unusable -> falls back to a windowed fetch
|
|
622
|
+
await flush() // windowed fetch resolves
|
|
623
|
+
const cols = store.clipColumns(item, barRect, ctx())
|
|
624
|
+
|
|
625
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
626
|
+
expect(fetcher.mock.calls[1][0]).toMatchObject({ src: 'proxy.mp4', start: 3, duration: 2 })
|
|
627
|
+
expect(cols).not.toBeNull()
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('does not re-fetch on zoom-out: a higher bucket already ready is reused', async () => {
|
|
631
|
+
const store = new WaveformPeaksStore()
|
|
632
|
+
const big = Array.from({ length: 800 * 2 }, (_, i) => (i % 2 === 0 ? -100 : 100))
|
|
633
|
+
const fetcher = vi.fn().mockResolvedValue(peaksFixture({ samplesPerSecond: 800, duration: 1, peaks: big }))
|
|
634
|
+
const item = clip({ proxySrc: 'proxy.mp4' })
|
|
635
|
+
const barRect = rect()
|
|
636
|
+
|
|
637
|
+
store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, pxPerSecond: 1000 })) // bucket 800
|
|
638
|
+
await flush()
|
|
639
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
640
|
+
expect(fetcher.mock.calls[0][0].samplesPerSecond).toBe(800)
|
|
641
|
+
|
|
642
|
+
const zoomedOut = store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, pxPerSecond: 10 })) // bucket 50
|
|
643
|
+
expect(fetcher).toHaveBeenCalledTimes(1) // unchanged — no new fetch
|
|
644
|
+
expect(zoomedOut).not.toBeNull()
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('fetches the next bucket on zoom-in, keeping the old bucket′s data until it resolves', async () => {
|
|
648
|
+
const store = new WaveformPeaksStore()
|
|
649
|
+
const lowRes = peaksFixture({ samplesPerSecond: 50, duration: 1, peaks: [-1, 1, -1, 1] })
|
|
650
|
+
const highRes = peaksFixture({ samplesPerSecond: 800, duration: 1, peaks: Array.from({ length: 800 * 2 }, () => 5) })
|
|
651
|
+
let resolveHigh: (v: PeaksData) => void = () => {}
|
|
652
|
+
const fetcher = vi.fn()
|
|
653
|
+
.mockResolvedValueOnce(lowRes)
|
|
654
|
+
.mockImplementationOnce(() => new Promise<PeaksData>(resolve => { resolveHigh = resolve }))
|
|
655
|
+
const item = clip({ proxySrc: 'proxy.mp4' })
|
|
656
|
+
const barRect = rect()
|
|
657
|
+
|
|
658
|
+
store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, pxPerSecond: 10 })) // bucket 50
|
|
659
|
+
await flush()
|
|
660
|
+
const beforeZoomIn = store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, pxPerSecond: 10 }))
|
|
661
|
+
expect(beforeZoomIn).not.toBeNull()
|
|
662
|
+
|
|
663
|
+
// Zoom in past 200 → triggers an 800-bucket fetch that hasn't resolved yet.
|
|
664
|
+
const midFlight = store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, pxPerSecond: 1000 }))
|
|
665
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
666
|
+
expect(midFlight).not.toBeNull() // still the old (50-bucket) data, not null
|
|
667
|
+
|
|
668
|
+
resolveHigh(highRes)
|
|
669
|
+
await flush()
|
|
670
|
+
const afterResolve = store.clipColumns(item, barRect, queryCtx({ getWaveformPeaks: fetcher, pxPerSecond: 1000 }))
|
|
671
|
+
expect(afterResolve).not.toBeNull()
|
|
672
|
+
expect(fetcher).toHaveBeenCalledTimes(2) // no third fetch just from reading it again
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
// ── Viewport-aware slicing (scroll bug fix) ─────────────────────────────
|
|
676
|
+
// Before this, `clipColumns` always resampled the WHOLE clip's peaks into
|
|
677
|
+
// whatever width `rect` happened to be — correct only while the entire
|
|
678
|
+
// clip fit on screen. Once a clip ran off both edges of the viewport, the
|
|
679
|
+
// same "whole clip squeezed into the visible width" picture painted at
|
|
680
|
+
// every scroll position, because `rect` (clamped to the visible surface)
|
|
681
|
+
// was the only geometry `clipColumns` had to go on.
|
|
682
|
+
|
|
683
|
+
it('resamples a DIFFERENT sub-window of the peaks when the same on-screen rect maps to a different part of a long clip (scroll-dependent, not squeezed to the whole clip every time)', async () => {
|
|
684
|
+
const store = new WaveformPeaksStore()
|
|
685
|
+
// 100 monotonically-increasing samples so two different sub-ranges are
|
|
686
|
+
// guaranteed to differ.
|
|
687
|
+
const peaks: number[] = []
|
|
688
|
+
for (let i = 0; i < 100; i++) peaks.push(-(i + 1), i + 1)
|
|
689
|
+
const fetcher = vi.fn().mockResolvedValue(peaksFixture({ duration: 100, peaks }))
|
|
690
|
+
// A 100s clip at 10px/s spans 1000px on screen — far wider than the
|
|
691
|
+
// 200px viewport it's drawn into, so `rect` (the visible slice) is only
|
|
692
|
+
// ever a fraction of the clip's full span.
|
|
693
|
+
const item = clip({ proxySrc: 'proxy.mp4', start: 0, end: 100 })
|
|
694
|
+
const visibleRect = rect({ x: 0, width: 200 })
|
|
695
|
+
const atStart = { pxPerSecond: 10, scrollSeconds: 0, widthPx: 200 }
|
|
696
|
+
const scrolledIn = { pxPerSecond: 10, scrollSeconds: 40, widthPx: 200 }
|
|
697
|
+
|
|
698
|
+
// Warm the cache.
|
|
699
|
+
store.clipColumns(item, visibleRect, queryCtx({ getWaveformPeaks: fetcher, viewport: atStart }))
|
|
700
|
+
await flush()
|
|
701
|
+
|
|
702
|
+
const colsAtStart = store.clipColumns(item, visibleRect, queryCtx({ getWaveformPeaks: fetcher, viewport: atStart }))
|
|
703
|
+
const colsScrolledIn = store.clipColumns(item, visibleRect, queryCtx({ getWaveformPeaks: fetcher, viewport: scrolledIn }))
|
|
704
|
+
|
|
705
|
+
expect(colsAtStart).not.toBeNull()
|
|
706
|
+
expect(colsScrolledIn).not.toBeNull()
|
|
707
|
+
expect(colsAtStart).not.toEqual(colsScrolledIn)
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
it('matches the whole-clip resample when the full clip is on screen (unchanged from before the fix)', async () => {
|
|
711
|
+
const store = new WaveformPeaksStore()
|
|
712
|
+
const data = peaksFixture()
|
|
713
|
+
const fetcher = vi.fn().mockResolvedValue(data)
|
|
714
|
+
const item = clip({ proxySrc: 'proxy.mp4', start: 0, end: 2 })
|
|
715
|
+
// A 2s clip at 30px/s spans exactly 60px — hand clipColumns that as its
|
|
716
|
+
// rect, unclamped: the clip is fully visible.
|
|
717
|
+
const vp = { pxPerSecond: 30, scrollSeconds: 0, widthPx: 1000 }
|
|
718
|
+
const fullRect = rect({ x: 0, width: 60 })
|
|
719
|
+
|
|
720
|
+
store.clipColumns(item, fullRect, queryCtx({ getWaveformPeaks: fetcher, viewport: vp }))
|
|
721
|
+
await flush()
|
|
722
|
+
const cols = store.clipColumns(item, fullRect, queryCtx({ getWaveformPeaks: fetcher, viewport: vp }))
|
|
723
|
+
|
|
724
|
+
expect(cols).toEqual(resamplePeaksToColumns(data.peaks, 60))
|
|
725
|
+
})
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
describe('WaveformPeaksStore.audioColumns', () => {
|
|
729
|
+
it('never fetches when the adapter lacks getWaveformPeaks', () => {
|
|
730
|
+
const store = new WaveformPeaksStore()
|
|
731
|
+
const result = store.audioColumns(audio(), rect(), queryCtx({ getWaveformPeaks: undefined }))
|
|
732
|
+
expect(result).toBeNull()
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
it('skips a fully collapsed/inverted trim without fetching (DOM PlainFallback case)', () => {
|
|
736
|
+
const store = new WaveformPeaksStore()
|
|
737
|
+
const fetcher = vi.fn()
|
|
738
|
+
const result = store.audioColumns(audio({ inPoint: 8, outPoint: 3 }), rect(), queryCtx({ getWaveformPeaks: fetcher }))
|
|
739
|
+
expect(result).toBeNull()
|
|
740
|
+
expect(fetcher).not.toHaveBeenCalled()
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
it('positions the fetched window inside the bar via the ported fraction math, even when the returned window is clamped', async () => {
|
|
744
|
+
const store = new WaveformPeaksStore()
|
|
745
|
+
// Track window is [2, 12) (inPoint 2, outPoint 12); the step echoes back
|
|
746
|
+
// a clamped [2, 10) — 80% of the window — same as a real end-of-file clamp.
|
|
747
|
+
const fetcher = vi.fn().mockResolvedValue(peaksFixture({ start: 2, duration: 8, peaks: [-1, 1, -1, 1] }))
|
|
748
|
+
// Track spans [0, 2) on the timeline; at 50px/s that's exactly 100px, so
|
|
749
|
+
// this `barRect` is the WHOLE bar, unclamped — isolates the "clamped
|
|
750
|
+
// fetch" fraction math this test is about from the separate
|
|
751
|
+
// visible-span-vs-clamped-rect math `audioColumns` also does now.
|
|
752
|
+
const barRect = rect({ x: 0, width: 100 })
|
|
753
|
+
const fullyVisible = { pxPerSecond: 50, scrollSeconds: 0, widthPx: 1000 }
|
|
754
|
+
|
|
755
|
+
store.audioColumns(audio({ inPoint: 2, outPoint: 12 }), barRect, queryCtx({ getWaveformPeaks: fetcher, viewport: fullyVisible }))
|
|
756
|
+
await flush()
|
|
757
|
+
const result = store.audioColumns(audio({ inPoint: 2, outPoint: 12 }), barRect, queryCtx({ getWaveformPeaks: fetcher, viewport: fullyVisible }))
|
|
758
|
+
|
|
759
|
+
expect(result).not.toBeNull()
|
|
760
|
+
// [2,10) is 80% of the [2,12) window → sub-rect covers the first 80px of a 100px bar.
|
|
761
|
+
expect(result!.rect.x).toBeCloseTo(0)
|
|
762
|
+
expect(result!.rect.width).toBeCloseTo(80)
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
// ── Viewport-aware slicing (parity with clipColumns' scroll bug fix) ────
|
|
766
|
+
// Before this, `audioColumns` positioned the fetched window against
|
|
767
|
+
// whatever `rect` it was handed, taken at face value as "the whole bar" —
|
|
768
|
+
// correct only while the entire track fit on screen. Once a track ran off
|
|
769
|
+
// both edges, the same "whole window squeezed into whatever's visible"
|
|
770
|
+
// picture painted at every scroll position, same root cause as the
|
|
771
|
+
// clip-waveform bug, one layer further from the sample array because a
|
|
772
|
+
// fetch can itself return a clamped window.
|
|
773
|
+
|
|
774
|
+
it('resamples a DIFFERENT sub-window of the peaks when the same on-screen rect maps to a different part of a long audio track (scroll-dependent, not squeezed to the whole track every time)', async () => {
|
|
775
|
+
const store = new WaveformPeaksStore()
|
|
776
|
+
// 100 monotonically-increasing samples so two different sub-ranges are
|
|
777
|
+
// guaranteed to differ.
|
|
778
|
+
const peaks: number[] = []
|
|
779
|
+
for (let i = 0; i < 100; i++) peaks.push(-(i + 1), i + 1)
|
|
780
|
+
const fetcher = vi.fn().mockResolvedValue(peaksFixture({ start: 0, duration: 100, peaks }))
|
|
781
|
+
// A 100s track at 10px/s spans 1000px on screen — far wider than the
|
|
782
|
+
// 200px viewport it's drawn into, so `rect` (the visible slice) is only
|
|
783
|
+
// ever a fraction of the track's full span.
|
|
784
|
+
const track = audio({ id: 'a-long', start: 0, end: 100 })
|
|
785
|
+
const visibleRect = rect({ x: 0, width: 200 })
|
|
786
|
+
const atStart = { pxPerSecond: 10, scrollSeconds: 0, widthPx: 200 }
|
|
787
|
+
const scrolledIn = { pxPerSecond: 10, scrollSeconds: 40, widthPx: 200 }
|
|
788
|
+
|
|
789
|
+
// Warm the cache.
|
|
790
|
+
store.audioColumns(track, visibleRect, queryCtx({ getWaveformPeaks: fetcher, viewport: atStart }))
|
|
791
|
+
await flush()
|
|
792
|
+
|
|
793
|
+
const atStartResult = store.audioColumns(track, visibleRect, queryCtx({ getWaveformPeaks: fetcher, viewport: atStart }))
|
|
794
|
+
const scrolledInResult = store.audioColumns(track, visibleRect, queryCtx({ getWaveformPeaks: fetcher, viewport: scrolledIn }))
|
|
795
|
+
|
|
796
|
+
expect(atStartResult).not.toBeNull()
|
|
797
|
+
expect(scrolledInResult).not.toBeNull()
|
|
798
|
+
expect(atStartResult!.columns).not.toEqual(scrolledInResult!.columns)
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
it('matches the whole-window resample when the full track is on screen (unchanged from before the fix)', async () => {
|
|
802
|
+
const store = new WaveformPeaksStore()
|
|
803
|
+
const data = peaksFixture({ start: 0, duration: 2, peaks: [-100, 100, -200, 200, -50, 50, -300, 300] })
|
|
804
|
+
const fetcher = vi.fn().mockResolvedValue(data)
|
|
805
|
+
// A 2s track at 30px/s spans exactly 60px — hand audioColumns that as its
|
|
806
|
+
// rect, unclamped: the track is fully visible.
|
|
807
|
+
const track = audio({ id: 'a-full', start: 0, end: 2 })
|
|
808
|
+
const vp = { pxPerSecond: 30, scrollSeconds: 0, widthPx: 1000 }
|
|
809
|
+
const fullRect = rect({ x: 0, width: 60 })
|
|
810
|
+
|
|
811
|
+
store.audioColumns(track, fullRect, queryCtx({ getWaveformPeaks: fetcher, viewport: vp }))
|
|
812
|
+
await flush()
|
|
813
|
+
const result = store.audioColumns(track, fullRect, queryCtx({ getWaveformPeaks: fetcher, viewport: vp }))
|
|
814
|
+
|
|
815
|
+
expect(result).not.toBeNull()
|
|
816
|
+
expect(result!.rect).toEqual(fullRect)
|
|
817
|
+
expect(result!.columns).toEqual(resamplePeaksToColumns(data.peaks, 60))
|
|
818
|
+
})
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
// ── Draw integration via drawTimelineContent (draw.ts) ────────────────────
|
|
822
|
+
|
|
823
|
+
function project(over: Partial<Project> = {}): Project {
|
|
824
|
+
return { id: 'p1', ...over } as unknown as Project
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function viewport(over: Partial<Viewport> = {}): Viewport {
|
|
828
|
+
return { pxPerSecond: 20, scrollSeconds: 0, widthPx: 1000, ...over }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function scene(over: Partial<TimelineScene> = {}): TimelineScene {
|
|
832
|
+
const p = over.project ?? project()
|
|
833
|
+
return {
|
|
834
|
+
project: p,
|
|
835
|
+
viewport: viewport(),
|
|
836
|
+
layout: computeTimelineLayout(p),
|
|
837
|
+
selectedIds: [],
|
|
838
|
+
surfaceWidth: 1000,
|
|
839
|
+
surfaceHeight: 200,
|
|
840
|
+
...over,
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
describe('drawTimelineContent waveform wiring', () => {
|
|
845
|
+
it('emits waveform draw calls within a clip′s rect bounds when the lookup has ready columns', () => {
|
|
846
|
+
const p = project({ tracks: [{ id: 'trk-0', items: [clip({ id: 'c0', start: 0, end: 4 })] }] })
|
|
847
|
+
const r = recordingContext()
|
|
848
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }, { min: -0.5, max: 0.5 }]
|
|
849
|
+
const stats = drawTimelineContent(r.ctx, scene({
|
|
850
|
+
project: p,
|
|
851
|
+
layout: computeTimelineLayout(p),
|
|
852
|
+
waveforms: {
|
|
853
|
+
clipColumns: (item) => (item.id === 'c0' ? columns : null),
|
|
854
|
+
audioColumns: () => null,
|
|
855
|
+
},
|
|
856
|
+
}))
|
|
857
|
+
|
|
858
|
+
expect(stats.visualItemsDrawn).toBe(1)
|
|
859
|
+
// fill + border + 2 waveform bars (no selection ring, label width < MIN? label drawn too — just assert bar count via last N fills).
|
|
860
|
+
const fills = r.of('fillRect')
|
|
861
|
+
expect(fills.length).toBeGreaterThanOrEqual(4) // fill, border, 2 waveform bars
|
|
862
|
+
const expectedRectRight = 4 * 20 // (end-start)*pxPerSecond
|
|
863
|
+
for (const call of fills.slice(2, 4)) {
|
|
864
|
+
const [x, , w] = call.args as number[]
|
|
865
|
+
expect(x).toBeGreaterThanOrEqual(0)
|
|
866
|
+
expect(x + w).toBeLessThanOrEqual(expectedRectRight + 0.001)
|
|
867
|
+
}
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
it('emits no waveform draw calls when the lookup returns null (no proxy yet)', () => {
|
|
871
|
+
const p = project({ tracks: [{ id: 'trk-0', items: [clip({ id: 'c0', start: 0, end: 4 })] }] })
|
|
872
|
+
const withLookup = recordingContext()
|
|
873
|
+
drawTimelineContent(withLookup.ctx, scene({
|
|
874
|
+
project: p,
|
|
875
|
+
layout: computeTimelineLayout(p),
|
|
876
|
+
waveforms: { clipColumns: () => null, audioColumns: () => null },
|
|
877
|
+
}))
|
|
878
|
+
|
|
879
|
+
const withoutLookup = recordingContext()
|
|
880
|
+
drawTimelineContent(withoutLookup.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
|
|
881
|
+
|
|
882
|
+
// Absent lookup and a lookup that always returns null produce identical
|
|
883
|
+
// draw-call counts — no waveform fills either way.
|
|
884
|
+
expect(withLookup.calls.length).toBe(withoutLookup.calls.length)
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
it('grays a video-track clip′s waveform when the track is muted, and uses the normal colors when it is not', () => {
|
|
888
|
+
const p = project({
|
|
889
|
+
tracks: [
|
|
890
|
+
{ id: 'trk-0', items: [clip({ id: 'c0', start: 0, end: 4 })], muted: true },
|
|
891
|
+
{ id: 'trk-1', items: [clip({ id: 'c1', start: 0, end: 4 })] },
|
|
892
|
+
],
|
|
893
|
+
})
|
|
894
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }]
|
|
895
|
+
const lookup = { clipColumns: () => columns, audioColumns: () => null }
|
|
896
|
+
|
|
897
|
+
const mutedR = recordingContext()
|
|
898
|
+
drawTimelineContent(mutedR.ctx, scene({ project: p, layout: computeTimelineLayout(p), waveforms: lookup }))
|
|
899
|
+
const mutedFillStyles = mutedR.calls.filter(c => c.method === 'set:fillStyle').map(c => c.args[0])
|
|
900
|
+
|
|
901
|
+
// trk-0 (muted) contributes the grayed pair; trk-1 (unmuted) contributes
|
|
902
|
+
// the normal pair — both rows draw in the same pass.
|
|
903
|
+
expect(mutedFillStyles).toContain(WAVEFORM_COLORS.clipBandMuted)
|
|
904
|
+
expect(mutedFillStyles).toContain(WAVEFORM_COLORS.clipMuted)
|
|
905
|
+
expect(mutedFillStyles).toContain(WAVEFORM_COLORS.clipBand)
|
|
906
|
+
expect(mutedFillStyles).toContain(WAVEFORM_COLORS.clip)
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
it('grays a clip′s waveform when only the ITEM is muted, on an otherwise-unmuted track', () => {
|
|
910
|
+
const p = project({
|
|
911
|
+
tracks: [{ id: 'trk-0', items: [clip({ id: 'c0', start: 0, end: 4, muted: true })] }],
|
|
912
|
+
})
|
|
913
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }]
|
|
914
|
+
const r = recordingContext()
|
|
915
|
+
drawTimelineContent(r.ctx, scene({
|
|
916
|
+
project: p,
|
|
917
|
+
layout: computeTimelineLayout(p),
|
|
918
|
+
waveforms: { clipColumns: () => columns, audioColumns: () => null },
|
|
919
|
+
}))
|
|
920
|
+
const fillStyles = r.calls.filter(c => c.method === 'set:fillStyle').map(c => c.args[0])
|
|
921
|
+
expect(fillStyles).toContain(WAVEFORM_COLORS.clipMuted)
|
|
922
|
+
expect(fillStyles).not.toContain(WAVEFORM_COLORS.clip)
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
it('draws an audio-lane waveform inside the bar via the lookup', () => {
|
|
926
|
+
const p = project({ audio: { tracks: [audio({ id: 'a0', start: 0, end: 4 })] } })
|
|
927
|
+
const r = recordingContext()
|
|
928
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }, { min: -1, max: 1 }, { min: -1, max: 1 }]
|
|
929
|
+
drawTimelineContent(r.ctx, scene({
|
|
930
|
+
project: p,
|
|
931
|
+
layout: computeTimelineLayout(p),
|
|
932
|
+
waveforms: {
|
|
933
|
+
clipColumns: () => null,
|
|
934
|
+
audioColumns: (track, rc) => (track.id === 'a0' ? { rect: rc, columns } : null),
|
|
935
|
+
},
|
|
936
|
+
}))
|
|
937
|
+
|
|
938
|
+
// The bar's own background is painted via fill() (roundRectPath), not
|
|
939
|
+
// fillRect, so the waveform's 3 bars are the only fillRects the LOOKUP
|
|
940
|
+
// adds. Measured as a delta against the same scene with no lookup, because
|
|
941
|
+
// the ruler strip along the top fillRects too.
|
|
942
|
+
const bare = recordingContext()
|
|
943
|
+
drawTimelineContent(bare.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
|
|
944
|
+
expect(r.of('fillRect').length - bare.of('fillRect').length).toBe(3)
|
|
945
|
+
})
|
|
946
|
+
})
|