@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,2198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP5 T4 — the canvas painter. Every draw function is pure, so the tests drive
|
|
3
|
+
* them with a context stub that records the calls and asserts on the list: the
|
|
4
|
+
* rectangle a clip lands in IS the observable behaviour of a painter.
|
|
5
|
+
*
|
|
6
|
+
* The last block is the acceptance probe for the virtualization claim: with a
|
|
7
|
+
* long project and a small window, the number of draw calls must be bounded by
|
|
8
|
+
* what's visible, not by how many items exist.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import type { AudioTrack, CaptionSegment, VisualItem } from '../../../../schema'
|
|
12
|
+
import type { Project } from '../../../../types'
|
|
13
|
+
import {
|
|
14
|
+
AUDIO_HANDLE_WIDTH_PX,
|
|
15
|
+
AUDIO_ITEM_INSET_PX,
|
|
16
|
+
AUDIO_ITEM_RADIUS_PX,
|
|
17
|
+
CAPTION_PALETTE,
|
|
18
|
+
CAPTION_RAIL_ACCENT,
|
|
19
|
+
DARK_TIMELINE_PALETTE,
|
|
20
|
+
LABEL_SHADOW_COLOR,
|
|
21
|
+
LIGHT_CAPTION_PALETTE,
|
|
22
|
+
LIGHT_CAPTION_RAIL_ACCENT,
|
|
23
|
+
LIGHT_LABEL_SHADOW_COLOR,
|
|
24
|
+
LIGHT_TIMELINE_COLORS,
|
|
25
|
+
LIGHT_TIMELINE_PALETTE,
|
|
26
|
+
LIGHT_TRACK_PALETTE,
|
|
27
|
+
timelinePalette,
|
|
28
|
+
drawCursorLine,
|
|
29
|
+
drawMarquee,
|
|
30
|
+
drawRuler,
|
|
31
|
+
type TimelinePalette,
|
|
32
|
+
CLIP_HANDLE_WIDTH_PX,
|
|
33
|
+
FADE_GRIP_SIZE_PX,
|
|
34
|
+
CURSOR_WIDTH_PX,
|
|
35
|
+
LABEL_PAD_PX,
|
|
36
|
+
LABEL_TOP_OFFSET_PX,
|
|
37
|
+
CLIP_GUTTER_PX,
|
|
38
|
+
CLIP_SELECTED_BORDER_PX,
|
|
39
|
+
MIN_LABEL_WIDTH_PX,
|
|
40
|
+
PLAYHEAD_WIDTH_PX,
|
|
41
|
+
ROW_RADIUS_PX,
|
|
42
|
+
TIMELINE_COLORS,
|
|
43
|
+
TRACK_PALETTE,
|
|
44
|
+
clampRectToSurface,
|
|
45
|
+
drawRowBackground,
|
|
46
|
+
computeTimelineLayout,
|
|
47
|
+
overlapBands,
|
|
48
|
+
drawAudioItem,
|
|
49
|
+
drawCaptionBlock,
|
|
50
|
+
drawClipRect,
|
|
51
|
+
drawTrimHandle,
|
|
52
|
+
FADE_ENVELOPE_SEGMENTS,
|
|
53
|
+
RULER_HEIGHT_PX,
|
|
54
|
+
SNAP_GUIDE_CAP_HALF_WIDTH_PX,
|
|
55
|
+
SNAP_GUIDE_WEAK_WIDTH_PX,
|
|
56
|
+
SNAP_GUIDE_WIDTH_PX,
|
|
57
|
+
OVERLAP_EDGE_WIDTH_PX,
|
|
58
|
+
OVERLAP_HATCH_SHADOW_WIDTH_PX,
|
|
59
|
+
OVERLAP_HATCH_WIDTH_PX,
|
|
60
|
+
drawItemHandles,
|
|
61
|
+
drawKeyframeStrip,
|
|
62
|
+
drawOverlapBand,
|
|
63
|
+
drawPlayhead,
|
|
64
|
+
drawSnapGuide,
|
|
65
|
+
drawTimelineContent,
|
|
66
|
+
drawTimelineOverlay,
|
|
67
|
+
type DrawContext,
|
|
68
|
+
type TimelineScene,
|
|
69
|
+
} from '../draw'
|
|
70
|
+
import { KEYFRAME_DIAMOND_SIZE_PX, KEYFRAME_STRIP_BOTTOM_PAD_PX, keyframeDiamondX } from '../keyframe-strip'
|
|
71
|
+
import { LIGHT_WAVEFORM_COLORS, WAVEFORM_COLORS } from '../waveforms'
|
|
72
|
+
import {
|
|
73
|
+
AUDIO_LANE_HEIGHT_PX,
|
|
74
|
+
BASE_VISUAL_ROW_RENDER_HEIGHT_PX,
|
|
75
|
+
CAPTION_ROW_HEIGHT_PX,
|
|
76
|
+
ROW_GAP_PX,
|
|
77
|
+
VISUAL_ROW_RENDER_HEIGHT_PX,
|
|
78
|
+
} from '../../timeline-model'
|
|
79
|
+
import type { Viewport } from '../viewport'
|
|
80
|
+
|
|
81
|
+
// ── Recording context ────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
interface RecordedCall { method: string; args: unknown[] }
|
|
84
|
+
|
|
85
|
+
interface Recorder {
|
|
86
|
+
ctx: DrawContext
|
|
87
|
+
calls: RecordedCall[]
|
|
88
|
+
of: (method: string) => RecordedCall[]
|
|
89
|
+
count: (method: string) => number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function recordingContext(): Recorder {
|
|
93
|
+
const calls: RecordedCall[] = []
|
|
94
|
+
const props: Record<string, unknown> = {}
|
|
95
|
+
|
|
96
|
+
const proxy = new Proxy({}, {
|
|
97
|
+
get(_target, prop: string) {
|
|
98
|
+
if (prop in props) return props[prop]
|
|
99
|
+
if (prop === 'createLinearGradient') {
|
|
100
|
+
return (...args: unknown[]) => {
|
|
101
|
+
calls.push({ method: 'createLinearGradient', args })
|
|
102
|
+
return {
|
|
103
|
+
addColorStop: (...stop: unknown[]) => calls.push({ method: 'addColorStop', args: stop }),
|
|
104
|
+
} as unknown as CanvasGradient
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return (...args: unknown[]) => { calls.push({ method: prop, args }) }
|
|
108
|
+
},
|
|
109
|
+
set(_target, prop: string, value: unknown) {
|
|
110
|
+
props[prop] = value
|
|
111
|
+
calls.push({ method: `set:${prop}`, args: [value] })
|
|
112
|
+
return true
|
|
113
|
+
},
|
|
114
|
+
}) as unknown as DrawContext
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
ctx: proxy,
|
|
118
|
+
calls,
|
|
119
|
+
of: (method: string) => calls.filter(c => c.method === method),
|
|
120
|
+
count: (method: string) => calls.filter(c => c.method === method).length,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Fixtures ─────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function clip(over: Partial<VisualItem> = {}): VisualItem {
|
|
127
|
+
return { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 2, ...over }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function audio(over: Partial<AudioTrack> = {}): AudioTrack {
|
|
131
|
+
return { id: 'a0', src: '/media/voice.mp3', start: 0, end: 2, ...over }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function captionSegment(over: Partial<CaptionSegment> = {}): CaptionSegment {
|
|
135
|
+
return { id: 'seg0', text: 'hello world', start: 0, end: 2, ...over }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function project(over: Partial<Project> = {}): Project {
|
|
139
|
+
return { id: 'p1', ...over } as unknown as Project
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function viewport(over: Partial<Viewport> = {}): Viewport {
|
|
143
|
+
return { pxPerSecond: 10, scrollSeconds: 0, widthPx: 1000, ...over }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function scene(over: Partial<TimelineScene> = {}): TimelineScene {
|
|
147
|
+
const p = over.project ?? project()
|
|
148
|
+
return {
|
|
149
|
+
project: p,
|
|
150
|
+
viewport: viewport(),
|
|
151
|
+
layout: computeTimelineLayout(p),
|
|
152
|
+
selectedIds: [],
|
|
153
|
+
surfaceWidth: 1000,
|
|
154
|
+
surfaceHeight: 200,
|
|
155
|
+
...over,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What the ruler alone draws at this viewport — an empty project draws the
|
|
160
|
+
* ruler and nothing else. Content-volume assertions subtract this so they keep
|
|
161
|
+
* measuring the thing they are about (items, rows) rather than the fixed cost
|
|
162
|
+
* of the chrome above them. */
|
|
163
|
+
function rulerBaseline(vp: Viewport) {
|
|
164
|
+
const empty = project()
|
|
165
|
+
const r = recordingContext()
|
|
166
|
+
drawTimelineContent(r.ctx, scene({ project: empty, layout: computeTimelineLayout(empty), viewport: vp }))
|
|
167
|
+
return { calls: r.calls.length, fillRect: r.count('fillRect'), fillText: r.count('fillText') }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Layout ───────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
describe('computeTimelineLayout', () => {
|
|
173
|
+
it('stacks visual tracks reversed, base track last and taller, audio lanes below', () => {
|
|
174
|
+
const p = project({
|
|
175
|
+
// ov1/ov2 are overlay-typed (not video), so they stay the short row —
|
|
176
|
+
// isolates "base track is tall" from the separate "a VIDEO track is
|
|
177
|
+
// tall" rule below.
|
|
178
|
+
tracks: [[clip({ id: 'base' })], [clip({ id: 'ov1', type: 'overlay' })], [clip({ id: 'ov2', type: 'overlay' })]],
|
|
179
|
+
audio: { tracks: [audio({ id: 'm', lane: 0 }), audio({ id: 'v', lane: 1 })] },
|
|
180
|
+
} as unknown as Partial<Project>)
|
|
181
|
+
|
|
182
|
+
const layout = computeTimelineLayout(p)
|
|
183
|
+
|
|
184
|
+
// Draw order top→bottom mirrors the DOM's `[...allTracks].reverse()`.
|
|
185
|
+
expect(layout.rows.map(r => r.trackIdx)).toEqual([2, 1, 0])
|
|
186
|
+
// Rows start below the ruler strip, which owns the top of the surface.
|
|
187
|
+
const contentTop = RULER_HEIGHT_PX + ROW_GAP_PX
|
|
188
|
+
expect(layout.ruler).toEqual({ y: 0, height: RULER_HEIGHT_PX })
|
|
189
|
+
expect(layout.rows[0]).toMatchObject({ y: contentTop, height: VISUAL_ROW_RENDER_HEIGHT_PX })
|
|
190
|
+
expect(layout.rows[1]).toMatchObject({ y: contentTop + VISUAL_ROW_RENDER_HEIGHT_PX + ROW_GAP_PX })
|
|
191
|
+
// The base video track is the tall one (120px vs. 40px for the rest).
|
|
192
|
+
expect(layout.rows[2].height).toBe(BASE_VISUAL_ROW_RENDER_HEIGHT_PX)
|
|
193
|
+
|
|
194
|
+
expect(layout.lanes.map(l => l.laneIndex)).toEqual([0, 1])
|
|
195
|
+
expect(layout.lanes[0].y).toBe(layout.rows[2].y + BASE_VISUAL_ROW_RENDER_HEIGHT_PX + ROW_GAP_PX)
|
|
196
|
+
expect(layout.lanes[1].y).toBe(layout.lanes[0].y + AUDIO_LANE_HEIGHT_PX + ROW_GAP_PX)
|
|
197
|
+
expect(layout.height).toBe(layout.lanes[1].y + AUDIO_LANE_HEIGHT_PX)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('gives every VIDEO-kind track the tall base row height, not just trackIdx 0 — an overlay track stays short', () => {
|
|
201
|
+
// A second video track carries the same filmstrip+waveform content as
|
|
202
|
+
// the base track and needs the same room; an overlay/image track has
|
|
203
|
+
// neither and keeps the short row.
|
|
204
|
+
const p = project({
|
|
205
|
+
tracks: [
|
|
206
|
+
[clip({ id: 'base', type: 'video' })],
|
|
207
|
+
[clip({ id: 'video2', type: 'video' })],
|
|
208
|
+
[clip({ id: 'ov', type: 'overlay' })],
|
|
209
|
+
],
|
|
210
|
+
} as unknown as Partial<Project>)
|
|
211
|
+
|
|
212
|
+
const layout = computeTimelineLayout(p)
|
|
213
|
+
expect(layout.rows.find(r => r.trackIdx === 0)!.height).toBe(BASE_VISUAL_ROW_RENDER_HEIGHT_PX)
|
|
214
|
+
expect(layout.rows.find(r => r.trackIdx === 1)!.height).toBe(BASE_VISUAL_ROW_RENDER_HEIGHT_PX)
|
|
215
|
+
expect(layout.rows.find(r => r.trackIdx === 2)!.height).toBe(VISUAL_ROW_RENDER_HEIGHT_PX)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('keeps the base track (trackIdx 0) tall even when it has no items yet', () => {
|
|
219
|
+
// trackIdx 0 is always tall regardless of content, so a blank base row
|
|
220
|
+
// doesn't jump size the moment footage lands on it.
|
|
221
|
+
const p = project({ tracks: [[]] } as unknown as Partial<Project>)
|
|
222
|
+
const layout = computeTimelineLayout(p)
|
|
223
|
+
expect(layout.rows.find(r => r.trackIdx === 0)!.height).toBe(BASE_VISUAL_ROW_RENDER_HEIGHT_PX)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('reorders a scrambled-input project so the video track is trackIdx 0, even when it was written second on disk (Part B)', () => {
|
|
227
|
+
// Raw/on-disk order: overlay first, video second — `allTracks` here
|
|
228
|
+
// comes from `trackItems`, which funnels through `normalizeTracks`
|
|
229
|
+
// (now order-canonicalizing too), so `computeTimelineLayout` never sees
|
|
230
|
+
// the raw order — the video track lands at trackIdx 0 regardless of
|
|
231
|
+
// where it sat in the stored array.
|
|
232
|
+
const p = project({
|
|
233
|
+
tracks: [
|
|
234
|
+
[{ id: 'ov', type: 'overlay', start: 0, end: 1 }],
|
|
235
|
+
[{ id: 'v0', type: 'video', src: 'v0.mp4', start: 0, end: 1 }],
|
|
236
|
+
],
|
|
237
|
+
} as unknown as Partial<Project>)
|
|
238
|
+
|
|
239
|
+
const layout = computeTimelineLayout(p)
|
|
240
|
+
const baseRow = layout.rows.find(r => r.trackIdx === 0)!
|
|
241
|
+
expect(baseRow.items.map(i => i.id)).toEqual(['v0'])
|
|
242
|
+
expect(baseRow.height).toBe(BASE_VISUAL_ROW_RENDER_HEIGHT_PX)
|
|
243
|
+
const overlayRow = layout.rows.find(r => r.trackIdx === 1)!
|
|
244
|
+
expect(overlayRow.items.map(i => i.id)).toEqual(['ov'])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('is empty for an empty project, but still reserves the ruler', () => {
|
|
248
|
+
const layout = computeTimelineLayout(project())
|
|
249
|
+
expect(layout).toEqual({
|
|
250
|
+
ruler: { y: 0, height: RULER_HEIGHT_PX },
|
|
251
|
+
rows: [],
|
|
252
|
+
lanes: [],
|
|
253
|
+
height: RULER_HEIGHT_PX,
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// ── Captions in the canvas layout (Phase 1: one band. Phase 3: N bands) ──
|
|
258
|
+
|
|
259
|
+
describe('the caption band(s)', () => {
|
|
260
|
+
it('sits strictly between the last overlay row and the base video row', () => {
|
|
261
|
+
const p = project({
|
|
262
|
+
// Overlay-typed, not `clip()`'s default video — this test is
|
|
263
|
+
// specifically about an OVERLAY row above the base, per its own name.
|
|
264
|
+
tracks: [[clip({ id: 'base' })], [clip({ id: 'ov1', type: 'overlay' })]],
|
|
265
|
+
captions: { style: 'clean', segments: [captionSegment()] },
|
|
266
|
+
} as unknown as Partial<Project>)
|
|
267
|
+
|
|
268
|
+
const layout = computeTimelineLayout(p)
|
|
269
|
+
const overlayRow = layout.rows.find(r => r.trackIdx === 1)!
|
|
270
|
+
const baseRow = layout.rows.find(r => r.trackIdx === 0)!
|
|
271
|
+
|
|
272
|
+
expect(layout.captions).toHaveLength(1)
|
|
273
|
+
expect(layout.captions![0].lane).toBe(0)
|
|
274
|
+
expect(layout.captions![0].height).toBe(CAPTION_ROW_HEIGHT_PX)
|
|
275
|
+
expect(layout.captions![0].y).toBeGreaterThan(overlayRow.y + overlayRow.height)
|
|
276
|
+
expect(layout.captions![0].y).toBeLessThan(baseRow.y)
|
|
277
|
+
expect(layout.captions![0].segments).toEqual([captionSegment()])
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('is absent — not an empty array — with no captions, and every other row is unchanged', () => {
|
|
281
|
+
const tracks = [[clip({ id: 'base' })], [clip({ id: 'ov1' })]]
|
|
282
|
+
const noCaptionsAtAll = project({ tracks } as unknown as Partial<Project>)
|
|
283
|
+
const emptySegments = project({
|
|
284
|
+
tracks,
|
|
285
|
+
captions: { style: 'clean', segments: [] },
|
|
286
|
+
} as unknown as Partial<Project>)
|
|
287
|
+
|
|
288
|
+
const baseline = computeTimelineLayout(noCaptionsAtAll)
|
|
289
|
+
for (const p of [noCaptionsAtAll, emptySegments]) {
|
|
290
|
+
const layout = computeTimelineLayout(p)
|
|
291
|
+
expect(layout.captions).toBeUndefined()
|
|
292
|
+
expect(layout.rows).toEqual(baseline.rows)
|
|
293
|
+
expect(layout.lanes).toEqual(baseline.lanes)
|
|
294
|
+
expect(layout.height).toBe(baseline.height)
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('still appears, above where the video row would be, on a project with captions but no visual tracks', () => {
|
|
299
|
+
const p = project({
|
|
300
|
+
captions: { style: 'clean', segments: [captionSegment()] },
|
|
301
|
+
} as unknown as Partial<Project>)
|
|
302
|
+
const layout = computeTimelineLayout(p)
|
|
303
|
+
|
|
304
|
+
expect(layout.rows).toEqual([])
|
|
305
|
+
expect(layout.captions).toHaveLength(1)
|
|
306
|
+
expect(layout.captions![0].y).toBe(RULER_HEIGHT_PX + ROW_GAP_PX)
|
|
307
|
+
expect(layout.height).toBe(layout.captions![0].y + CAPTION_ROW_HEIGHT_PX)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// ── Regression guard (Phase 3) ──
|
|
311
|
+
// Multi-lane captions must not move a single-lane project's band by even
|
|
312
|
+
// one pixel: the exact geometry a lane-0-only project got in Phase 1 is
|
|
313
|
+
// reproduced here, numeral for numeral, rather than compared loosely.
|
|
314
|
+
it('keeps a lane-0-only layout — band y, band height, and total surface height — byte-for-byte identical to before N-lane captions', () => {
|
|
315
|
+
const p = project({
|
|
316
|
+
// Overlay-typed, not `clip()`'s default video: this is the SINGLE
|
|
317
|
+
// -video-track case (only `base` is video) the byte-identical
|
|
318
|
+
// guarantee is about — the caption band still sits directly above
|
|
319
|
+
// the base row, exactly where it always has.
|
|
320
|
+
tracks: [[clip({ id: 'base' })], [clip({ id: 'ov1', type: 'overlay' })]],
|
|
321
|
+
captions: { style: 'clean', segments: [captionSegment()] },
|
|
322
|
+
} as unknown as Partial<Project>)
|
|
323
|
+
|
|
324
|
+
const layout = computeTimelineLayout(p)
|
|
325
|
+
// Re-derived from the layout's own rows rather than pinned to a height
|
|
326
|
+
// constant, so this regression guard survives future height changes.
|
|
327
|
+
const upperRow = layout.rows.find(r => r.trackIdx === 1)!
|
|
328
|
+
const baseRow = layout.rows.find(r => r.trackIdx === 0)!
|
|
329
|
+
const band = layout.captions![0]
|
|
330
|
+
|
|
331
|
+
expect(layout.captions).toHaveLength(1)
|
|
332
|
+
expect(band).toMatchObject({ lane: 0, height: CAPTION_ROW_HEIGHT_PX })
|
|
333
|
+
expect(band.y).toBe(upperRow.y + upperRow.height + ROW_GAP_PX)
|
|
334
|
+
expect(baseRow.y).toBe(band.y + band.height + ROW_GAP_PX)
|
|
335
|
+
expect(layout.height).toBe(baseRow.y + baseRow.height)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('emits N bands in descending lane order, lane 0 landing exactly where the single band sits today', () => {
|
|
339
|
+
const p = project({
|
|
340
|
+
// Overlay-typed, not `clip()`'s default video — single-video-track
|
|
341
|
+
// case, so lane 0 lands directly above `base` with nothing between.
|
|
342
|
+
tracks: [[clip({ id: 'base' })], [clip({ id: 'ov1', type: 'overlay' })]],
|
|
343
|
+
captions: {
|
|
344
|
+
style: 'clean',
|
|
345
|
+
segments: [
|
|
346
|
+
captionSegment({ id: 's0', lane: 0 }),
|
|
347
|
+
captionSegment({ id: 's1', lane: 1, start: 2, end: 4 }),
|
|
348
|
+
captionSegment({ id: 's2', lane: 2, start: 4, end: 6 }),
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
} as unknown as Partial<Project>)
|
|
352
|
+
|
|
353
|
+
const layout = computeTimelineLayout(p)
|
|
354
|
+
const baseRow = layout.rows.find(r => r.trackIdx === 0)!
|
|
355
|
+
|
|
356
|
+
expect(layout.captions).toHaveLength(3)
|
|
357
|
+
// Index 0 is the HIGHEST lane; the last entry is lane 0.
|
|
358
|
+
expect(layout.captions!.map(c => c.lane)).toEqual([2, 1, 0])
|
|
359
|
+
|
|
360
|
+
const [lane2, lane1, lane0] = layout.captions!
|
|
361
|
+
// Lane 0 sits immediately above the base row — the exact position a
|
|
362
|
+
// lane-0-only project's single band has always occupied.
|
|
363
|
+
expect(lane0.y + lane0.height + ROW_GAP_PX).toBe(baseRow.y)
|
|
364
|
+
// Higher lanes stack upward (smaller y) toward the overlays.
|
|
365
|
+
expect(lane1.y).toBeLessThan(lane0.y)
|
|
366
|
+
expect(lane2.y).toBeLessThan(lane1.y)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
// ── Part B: track grouping — the caption band sits above the WHOLE
|
|
370
|
+
// video block, not squeezed between two video tracks ──────────────────
|
|
371
|
+
|
|
372
|
+
it('with two video tracks and one overlay track, stacks overlay(top) → captions → video block (topmost video, then base)', () => {
|
|
373
|
+
const p = project({
|
|
374
|
+
tracks: [
|
|
375
|
+
[clip({ id: 'base' })], // video, base
|
|
376
|
+
[clip({ id: 'video2', type: 'video' })], // video, second track
|
|
377
|
+
[clip({ id: 'ov1', type: 'overlay' })], // overlay
|
|
378
|
+
],
|
|
379
|
+
captions: { style: 'clean', segments: [captionSegment()] },
|
|
380
|
+
} as unknown as Partial<Project>)
|
|
381
|
+
|
|
382
|
+
const layout = computeTimelineLayout(p)
|
|
383
|
+
// Already in canonical order in this fixture (video tracks precede the
|
|
384
|
+
// overlay track), so trackIdx assignment is unchanged from array order
|
|
385
|
+
// — but the DRAW order (top of screen first) is still the reverse.
|
|
386
|
+
expect(layout.rows.map(r => r.trackIdx)).toEqual([2, 1, 0])
|
|
387
|
+
const overlayRow = layout.rows.find(r => r.trackIdx === 2)!
|
|
388
|
+
const video2Row = layout.rows.find(r => r.trackIdx === 1)!
|
|
389
|
+
const baseRow = layout.rows.find(r => r.trackIdx === 0)!
|
|
390
|
+
|
|
391
|
+
expect(layout.captions).toHaveLength(1)
|
|
392
|
+
const band = layout.captions![0]
|
|
393
|
+
// Captions sit directly above the TOPMOST video track (video2) — NOT
|
|
394
|
+
// between video2 and base — and directly below the overlay row.
|
|
395
|
+
expect(band.y).toBe(overlayRow.y + overlayRow.height + ROW_GAP_PX)
|
|
396
|
+
expect(video2Row.y).toBe(band.y + band.height + ROW_GAP_PX)
|
|
397
|
+
expect(video2Row.y + video2Row.height + ROW_GAP_PX).toBe(baseRow.y)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('a hole lane still gets its own band, empty of segments, rather than being skipped', () => {
|
|
401
|
+
const p = project({
|
|
402
|
+
tracks: [[clip({ id: 'base' })]],
|
|
403
|
+
captions: {
|
|
404
|
+
style: 'clean',
|
|
405
|
+
// Lanes 0 and 2 occupied; lane 1 is a hole.
|
|
406
|
+
segments: [
|
|
407
|
+
captionSegment({ id: 's0', lane: 0 }),
|
|
408
|
+
captionSegment({ id: 's2', lane: 2, start: 4, end: 6 }),
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
} as unknown as Partial<Project>)
|
|
412
|
+
|
|
413
|
+
const layout = computeTimelineLayout(p)
|
|
414
|
+
expect(layout.captions).toHaveLength(3)
|
|
415
|
+
expect(layout.captions!.map(c => c.lane)).toEqual([2, 1, 0])
|
|
416
|
+
const holeBand = layout.captions!.find(c => c.lane === 1)!
|
|
417
|
+
expect(holeBand.segments).toEqual([])
|
|
418
|
+
expect(holeBand.height).toBe(CAPTION_ROW_HEIGHT_PX)
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
describe('clampRectToSurface', () => {
|
|
424
|
+
it('trims a clip that runs far past both edges', () => {
|
|
425
|
+
expect(clampRectToSurface({ x: -50_000, y: 0, width: 100_000, height: 40 }, 1000))
|
|
426
|
+
.toEqual({ x: -1, y: 0, width: 1002, height: 40 })
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
// ── Element painters ─────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
describe('drawClipRect', () => {
|
|
433
|
+
it('insets the body from the clip\'s span, leaving a gutter between touching clips', () => {
|
|
434
|
+
const r = recordingContext()
|
|
435
|
+
drawClipRect(r.ctx, {
|
|
436
|
+
rect: { x: 120, y: 40, width: 200, height: 40 },
|
|
437
|
+
palette: TRACK_PALETTE[1],
|
|
438
|
+
selected: false,
|
|
439
|
+
label: '▪ video',
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
// Inset by CLIP_GUTTER_PX per side — two touching clips therefore show
|
|
443
|
+
// twice that as dark row background, which is what makes a cut visible
|
|
444
|
+
// once filmstrip frames run edge to edge.
|
|
445
|
+
const fills = r.of('fillRect')
|
|
446
|
+
expect(fills[0].args).toEqual([120 + CLIP_GUTTER_PX, 40, 200 - CLIP_GUTTER_PX * 2, 40])
|
|
447
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TRACK_PALETTE[1].fill)).toBe(true)
|
|
448
|
+
// Top-left, not vertically centred: the centre of a clip is now the seam
|
|
449
|
+
// between its frames band and its waveform band.
|
|
450
|
+
expect(r.of('fillText')[0].args).toEqual(['▪ video', 120 + CLIP_GUTTER_PX + 6, 40 + LABEL_TOP_OFFSET_PX])
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('outlines every clip, and a selected one in thick white', () => {
|
|
454
|
+
const plain = recordingContext()
|
|
455
|
+
drawClipRect(plain.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, palette: TRACK_PALETTE[0], selected: false, label: 'x' })
|
|
456
|
+
expect(plain.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TRACK_PALETTE[0].border)).toBe(true)
|
|
457
|
+
expect(plain.calls.some(c => c.method === 'set:lineWidth' && c.args[0] === 1)).toBe(true)
|
|
458
|
+
|
|
459
|
+
const picked = recordingContext()
|
|
460
|
+
drawClipRect(picked.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, palette: TRACK_PALETTE[0], selected: true, label: 'x' })
|
|
461
|
+
// White, not the track's own hue — the only treatment that reads over an
|
|
462
|
+
// arbitrary video frame.
|
|
463
|
+
expect(picked.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.clipSelectedOutline)).toBe(true)
|
|
464
|
+
expect(picked.calls.some(c => c.method === 'set:lineWidth' && c.args[0] === CLIP_SELECTED_BORDER_PX)).toBe(true)
|
|
465
|
+
expect(picked.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TRACK_PALETTE[0].fillSelected)).toBe(true)
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('clips its content to the rounded body, so frames stop at the corners', () => {
|
|
469
|
+
const r = recordingContext()
|
|
470
|
+
drawClipRect(r.ctx, {
|
|
471
|
+
rect: { x: 0, y: 0, width: 100, height: 40 },
|
|
472
|
+
palette: TRACK_PALETTE[0], selected: false, label: 'x',
|
|
473
|
+
drawContent: (c, body) => c.fillRect(body.x, body.y, body.width, body.height),
|
|
474
|
+
})
|
|
475
|
+
// A clip path is established before the content paints.
|
|
476
|
+
const clipAt = r.calls.findIndex(c => c.method === 'clip')
|
|
477
|
+
const contentAt = r.calls.findIndex(c => c.method === 'fillRect' && (c.args as number[])[2] === 100 - CLIP_GUTTER_PX * 2)
|
|
478
|
+
expect(clipAt).toBeGreaterThanOrEqual(0)
|
|
479
|
+
expect(clipAt).toBeLessThan(contentAt)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('hands drawContent the inset body, not the full span', () => {
|
|
483
|
+
let handed: { x: number; width: number } | null = null
|
|
484
|
+
const r = recordingContext()
|
|
485
|
+
drawClipRect(r.ctx, {
|
|
486
|
+
rect: { x: 120, y: 40, width: 200, height: 40 },
|
|
487
|
+
palette: TRACK_PALETTE[0], selected: false, label: 'x',
|
|
488
|
+
drawContent: (_c, body) => { handed = { x: body.x, width: body.width } },
|
|
489
|
+
})
|
|
490
|
+
expect(handed).toEqual({ x: 120 + CLIP_GUTTER_PX, width: 200 - CLIP_GUTTER_PX * 2 })
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('skips the label on a clip too narrow to read it', () => {
|
|
494
|
+
const r = recordingContext()
|
|
495
|
+
drawClipRect(r.ctx, {
|
|
496
|
+
rect: { x: 0, y: 0, width: MIN_LABEL_WIDTH_PX - 1, height: 40 },
|
|
497
|
+
palette: TRACK_PALETTE[0], selected: false, label: '▪ video',
|
|
498
|
+
})
|
|
499
|
+
expect(r.count('fillText')).toBe(0)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('draws nothing for a zero-width rect', () => {
|
|
503
|
+
const r = recordingContext()
|
|
504
|
+
drawClipRect(r.ctx, { rect: { x: 10, y: 0, width: 0, height: 40 }, palette: TRACK_PALETTE[0], selected: false, label: 'x' })
|
|
505
|
+
expect(r.calls).toHaveLength(0)
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
// ── Trim handles: the affordance selection is FOR ──
|
|
509
|
+
//
|
|
510
|
+
// The handles themselves are the row painter's job now (a clip stacked on
|
|
511
|
+
// top of this one would bury them), so `drawClipRect` must NOT draw them —
|
|
512
|
+
// only leave room for them.
|
|
513
|
+
|
|
514
|
+
it('leaves the handles to the row painter', () => {
|
|
515
|
+
const r = recordingContext()
|
|
516
|
+
drawClipRect(r.ctx, { rect: { x: 0, y: 0, width: 200, height: 40 }, palette: TRACK_PALETTE[0], selected: true, label: '' })
|
|
517
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.handleFill)).toBe(false)
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('indents a selected clip\'s label past its in handle', () => {
|
|
521
|
+
const free = recordingContext()
|
|
522
|
+
drawClipRect(free.ctx, { rect: { x: 0, y: 0, width: 200, height: 40 }, palette: TRACK_PALETTE[0], selected: false, label: 'title' })
|
|
523
|
+
const picked = recordingContext()
|
|
524
|
+
drawClipRect(picked.ctx, { rect: { x: 0, y: 0, width: 200, height: 40 }, palette: TRACK_PALETTE[0], selected: true, label: 'title' })
|
|
525
|
+
// Without the indent the pill would sit on top of the first glyph.
|
|
526
|
+
expect(Number(picked.of('fillText')[0].args[1]) - Number(free.of('fillText')[0].args[1]))
|
|
527
|
+
.toBe(CLIP_HANDLE_WIDTH_PX)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('dims a row unrelated to the primary selection', () => {
|
|
531
|
+
const r = recordingContext()
|
|
532
|
+
drawClipRect(r.ctx, { rect: { x: 0, y: 0, width: 80, height: 40 }, palette: TRACK_PALETTE[0], selected: false, label: 'x', dimmed: true })
|
|
533
|
+
expect(r.calls.some(c => c.method === 'set:globalAlpha' && c.args[0] === 0.3)).toBe(true)
|
|
534
|
+
})
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
describe('drawTrimHandle', () => {
|
|
538
|
+
// 2 ticks of 1.5px with a 2.5px gap; a 40px-tall row gives the capped 16px
|
|
539
|
+
// tick height, centred, so the grip sits at y=12.
|
|
540
|
+
const GRIP_SPAN = 5.5
|
|
541
|
+
|
|
542
|
+
it('pins the in handle to the left edge and the out handle to the right', () => {
|
|
543
|
+
const left = recordingContext()
|
|
544
|
+
drawTrimHandle(left.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, edge: 'in', width: 10 })
|
|
545
|
+
expect(left.of('fillRect').map(c => c.args)).toEqual([
|
|
546
|
+
[(10 - GRIP_SPAN) / 2, 12, 1.5, 16],
|
|
547
|
+
[(10 - GRIP_SPAN) / 2 + 4, 12, 1.5, 16],
|
|
548
|
+
])
|
|
549
|
+
|
|
550
|
+
const right = recordingContext()
|
|
551
|
+
drawTrimHandle(right.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, edge: 'out', width: 10 })
|
|
552
|
+
// 100 - 10 = 90 is the pill's left edge; the grip is centred in it.
|
|
553
|
+
expect(right.of('fillRect')[0].args[0]).toBe(90 + (10 - GRIP_SPAN) / 2)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('draws the pill in the handle white, and its grip dark', () => {
|
|
557
|
+
const r = recordingContext()
|
|
558
|
+
drawTrimHandle(r.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, edge: 'in', width: 10 })
|
|
559
|
+
expect(r.of('set:fillStyle').map(c => c.args[0])).toEqual([
|
|
560
|
+
TIMELINE_COLORS.handleFill,
|
|
561
|
+
TIMELINE_COLORS.handleGrip,
|
|
562
|
+
])
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('goes opaque under the pointer, so the grabbable edge is unambiguous', () => {
|
|
566
|
+
const r = recordingContext()
|
|
567
|
+
drawTrimHandle(r.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, edge: 'in', width: 10, hovered: true })
|
|
568
|
+
expect(r.of('set:fillStyle').map(c => c.args[0])).toEqual([
|
|
569
|
+
TIMELINE_COLORS.handleFillHovered,
|
|
570
|
+
TIMELINE_COLORS.handleGripHovered,
|
|
571
|
+
])
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('halves itself on a short clip, so two handles can never meet', () => {
|
|
575
|
+
const r = recordingContext()
|
|
576
|
+
// 12px of clip, 10px of nominal handle: each takes 6 and the grip, which
|
|
577
|
+
// no longer fits with a margin, drops out rather than filling the pill.
|
|
578
|
+
drawTrimHandle(r.ctx, { rect: { x: 0, y: 0, width: 12, height: 40 }, edge: 'out', width: 10 })
|
|
579
|
+
expect(r.count('fill')).toBe(1)
|
|
580
|
+
expect(r.count('fillRect')).toBe(0)
|
|
581
|
+
// The pill starts halfway across, not 10px from the right edge.
|
|
582
|
+
expect(r.of('moveTo')[0].args[0]).toBeGreaterThanOrEqual(6)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('draws nothing at all once the pill is thinner than its own outline', () => {
|
|
586
|
+
const r = recordingContext()
|
|
587
|
+
drawTrimHandle(r.ctx, { rect: { x: 0, y: 0, width: 5, height: 40 }, edge: 'in', width: 10 })
|
|
588
|
+
expect(r.calls).toHaveLength(0)
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('draws nothing for an empty rect', () => {
|
|
592
|
+
const r = recordingContext()
|
|
593
|
+
drawTrimHandle(r.ctx, { rect: { x: 0, y: 0, width: 0, height: 0 }, edge: 'in', width: 10 })
|
|
594
|
+
expect(r.calls).toHaveLength(0)
|
|
595
|
+
})
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
describe('drawKeyframeStrip', () => {
|
|
599
|
+
// 2s–4s overlay, keyframed on offsetX (t=0, t=1) and opacity (t=0 only —
|
|
600
|
+
// shares the FIRST diamond with offsetX). Union of times is {0, 1}: one
|
|
601
|
+
// diamond both props share, one offsetX draws alone.
|
|
602
|
+
function keyframedOverlay(): VisualItem {
|
|
603
|
+
return clip({
|
|
604
|
+
id: 'o0', type: 'overlay', src: undefined, start: 2, end: 4,
|
|
605
|
+
keyframes: [
|
|
606
|
+
{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 1, value: 10 }] },
|
|
607
|
+
{ prop: 'opacity', points: [{ t: 0, value: 1 }] },
|
|
608
|
+
],
|
|
609
|
+
} as unknown as Partial<VisualItem>)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// A REALISTIC body, not the clip's raw rect: the real caller (the row loop
|
|
613
|
+
// below) always hands `drawKeyframeStrip` `clipBodyRect(rect)`, which insets
|
|
614
|
+
// by `CLIP_GUTTER_PX` per side. `rect.x` for this item (start=2 @ 10px/s) is
|
|
615
|
+
// 20 and `rect.width` is 20, so `body` is `{ x: 22, width: 16 }` — using
|
|
616
|
+
// `{ x: 20, width: 20 }` here (rect verbatim) used to mask the endpoint-
|
|
617
|
+
// diamond clipping bug this file now guards against, because it made
|
|
618
|
+
// `body.x` coincide with `rect.x` instead of sitting `CLIP_GUTTER_PX` inside
|
|
619
|
+
// it.
|
|
620
|
+
const BODY = { x: 20 + CLIP_GUTTER_PX, y: 0, width: 20 - CLIP_GUTTER_PX * 2, height: 40 }
|
|
621
|
+
const vp = viewport({ pxPerSecond: 10 })
|
|
622
|
+
|
|
623
|
+
it('draws one diamond per DISTINCT time in the union, not one per prop', () => {
|
|
624
|
+
const r = recordingContext()
|
|
625
|
+
drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp)
|
|
626
|
+
// Each diamond is one closed 4-point path: moveTo + 3 lineTo + closePath.
|
|
627
|
+
expect(r.count('moveTo')).toBe(2)
|
|
628
|
+
expect(r.count('closePath')).toBe(2)
|
|
629
|
+
expect(r.count('fill')).toBe(2)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('positions each diamond at item.start + t, run through the SAME timeToX everything else uses', () => {
|
|
633
|
+
const r = recordingContext()
|
|
634
|
+
drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp)
|
|
635
|
+
const y = BODY.y + BODY.height - KEYFRAME_DIAMOND_SIZE_PX / 2 - KEYFRAME_STRIP_BOTTOM_PAD_PX
|
|
636
|
+
const half = KEYFRAME_DIAMOND_SIZE_PX / 2
|
|
637
|
+
// t=0 -> (2+0)*10=20; t=1 -> (2+1)*10=30.
|
|
638
|
+
expect(r.of('moveTo').map(c => c.args)).toEqual([
|
|
639
|
+
[20, y - half],
|
|
640
|
+
[30, y - half],
|
|
641
|
+
])
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
it('fills amber and strokes for definition', () => {
|
|
645
|
+
const r = recordingContext()
|
|
646
|
+
drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp)
|
|
647
|
+
expect(r.of('set:fillStyle').every(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(true)
|
|
648
|
+
expect(r.of('set:strokeStyle').every(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondStroke)).toBe(true)
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it('paints the diamond matching selectedT with a different fillStyle than the rest', () => {
|
|
652
|
+
const r = recordingContext()
|
|
653
|
+
// Union of times is {0, 1} (ascending) — selecting t=1 must mark only the
|
|
654
|
+
// SECOND diamond, leaving the first one drawn with the ordinary fill.
|
|
655
|
+
drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp, 1)
|
|
656
|
+
const fills = r.of('set:fillStyle').map(c => c.args[0])
|
|
657
|
+
expect(fills).toEqual([TIMELINE_COLORS.keyframeDiamondFill, TIMELINE_COLORS.keyframeDiamondSelectedFill])
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('paints every diamond identically when selectedT is null (or omitted)', () => {
|
|
661
|
+
const r = recordingContext()
|
|
662
|
+
drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp, null)
|
|
663
|
+
const fills = r.of('set:fillStyle').map(c => c.args[0])
|
|
664
|
+
expect(fills[0]).toBe(fills[1])
|
|
665
|
+
expect(new Set(fills).size).toBe(1)
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('clips to a region derived from the clip body, widened past its edges (not the raw body rect)', () => {
|
|
669
|
+
const r = recordingContext()
|
|
670
|
+
drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp)
|
|
671
|
+
// The widened region equals the clip's TRUE (un-gutter-inset) rect, grown
|
|
672
|
+
// by half a diamond on each side: [rect.x - half, rect.x + rect.width +
|
|
673
|
+
// half]. rect.x=20, rect.width=20, half=4 -> [16, 44] -> x=16, width=28.
|
|
674
|
+
const half = KEYFRAME_DIAMOND_SIZE_PX / 2
|
|
675
|
+
const rectX = BODY.x - CLIP_GUTTER_PX
|
|
676
|
+
const rectWidth = BODY.width + CLIP_GUTTER_PX * 2
|
|
677
|
+
expect(r.of('rect')[0].args).toEqual([rectX - half, BODY.y, rectWidth + half * 2, BODY.height])
|
|
678
|
+
expect(r.count('clip')).toBe(1)
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
it('an ENDPOINT diamond (t=0, at the clip\'s own true left edge) survives WHOLE — the gutter inset no longer slices it to a sliver', () => {
|
|
682
|
+
const r = recordingContext()
|
|
683
|
+
const item = keyframedOverlay()
|
|
684
|
+
drawKeyframeStrip(r.ctx, item, BODY, vp)
|
|
685
|
+
const [regionX, , regionWidth] = r.of('rect')[0].args as number[]
|
|
686
|
+
const half = KEYFRAME_DIAMOND_SIZE_PX / 2
|
|
687
|
+
const cx = keyframeDiamondX(item, 0, vp)
|
|
688
|
+
// The whole diamond — both edges, not just its centre — must fall inside
|
|
689
|
+
// the clip region drawKeyframeStrip actually used.
|
|
690
|
+
expect(regionX).toBeLessThanOrEqual(cx - half)
|
|
691
|
+
expect(regionX + regionWidth).toBeGreaterThanOrEqual(cx + half)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it('draws nothing for an unkeyframed item', () => {
|
|
695
|
+
const r = recordingContext()
|
|
696
|
+
drawKeyframeStrip(r.ctx, clip({ id: 'o1', type: 'overlay', src: undefined, start: 2, end: 4 }), BODY, vp)
|
|
697
|
+
expect(r.calls).toHaveLength(0)
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it('draws nothing into a zero-width body', () => {
|
|
701
|
+
const r = recordingContext()
|
|
702
|
+
drawKeyframeStrip(r.ctx, keyframedOverlay(), { ...BODY, width: 0 }, vp)
|
|
703
|
+
expect(r.calls).toHaveLength(0)
|
|
704
|
+
})
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The i-th fade envelope's curve `lineTo` calls (0-based: fade-in is drawn
|
|
709
|
+
* before fade-out when both are set — see `drawAudioItem`'s own call order).
|
|
710
|
+
* Each curve is exactly `FADE_ENVELOPE_SEGMENTS` `lineTo` calls (one
|
|
711
|
+
* `moveTo` plus `FADE_ENVELOPE_SEGMENTS` `lineTo`s), and nothing else on a
|
|
712
|
+
* bar calls `lineTo` before them — the bar body is `arcTo`, the tint band is
|
|
713
|
+
* `fillRect`, and the fade grips (which DO use `lineTo`) are always drawn
|
|
714
|
+
* after every envelope — so a fixed-size slice from the FRONT of `lineTo`
|
|
715
|
+
* calls is exact regardless of what (if anything) is drawn afterward.
|
|
716
|
+
*/
|
|
717
|
+
function envelopeCurveLineTos(r: Recorder, index = 0): number[][] {
|
|
718
|
+
const lines = r.of('lineTo').map(c => c.args as number[])
|
|
719
|
+
return lines.slice(index * FADE_ENVELOPE_SEGMENTS, (index + 1) * FADE_ENVELOPE_SEGMENTS)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
describe('drawAudioItem', () => {
|
|
723
|
+
it('fills emerald, labels the bar, and rings it when selected', () => {
|
|
724
|
+
const r = recordingContext()
|
|
725
|
+
drawAudioItem(r.ctx, { rect: { x: 10, y: 4, width: 300, height: 32 }, selected: true, muted: false, label: 'Voiceover' })
|
|
726
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.audioFill)).toBe(true)
|
|
727
|
+
expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.audioRing)).toBe(true)
|
|
728
|
+
// Indented past the in-handle: a selected bar grows a 6px pill on each
|
|
729
|
+
// end, and a label starting under one would lose its first glyph.
|
|
730
|
+
expect(r.of('fillText')[0].args).toEqual(['Voiceover', 16 + AUDIO_HANDLE_WIDTH_PX, 20])
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
it('leaves its handles to the lane painter too', () => {
|
|
734
|
+
const r = recordingContext()
|
|
735
|
+
drawAudioItem(r.ctx, { rect: { x: 0, y: 0, width: 300, height: 32 }, selected: true, muted: false, label: '' })
|
|
736
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.handleFill)).toBe(false)
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
it('uses the muted fill and drops the border', () => {
|
|
740
|
+
const r = recordingContext()
|
|
741
|
+
drawAudioItem(r.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: false, muted: true, label: 'Music' })
|
|
742
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.audioMutedFill)).toBe(true)
|
|
743
|
+
expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.audioBorder)).toBe(false)
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it('tints the WHOLE fade-width band, full height, both sides of the curve — clamped to the bar', () => {
|
|
747
|
+
const r = recordingContext()
|
|
748
|
+
drawAudioItem(r.ctx, {
|
|
749
|
+
rect: { x: 100, y: 0, width: 200, height: 32 },
|
|
750
|
+
selected: false, muted: false, label: 'Music',
|
|
751
|
+
fadeInPx: 40, fadeOutPx: 5000, // fade-out longer than the bar
|
|
752
|
+
})
|
|
753
|
+
expect(r.count('quadraticCurveTo')).toBe(0)
|
|
754
|
+
// The tint is a plain full-height rect — NOT a curve-bounded wedge —
|
|
755
|
+
// spanning the fade's own width. Fade-in: [spanX, spanX+w] = [100, 140].
|
|
756
|
+
const fills = r.of('fillRect').map(c => c.args as number[])
|
|
757
|
+
expect(fills).toContainEqual([100, 0, 40, 32])
|
|
758
|
+
// Fade-out, clamped to the bar's own 200px width, not the requested
|
|
759
|
+
// 5000: the fade eats the whole bar, so its band is the full [100, 300).
|
|
760
|
+
expect(fills).toContainEqual([100, 0, 200, 32])
|
|
761
|
+
const dimFills = r.calls.filter(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.fadeEnvelopeDim)
|
|
762
|
+
expect(dimFills).toHaveLength(2)
|
|
763
|
+
|
|
764
|
+
// The curve itself still traces the fade's own full-volume corner.
|
|
765
|
+
const fadeInCurve = envelopeCurveLineTos(r, 0)
|
|
766
|
+
expect(fadeInCurve[fadeInCurve.length - 1]).toEqual([140, 0])
|
|
767
|
+
const fadeOutCurve = envelopeCurveLineTos(r, 1)
|
|
768
|
+
expect(fadeOutCurve[fadeOutCurve.length - 1]).toEqual([100, 0])
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
it('samples the exp curve by default: y is monotonic and the endpoints are exact', () => {
|
|
772
|
+
const r = recordingContext()
|
|
773
|
+
drawAudioItem(r.ctx, {
|
|
774
|
+
rect: { x: 0, y: 0, width: 200, height: 32 },
|
|
775
|
+
selected: false, muted: false, label: 'Music',
|
|
776
|
+
fadeInPx: 100,
|
|
777
|
+
})
|
|
778
|
+
// moveTo[0] is the bar body's OWN rounded-rect path (`roundRectPath`,
|
|
779
|
+
// unrelated to the envelope); moveTo[1] is the curve's own start — the
|
|
780
|
+
// silent corner.
|
|
781
|
+
const moves = r.of('moveTo').map(c => c.args as number[])
|
|
782
|
+
expect(moves[1]).toEqual([0, 32])
|
|
783
|
+
const curve = envelopeCurveLineTos(r)
|
|
784
|
+
expect(curve).toHaveLength(FADE_ENVELOPE_SEGMENTS)
|
|
785
|
+
// Monotonically non-decreasing gain (y decreasing towards 0/top) as x
|
|
786
|
+
// increases from silent to full — every curve in fade-curve.ts is
|
|
787
|
+
// monotonic over [0,1].
|
|
788
|
+
for (let i = 1; i < curve.length; i++) {
|
|
789
|
+
expect(curve[i][1]).toBeLessThanOrEqual(curve[i - 1][1] + 1e-9)
|
|
790
|
+
}
|
|
791
|
+
// Last sample lands exactly at the full-volume corner.
|
|
792
|
+
expect(curve[curve.length - 1]).toEqual([100, 0])
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
it('anchors the fade to the clip′s TRUE span, not the clamped rect, so it scrolls with the clip', () => {
|
|
796
|
+
// A clip scrolled off the left edge: the lane painter clamps `rect` to the
|
|
797
|
+
// visible surface (x:0) for the bar fill and waveform, but hands the fade
|
|
798
|
+
// its TRUE unclamped span via fadeSpanX/fadeSpanWidth. The envelope and
|
|
799
|
+
// grips must anchor to the true span — otherwise they stay pinned to the
|
|
800
|
+
// viewport edge while the clip scrolls under them (the bug this fixes).
|
|
801
|
+
// The envelope is still clipped to the visible bar; only its anchor moves.
|
|
802
|
+
const r = recordingContext()
|
|
803
|
+
drawAudioItem(r.ctx, {
|
|
804
|
+
rect: { x: 0, y: 0, width: 200, height: 32 }, // clamped visible portion
|
|
805
|
+
fadeSpanX: -100, fadeSpanWidth: 300, // TRUE span: left edge off-screen at -100, 300px wide
|
|
806
|
+
selected: false, muted: false, label: 'Music',
|
|
807
|
+
fadeInPx: 40, fadeOutPx: 30,
|
|
808
|
+
})
|
|
809
|
+
// Fade-in full-volume x = fadeSpanX + fadeInPx = -60 — NOT rect.x + 40 =
|
|
810
|
+
// 40, where the old clamped-rect anchor wrongly pinned it on screen.
|
|
811
|
+
const fadeInCurve = envelopeCurveLineTos(r, 0)
|
|
812
|
+
expect(fadeInCurve[fadeInCurve.length - 1]).toEqual([-60, 0])
|
|
813
|
+
// Fade-out: silent at the TRUE right edge (fadeSpanX + fadeSpanWidth =
|
|
814
|
+
// 200), full-volume 30px in from there (170).
|
|
815
|
+
const fadeOutCurve = envelopeCurveLineTos(r, 1)
|
|
816
|
+
expect(fadeOutCurve[fadeOutCurve.length - 1]).toEqual([170, 0])
|
|
817
|
+
// The tint bands anchor to the same true span.
|
|
818
|
+
const fills = r.of('fillRect').map(c => c.args as number[])
|
|
819
|
+
expect(fills).toContainEqual([-100, 0, 40, 32]) // fade-in band: [-100, -60)
|
|
820
|
+
expect(fills).toContainEqual([170, 0, 30, 32]) // fade-out band: [170, 200)
|
|
821
|
+
// Both grips ride the same true-span anchors.
|
|
822
|
+
const half = FADE_GRIP_SIZE_PX / 2
|
|
823
|
+
const [inGrip, outGrip] = r.of('moveTo').map(c => c.args as number[]).slice(-2)
|
|
824
|
+
expect(inGrip).toEqual([-60 - half, 0])
|
|
825
|
+
expect(outGrip).toEqual([170 - half, 0])
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
it('draws the requested curve shape — linear is a straight ramp, not exp′s ease', () => {
|
|
829
|
+
const linear = recordingContext()
|
|
830
|
+
drawAudioItem(linear.ctx, {
|
|
831
|
+
rect: { x: 0, y: 0, width: 200, height: 32 },
|
|
832
|
+
selected: false, muted: false, label: 'x',
|
|
833
|
+
fadeInPx: 100, fadeInCurve: 'linear',
|
|
834
|
+
})
|
|
835
|
+
const exp = recordingContext()
|
|
836
|
+
drawAudioItem(exp.ctx, {
|
|
837
|
+
rect: { x: 0, y: 0, width: 200, height: 32 },
|
|
838
|
+
selected: false, muted: false, label: 'x',
|
|
839
|
+
fadeInPx: 100, fadeInCurve: 'exp',
|
|
840
|
+
})
|
|
841
|
+
// Midpoint (p=0.5) of the curve: linear sits at half the drop, exp (t²)
|
|
842
|
+
// has barely started dropping — the two curves must disagree.
|
|
843
|
+
const midOf = (r: Recorder) => {
|
|
844
|
+
const curve = envelopeCurveLineTos(r)
|
|
845
|
+
return curve[Math.floor(curve.length / 2) - 1]
|
|
846
|
+
}
|
|
847
|
+
expect(midOf(linear)[1]).not.toBeCloseTo(midOf(exp)[1], 1)
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('defaults an unset curve to exp', () => {
|
|
851
|
+
const withDefault = recordingContext()
|
|
852
|
+
drawAudioItem(withDefault.ctx, {
|
|
853
|
+
rect: { x: 0, y: 0, width: 200, height: 32 },
|
|
854
|
+
selected: false, muted: false, label: 'x',
|
|
855
|
+
fadeInPx: 100,
|
|
856
|
+
})
|
|
857
|
+
const explicit = recordingContext()
|
|
858
|
+
drawAudioItem(explicit.ctx, {
|
|
859
|
+
rect: { x: 0, y: 0, width: 200, height: 32 },
|
|
860
|
+
selected: false, muted: false, label: 'x',
|
|
861
|
+
fadeInPx: 100, fadeInCurve: 'exp',
|
|
862
|
+
})
|
|
863
|
+
expect(withDefault.calls).toEqual(explicit.calls)
|
|
864
|
+
})
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
describe('drawAudioItem fade grips', () => {
|
|
868
|
+
it('draws both grips at the bar′s own corners when no fade is set, in the SUBTLE style', () => {
|
|
869
|
+
const r = recordingContext()
|
|
870
|
+
drawAudioItem(r.ctx, { rect: { x: 100, y: 0, width: 200, height: 32 }, selected: false, muted: false, label: 'x' })
|
|
871
|
+
// No fade → no envelope curves → the fill/border path's own moveTo (0)
|
|
872
|
+
// is immediately followed by the two grips (1, 2).
|
|
873
|
+
const gripMoves = r.of('moveTo').map(c => c.args as number[])
|
|
874
|
+
const half = FADE_GRIP_SIZE_PX / 2
|
|
875
|
+
expect(gripMoves[1]).toEqual([100 - half, 0]) // in-grip at the LEFT corner
|
|
876
|
+
expect(gripMoves[2]).toEqual([300 - half, 0]) // out-grip at the RIGHT corner
|
|
877
|
+
const gripFills = r.calls.filter(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.fadeGripSubtle)
|
|
878
|
+
expect(gripFills).toHaveLength(2)
|
|
879
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.fadeGripActive)).toBe(false)
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('moves a grip to the fade′s INNER edge once a fade is set, not the corner', () => {
|
|
883
|
+
const r = recordingContext()
|
|
884
|
+
drawAudioItem(r.ctx, {
|
|
885
|
+
rect: { x: 100, y: 0, width: 200, height: 32 }, selected: false, muted: false, label: 'x',
|
|
886
|
+
fadeInPx: 40, fadeOutPx: 30,
|
|
887
|
+
})
|
|
888
|
+
// Envelope curves add their own moveTo calls ahead of the grips'; pick
|
|
889
|
+
// the grips off the END rather than counting in, so this stays correct
|
|
890
|
+
// regardless of how many moveTo calls `drawFadeEnvelope` itself makes.
|
|
891
|
+
const half = FADE_GRIP_SIZE_PX / 2
|
|
892
|
+
const [inGrip, outGrip] = r.of('moveTo').map(c => c.args as number[]).slice(-2)
|
|
893
|
+
expect(inGrip).toEqual([140 - half, 0]) // rect.x + fadeInPx
|
|
894
|
+
expect(outGrip).toEqual([270 - half, 0]) // rect.x + rect.width - fadeOutPx
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
it('brightens BOTH grips when the bar is selected', () => {
|
|
898
|
+
const r = recordingContext()
|
|
899
|
+
drawAudioItem(r.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: true, muted: false, label: 'x' })
|
|
900
|
+
const gripFills = r.calls.filter(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.fadeGripActive)
|
|
901
|
+
expect(gripFills).toHaveLength(2)
|
|
902
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.fadeGripSubtle)).toBe(false)
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
it('brightens only the HOVERED grip, leaving the other side subtle', () => {
|
|
906
|
+
const r = recordingContext()
|
|
907
|
+
drawAudioItem(r.ctx, {
|
|
908
|
+
rect: { x: 0, y: 0, width: 200, height: 32 }, selected: false, muted: false, label: 'x',
|
|
909
|
+
hoveredFadeSide: 'in',
|
|
910
|
+
})
|
|
911
|
+
const gripStyles = r.calls
|
|
912
|
+
.filter(c => c.method === 'set:fillStyle' && (c.args[0] === TIMELINE_COLORS.fadeGripActive || c.args[0] === TIMELINE_COLORS.fadeGripSubtle))
|
|
913
|
+
.map(c => c.args[0])
|
|
914
|
+
// In-grip drawn first, out-grip second (see `drawAudioItem`'s own order).
|
|
915
|
+
expect(gripStyles).toEqual([TIMELINE_COLORS.fadeGripActive, TIMELINE_COLORS.fadeGripSubtle])
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
it('skips both grips on a bar too narrow for them', () => {
|
|
919
|
+
const r = recordingContext()
|
|
920
|
+
drawAudioItem(r.ctx, { rect: { x: 0, y: 0, width: FADE_GRIP_SIZE_PX * 2 - 1, height: 32 }, selected: false, muted: false, label: '' })
|
|
921
|
+
expect(r.calls.some(c =>
|
|
922
|
+
c.method === 'set:fillStyle' && (c.args[0] === TIMELINE_COLORS.fadeGripActive || c.args[0] === TIMELINE_COLORS.fadeGripSubtle),
|
|
923
|
+
)).toBe(false)
|
|
924
|
+
})
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
describe('drawCaptionBlock', () => {
|
|
928
|
+
it('fills and borders in the unselected caption color, and centres the label', () => {
|
|
929
|
+
const r = recordingContext()
|
|
930
|
+
drawCaptionBlock(r.ctx, { rect: { x: 10, y: 4, width: 200, height: 32 }, selected: false, label: 'hello' })
|
|
931
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === CAPTION_PALETTE.fill)).toBe(true)
|
|
932
|
+
expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === CAPTION_PALETTE.border)).toBe(true)
|
|
933
|
+
// Vertically centred (no frames/waveform split to clear, unlike a clip),
|
|
934
|
+
// clipped and unindented since nothing is selected.
|
|
935
|
+
expect(r.of('fillText')[0].args).toEqual(['hello', 10 + LABEL_PAD_PX, 4 + 16])
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
it('swaps fill AND border-for-ring on selection, mirroring the retired DOM row', () => {
|
|
939
|
+
const r = recordingContext()
|
|
940
|
+
drawCaptionBlock(r.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: true, label: 'x' })
|
|
941
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === CAPTION_PALETTE.fillSelected)).toBe(true)
|
|
942
|
+
expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === CAPTION_PALETTE.ring)).toBe(true)
|
|
943
|
+
// Mutually exclusive with the unselected border — the DOM classes were
|
|
944
|
+
// never both at once.
|
|
945
|
+
expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === CAPTION_PALETTE.border)).toBe(false)
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it('indents a selected block\'s label past its handle', () => {
|
|
949
|
+
const free = recordingContext()
|
|
950
|
+
drawCaptionBlock(free.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: false, label: 'title' })
|
|
951
|
+
const picked = recordingContext()
|
|
952
|
+
drawCaptionBlock(picked.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: true, label: 'title' })
|
|
953
|
+
expect(Number(picked.of('fillText')[0].args[1]) - Number(free.of('fillText')[0].args[1]))
|
|
954
|
+
.toBe(AUDIO_HANDLE_WIDTH_PX)
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
it('leaves the handles to the row painter, like drawAudioItem', () => {
|
|
958
|
+
const r = recordingContext()
|
|
959
|
+
drawCaptionBlock(r.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: true, label: '' })
|
|
960
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.handleFill)).toBe(false)
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
it('skips the label on a block too narrow to read it', () => {
|
|
964
|
+
const r = recordingContext()
|
|
965
|
+
drawCaptionBlock(r.ctx, { rect: { x: 0, y: 0, width: MIN_LABEL_WIDTH_PX - 1, height: 32 }, selected: false, label: 'hello' })
|
|
966
|
+
expect(r.count('fillText')).toBe(0)
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
it('draws nothing for a zero-width rect', () => {
|
|
970
|
+
const r = recordingContext()
|
|
971
|
+
drawCaptionBlock(r.ctx, { rect: { x: 0, y: 0, width: 0, height: 32 }, selected: false, label: 'x' })
|
|
972
|
+
expect(r.calls).toHaveLength(0)
|
|
973
|
+
})
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
describe('drawPlayhead', () => {
|
|
977
|
+
it('centres the playhead on its time', () => {
|
|
978
|
+
const r = recordingContext()
|
|
979
|
+
drawPlayhead(r.ctx, 400, 0, 160)
|
|
980
|
+
expect(r.of('fillRect')[0].args).toEqual([400 - PLAYHEAD_WIDTH_PX / 2, 0, PLAYHEAD_WIDTH_PX, 160])
|
|
981
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.playhead)).toBe(true)
|
|
982
|
+
})
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
// ── Scene composition ────────────────────────────────────────────────────
|
|
986
|
+
|
|
987
|
+
describe('drawSnapGuide', () => {
|
|
988
|
+
it('centres the line on its x and points both caps inward', () => {
|
|
989
|
+
const r = recordingContext()
|
|
990
|
+
drawSnapGuide(r.ctx, 300, 0, 120)
|
|
991
|
+
expect(r.of('fillRect')[0].args).toEqual([300 - SNAP_GUIDE_WIDTH_PX / 2, 0, SNAP_GUIDE_WIDTH_PX, 120])
|
|
992
|
+
// Each cap's apex sits INSIDE the span — an arrowhead aimed at the
|
|
993
|
+
// boundary, not away from it.
|
|
994
|
+
const apexes = r.of('lineTo').filter((_, i) => i % 2 === 1).map(c => c.args)
|
|
995
|
+
expect(apexes).toEqual([[300, 7], [300, 113]])
|
|
996
|
+
})
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
describe('drawItemHandles', () => {
|
|
1000
|
+
it('draws one handle on each end', () => {
|
|
1001
|
+
const r = recordingContext()
|
|
1002
|
+
drawItemHandles(r.ctx, { x: 0, y: 0, width: 200, height: 40 }, CLIP_HANDLE_WIDTH_PX)
|
|
1003
|
+
expect(r.of('set:fillStyle').filter(c => c.args[0] === TIMELINE_COLORS.handleFill)).toHaveLength(2)
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
it('lights only the handle the pointer is on', () => {
|
|
1007
|
+
const r = recordingContext()
|
|
1008
|
+
drawItemHandles(r.ctx, { x: 0, y: 0, width: 200, height: 40 }, CLIP_HANDLE_WIDTH_PX, 'out')
|
|
1009
|
+
const fills = r.of('set:fillStyle').map(c => c.args[0])
|
|
1010
|
+
expect(fills.filter(f => f === TIMELINE_COLORS.handleFill)).toHaveLength(1)
|
|
1011
|
+
expect(fills.filter(f => f === TIMELINE_COLORS.handleFillHovered)).toHaveLength(1)
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
describe('overlapBands', () => {
|
|
1016
|
+
it('returns the intersection of each overlapping consecutive pair', () => {
|
|
1017
|
+
expect(overlapBands([{ start: 0, end: 5 }, { start: 4, end: 9 }])).toEqual([{ start: 4, end: 5 }])
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
it('sorts by start first, so array order cannot hide an overlap', () => {
|
|
1021
|
+
expect(overlapBands([{ start: 4, end: 9 }, { start: 0, end: 5 }])).toEqual([{ start: 4, end: 5 }])
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1024
|
+
it('clamps to the shorter item when one is contained in the other', () => {
|
|
1025
|
+
expect(overlapBands([{ start: 0, end: 9 }, { start: 2, end: 4 }])).toEqual([{ start: 2, end: 4 }])
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
it('ignores items that only touch, and gaps', () => {
|
|
1029
|
+
expect(overlapBands([{ start: 0, end: 5 }, { start: 5, end: 9 }])).toEqual([])
|
|
1030
|
+
expect(overlapBands([{ start: 0, end: 4 }, { start: 5, end: 9 }])).toEqual([])
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('finds every overlap in a run, not just the first', () => {
|
|
1034
|
+
expect(overlapBands([{ start: 0, end: 3 }, { start: 2, end: 6 }, { start: 5, end: 8 }]))
|
|
1035
|
+
.toEqual([{ start: 2, end: 3 }, { start: 5, end: 6 }])
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
it('has nothing to say about zero or one item', () => {
|
|
1039
|
+
expect(overlapBands([])).toEqual([])
|
|
1040
|
+
expect(overlapBands([{ start: 0, end: 5 }])).toEqual([])
|
|
1041
|
+
})
|
|
1042
|
+
})
|
|
1043
|
+
|
|
1044
|
+
describe('drawOverlapBand', () => {
|
|
1045
|
+
it('does not share a literal with the muted-audio fill', () => {
|
|
1046
|
+
// Both are near-transparent white. If they ever converge, a muted bar and
|
|
1047
|
+
// an overlap band become indistinguishable by colour, and every test that
|
|
1048
|
+
// keys on one of them starts matching the other.
|
|
1049
|
+
expect(TIMELINE_COLORS.overlapFill).not.toBe(TIMELINE_COLORS.audioMutedFill)
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
it('washes, hatches and rules both edges', () => {
|
|
1053
|
+
const r = recordingContext()
|
|
1054
|
+
drawOverlapBand(r.ctx, { x: 100, y: 0, width: 60, height: 40 })
|
|
1055
|
+
const fills = r.of('set:fillStyle').map(c => c.args[0])
|
|
1056
|
+
expect(fills).toEqual([TIMELINE_COLORS.overlapFill, TIMELINE_COLORS.overlapEdge])
|
|
1057
|
+
// The wash, then a hard rule down each side.
|
|
1058
|
+
const rects = r.of('fillRect').map(c => c.args)
|
|
1059
|
+
expect(rects[0]).toEqual([100, 0, 60, 40])
|
|
1060
|
+
expect(rects[1]).toEqual([100, 0, OVERLAP_EDGE_WIDTH_PX, 40])
|
|
1061
|
+
expect(rects[2]).toEqual([100 + 60 - OVERLAP_EDGE_WIDTH_PX, 0, OVERLAP_EDGE_WIDTH_PX, 40])
|
|
1062
|
+
// The hatch is stroked twice off one path: a wide dark pass, then amber
|
|
1063
|
+
// inside it, so it reads over a bright frame and a dark one alike.
|
|
1064
|
+
expect(r.of('set:strokeStyle').map(c => c.args[0])).toEqual([
|
|
1065
|
+
TIMELINE_COLORS.overlapHatchShadow,
|
|
1066
|
+
TIMELINE_COLORS.overlapHatch,
|
|
1067
|
+
])
|
|
1068
|
+
expect(r.of('set:lineWidth').map(c => c.args[0])).toEqual([
|
|
1069
|
+
OVERLAP_HATCH_SHADOW_WIDTH_PX,
|
|
1070
|
+
OVERLAP_HATCH_WIDTH_PX,
|
|
1071
|
+
])
|
|
1072
|
+
expect(r.count('stroke')).toBe(2)
|
|
1073
|
+
expect(r.count('beginPath')).toBe(2) // the clip rect, then the one hatch path
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
it('clips the hatching so the diagonals stop at the band', () => {
|
|
1077
|
+
const r = recordingContext()
|
|
1078
|
+
drawOverlapBand(r.ctx, { x: 100, y: 0, width: 60, height: 40 })
|
|
1079
|
+
const clipAt = r.calls.findIndex(c => c.method === 'clip')
|
|
1080
|
+
const strokeAt = r.calls.findIndex(c => c.method === 'stroke')
|
|
1081
|
+
expect(clipAt).toBeGreaterThanOrEqual(0)
|
|
1082
|
+
expect(clipAt).toBeLessThan(strokeAt)
|
|
1083
|
+
// …and the edges are painted after the clip is released, so they stay full
|
|
1084
|
+
// weight right up to the band's boundary.
|
|
1085
|
+
expect(r.calls.findIndex(c => c.method === 'restore')).toBeLessThan(
|
|
1086
|
+
r.calls.map(c => c.method === 'set:fillStyle' ? c.args[0] : null).indexOf(TIMELINE_COLORS.overlapEdge),
|
|
1087
|
+
)
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
it('leans its hatching one way, evenly spaced', () => {
|
|
1091
|
+
const r = recordingContext()
|
|
1092
|
+
drawOverlapBand(r.ctx, { x: 0, y: 0, width: 18, height: 10 })
|
|
1093
|
+
// Bottom-left to top-right, every OVERLAP_HATCH_SPACING_PX across a span
|
|
1094
|
+
// of width + height.
|
|
1095
|
+
expect(r.of('moveTo').map(c => c.args)).toEqual([[0, 10], [9, 10], [18, 10], [27, 10]])
|
|
1096
|
+
expect(r.of('lineTo').map(c => c.args)).toEqual([[-10, 0], [-1, 0], [8, 0], [17, 0]])
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
it('never lets the two edge rules cross on a hairline overlap', () => {
|
|
1100
|
+
const r = recordingContext()
|
|
1101
|
+
drawOverlapBand(r.ctx, { x: 100, y: 0, width: 3, height: 40 })
|
|
1102
|
+
const rects = r.of('fillRect').map(c => c.args as number[])
|
|
1103
|
+
expect(rects[1][2]).toBe(1.5)
|
|
1104
|
+
expect(rects[2][0]).toBe(101.5)
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
it('draws nothing for an empty band', () => {
|
|
1108
|
+
const r = recordingContext()
|
|
1109
|
+
drawOverlapBand(r.ctx, { x: 10, y: 0, width: 0, height: 40 })
|
|
1110
|
+
expect(r.calls).toHaveLength(0)
|
|
1111
|
+
})
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
describe('drawSnapGuide — weak', () => {
|
|
1115
|
+
it('draws a capless hairline for a cross-track boundary', () => {
|
|
1116
|
+
const r = recordingContext()
|
|
1117
|
+
drawSnapGuide(r.ctx, 300, 0, 120, 'weak')
|
|
1118
|
+
expect(r.of('fillRect')[0].args).toEqual([300 - SNAP_GUIDE_WEAK_WIDTH_PX / 2, 0, SNAP_GUIDE_WEAK_WIDTH_PX, 120])
|
|
1119
|
+
expect(r.of('set:fillStyle')[0].args[0]).toBe(TIMELINE_COLORS.snapGuideWeak)
|
|
1120
|
+
// No arrowheads: the caps are what make the strong guide shout, and this
|
|
1121
|
+
// one is meant to be barely there.
|
|
1122
|
+
expect(r.count('closePath')).toBe(0)
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
it('is thinner than the strong guide it sits alongside', () => {
|
|
1126
|
+
expect(SNAP_GUIDE_WEAK_WIDTH_PX).toBeLessThan(SNAP_GUIDE_WIDTH_PX)
|
|
1127
|
+
})
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
describe('drawRowBackground — panel + divider', () => {
|
|
1131
|
+
it('fills the row and strokes a rowDivider outline as the track divider', () => {
|
|
1132
|
+
const r = recordingContext()
|
|
1133
|
+
drawRowBackground(r.ctx, { x: 0, y: 10, width: 200, height: 40 })
|
|
1134
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackground)).toBe(true)
|
|
1135
|
+
expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.rowDivider)).toBe(true)
|
|
1136
|
+
const strokes = r.of('strokeRect')
|
|
1137
|
+
expect(strokes).toHaveLength(1)
|
|
1138
|
+
expect(strokes[0].args).toEqual([0.5, 10.5, 199, 39])
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
it('paints the alternate shade when one is passed, so adjacent lanes differ', () => {
|
|
1142
|
+
const r = recordingContext()
|
|
1143
|
+
drawRowBackground(r.ctx, { x: 0, y: 0, width: 100, height: 20 }, TIMELINE_COLORS.rowBackgroundAlt)
|
|
1144
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackgroundAlt)).toBe(true)
|
|
1145
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackground)).toBe(false)
|
|
1146
|
+
})
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
describe('drawTimelineContent', () => {
|
|
1150
|
+
it('places clips from the viewport, not from a percentage of the project', () => {
|
|
1151
|
+
const p = project({ tracks: [[clip({ id: 'c0', start: 10, end: 14 })]] } as unknown as Partial<Project>)
|
|
1152
|
+
const r = recordingContext()
|
|
1153
|
+
drawTimelineContent(r.ctx, scene({
|
|
1154
|
+
project: p,
|
|
1155
|
+
layout: computeTimelineLayout(p),
|
|
1156
|
+
viewport: viewport({ pxPerSecond: 25, scrollSeconds: 8 }),
|
|
1157
|
+
}))
|
|
1158
|
+
|
|
1159
|
+
// (10 - 8) * 25 = 50px from the left, 4s * 25 = 100px wide — then inset by
|
|
1160
|
+
// the gutter each side, which is paint only and does not move the clip in time.
|
|
1161
|
+
// Located by its width rather than by call order: the ruler and the row
|
|
1162
|
+
// background both fillRect before the clip does.
|
|
1163
|
+
const rowY = computeTimelineLayout(p).rows[0].y
|
|
1164
|
+
const clipFill = r.of('fillRect').find(c => c.args[2] === 100 - CLIP_GUTTER_PX * 2)
|
|
1165
|
+
expect(clipFill?.args).toEqual([50 + CLIP_GUTTER_PX, rowY, 100 - CLIP_GUTTER_PX * 2, BASE_VISUAL_ROW_RENDER_HEIGHT_PX])
|
|
1166
|
+
})
|
|
1167
|
+
|
|
1168
|
+
it('insets audio bars inside their lane, left/right AND top/bottom', () => {
|
|
1169
|
+
const p = project({ audio: { tracks: [audio({ start: 0, end: 4 })] } } as unknown as Partial<Project>)
|
|
1170
|
+
const r = recordingContext()
|
|
1171
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 20 }) }))
|
|
1172
|
+
|
|
1173
|
+
// Audio bars are rounded, so their box is a path, not a fillRect: the lane
|
|
1174
|
+
// background's path comes first, the bar's second, inset top and bottom
|
|
1175
|
+
// by AUDIO_ITEM_INSET_PX, AND left/right by CLIP_GUTTER_PX — the same
|
|
1176
|
+
// horizontal gutter a video clip gets, so two touching audio bars show
|
|
1177
|
+
// the same uniform gap two touching video clips do.
|
|
1178
|
+
const barHeight = AUDIO_LANE_HEIGHT_PX - AUDIO_ITEM_INSET_PX * 2
|
|
1179
|
+
// Lane y, not 0: the ruler owns the top of the surface and everything below
|
|
1180
|
+
// it is offset by that strip.
|
|
1181
|
+
const barTop = computeTimelineLayout(p).lanes[0].y + AUDIO_ITEM_INSET_PX
|
|
1182
|
+
// 4s @ 20px/s = 80px span, inset by CLIP_GUTTER_PX on each side.
|
|
1183
|
+
const barLeft = CLIP_GUTTER_PX
|
|
1184
|
+
const barRight = 80 - CLIP_GUTTER_PX
|
|
1185
|
+
expect(r.of('moveTo')[1].args).toEqual([barLeft + 4, barTop])
|
|
1186
|
+
expect(r.of('arcTo').some(c =>
|
|
1187
|
+
JSON.stringify(c.args) === JSON.stringify([barRight, barTop, barRight, barTop + barHeight, 4]),
|
|
1188
|
+
)).toBe(true)
|
|
1189
|
+
// By content, not by index — the ruler's own tick labels are fillText too.
|
|
1190
|
+
const barLabel = r.of('fillText').find(c => c.args[0] === 'voice.mp3')
|
|
1191
|
+
expect(barLabel?.args).toEqual(['voice.mp3', barLeft + 6, barTop + barHeight / 2])
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
it('gives two butted audio bars the same uniform CLIP_GUTTER_PX gap two butted video clips get, not a seamless touch', () => {
|
|
1195
|
+
const p = project({
|
|
1196
|
+
audio: {
|
|
1197
|
+
tracks: [
|
|
1198
|
+
audio({ id: 'a', start: 0, end: 5, lane: 0 }),
|
|
1199
|
+
audio({ id: 'b', start: 5, end: 9, lane: 0 }), // butted to `a` — no gap in time
|
|
1200
|
+
],
|
|
1201
|
+
},
|
|
1202
|
+
} as unknown as Partial<Project>)
|
|
1203
|
+
const r = recordingContext()
|
|
1204
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
|
|
1205
|
+
|
|
1206
|
+
// Path order: the lane background's own roundRectPath moves first (1);
|
|
1207
|
+
// then each bar's OWN fill path (1 moveTo, unselected/unmuted — fill and
|
|
1208
|
+
// border share the one path), followed by its two fade-grip triangles
|
|
1209
|
+
// (1 moveTo apiece, drawn on every bar regardless of selection — see
|
|
1210
|
+
// `drawFadeGrip`). So track `a` is index 1, and track `b`'s fill-path
|
|
1211
|
+
// moveTo is index 1 + (1 fill + 2 grips) = 4, not a plain "next index".
|
|
1212
|
+
const moveTos = r.of('moveTo').map(c => c.args as number[])
|
|
1213
|
+
const aMoveTo = moveTos[1]
|
|
1214
|
+
const bMoveTo = moveTos[4]
|
|
1215
|
+
const aLeft = aMoveTo[0] - AUDIO_ITEM_RADIUS_PX
|
|
1216
|
+
const bLeft = bMoveTo[0] - AUDIO_ITEM_RADIUS_PX
|
|
1217
|
+
// `a` spans [0,5) → raw x 0; `b` starts immediately after at raw x 50
|
|
1218
|
+
// (5s × 10px/s). Each bar's drawn left edge sits CLIP_GUTTER_PX in from
|
|
1219
|
+
// its raw span start — the identical inset a video clip's `clipBodyRect`
|
|
1220
|
+
// applies — so the two bars show a uniform 2×CLIP_GUTTER_PX gap rather
|
|
1221
|
+
// than touching.
|
|
1222
|
+
expect(aLeft).toBe(CLIP_GUTTER_PX)
|
|
1223
|
+
expect(bLeft).toBe(50 + CLIP_GUTTER_PX)
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
it('marks the overlap between two audio bars as a crossfade band', () => {
|
|
1227
|
+
const p = project({
|
|
1228
|
+
audio: { tracks: [audio({ id: 'a', start: 0, end: 5, lane: 0 }), audio({ id: 'b', start: 4, end: 9, lane: 0 })] },
|
|
1229
|
+
} as unknown as Partial<Project>)
|
|
1230
|
+
const r = recordingContext()
|
|
1231
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
|
|
1232
|
+
expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)).toBe(true)
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
it('leaves a muted bar out of the pairing — it is crossfading with nothing', () => {
|
|
1236
|
+
const p = project({
|
|
1237
|
+
audio: { tracks: [audio({ id: 'a', start: 0, end: 5, lane: 0 }), audio({ id: 'b', start: 4, end: 9, lane: 0, muted: true })] },
|
|
1238
|
+
} as unknown as Partial<Project>)
|
|
1239
|
+
const r = recordingContext()
|
|
1240
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
|
|
1241
|
+
expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)).toBe(false)
|
|
1242
|
+
})
|
|
1243
|
+
|
|
1244
|
+
// ── Overlaps on a VISUAL row: previously not marked at all ──
|
|
1245
|
+
|
|
1246
|
+
it('marks two overlapping clips on a video track', () => {
|
|
1247
|
+
// The bug this fixes: within a track the later clip simply wins, so this
|
|
1248
|
+
// span is one second of clip `a` that will never be seen — and the only
|
|
1249
|
+
// sign of it was the clip band's wash landing twice.
|
|
1250
|
+
const p = project({ tracks: [[clip({ id: 'a', start: 0, end: 5 }), clip({ id: 'b', start: 4, end: 9 })]] } as unknown as Partial<Project>)
|
|
1251
|
+
const r = recordingContext()
|
|
1252
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
|
|
1253
|
+
expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)).toBe(true)
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1256
|
+
it('draws no band for clips that merely touch', () => {
|
|
1257
|
+
// Butted-up clips are the normal case and must stay unmarked, or every
|
|
1258
|
+
// cut on the timeline would be hatched.
|
|
1259
|
+
const p = project({ tracks: [[clip({ id: 'a', start: 0, end: 5 }), clip({ id: 'b', start: 5, end: 9 })]] } as unknown as Partial<Project>)
|
|
1260
|
+
const r = recordingContext()
|
|
1261
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
|
|
1262
|
+
expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)).toBe(false)
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
it('draws the band over the clips it spans, not under them', () => {
|
|
1266
|
+
const p = project({ tracks: [[clip({ id: 'a', start: 0, end: 5 }), clip({ id: 'b', start: 4, end: 9 })]] } as unknown as Partial<Project>)
|
|
1267
|
+
const r = recordingContext()
|
|
1268
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
|
|
1269
|
+
const lastClipFill = r.calls.map(c => c.method === 'set:fillStyle' ? c.args[0] : null).lastIndexOf(TRACK_PALETTE[0].fill)
|
|
1270
|
+
const bandAt = r.calls.findIndex(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)
|
|
1271
|
+
expect(bandAt).toBeGreaterThan(lastClipFill)
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
it('draws a selected clip\'s handles after every clip in the row', () => {
|
|
1275
|
+
// The bug: clip `b` overlaps the end of the selected clip `a`, so painting
|
|
1276
|
+
// handles in stacking order buried a's out handle under b. You could see
|
|
1277
|
+
// the clip was selected and had no way to see — or reach — its trailing
|
|
1278
|
+
// edge.
|
|
1279
|
+
const p = project({ tracks: [[clip({ id: 'a', start: 0, end: 5 }), clip({ id: 'b', start: 4, end: 9 })]] } as unknown as Partial<Project>)
|
|
1280
|
+
const r = recordingContext()
|
|
1281
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['a'], viewport: viewport({ pxPerSecond: 30 }) }))
|
|
1282
|
+
|
|
1283
|
+
const handleAt = r.calls.map(c => c.method === 'set:fillStyle' ? c.args[0] : null).lastIndexOf(TIMELINE_COLORS.handleFill)
|
|
1284
|
+
const lastClipFill = r.calls.map(c => c.method === 'set:fillStyle' ? c.args[0] : null).lastIndexOf(TRACK_PALETTE[0].fill)
|
|
1285
|
+
expect(handleAt).toBeGreaterThan(lastClipFill)
|
|
1286
|
+
// …and over the overlap hatching as well, since the handle is the control.
|
|
1287
|
+
const bandAt = r.calls.findIndex(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)
|
|
1288
|
+
expect(handleAt).toBeGreaterThan(bandAt)
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
it('never draws the playhead — that is the overlay layer', () => {
|
|
1292
|
+
const p = project({ tracks: [[clip({ start: 0, end: 5 })]] } as unknown as Partial<Project>)
|
|
1293
|
+
const r = recordingContext()
|
|
1294
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
|
|
1295
|
+
expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.playhead)).toBe(false)
|
|
1296
|
+
})
|
|
1297
|
+
|
|
1298
|
+
it('clears before it paints', () => {
|
|
1299
|
+
const r = recordingContext()
|
|
1300
|
+
drawTimelineContent(r.ctx, scene())
|
|
1301
|
+
expect(r.calls[0]).toEqual({ method: 'clearRect', args: [0, 0, 1000, 200] })
|
|
1302
|
+
})
|
|
1303
|
+
|
|
1304
|
+
// ── Captions (Phase 1: display only) ──
|
|
1305
|
+
|
|
1306
|
+
describe('the caption band', () => {
|
|
1307
|
+
function projectWithCaptions(segments: CaptionSegment[]): Project {
|
|
1308
|
+
return project({
|
|
1309
|
+
tracks: [[clip({ id: 'base', start: 0, end: 20 })]],
|
|
1310
|
+
captions: { style: 'clean', segments },
|
|
1311
|
+
} as unknown as Partial<Project>)
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
it('draws one block per segment', () => {
|
|
1315
|
+
const p = projectWithCaptions([
|
|
1316
|
+
captionSegment({ id: 's0', start: 0, end: 2, text: 'a' }),
|
|
1317
|
+
captionSegment({ id: 's1', start: 2, end: 4, text: 'b' }),
|
|
1318
|
+
captionSegment({ id: 's2', start: 4, end: 6, text: 'c' }),
|
|
1319
|
+
])
|
|
1320
|
+
const r = recordingContext()
|
|
1321
|
+
const stats = drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
|
|
1322
|
+
expect(stats.captionItemsDrawn).toBe(3)
|
|
1323
|
+
expect(r.of('set:fillStyle').filter(c => c.args[0] === CAPTION_PALETTE.fill)).toHaveLength(3)
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
it('highlights the block in scene.selectedIds and gives it handles; the others get neither', () => {
|
|
1327
|
+
const p = projectWithCaptions([
|
|
1328
|
+
captionSegment({ id: 's0', start: 0, end: 2, text: 'a' }),
|
|
1329
|
+
captionSegment({ id: 's1', start: 2, end: 4, text: 'b' }),
|
|
1330
|
+
])
|
|
1331
|
+
const r = recordingContext()
|
|
1332
|
+
// Caption ids live in the SAME unified selectedIds array as clips/audio —
|
|
1333
|
+
// no separate `selectedCaptionId` scene field.
|
|
1334
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['s1'] }))
|
|
1335
|
+
|
|
1336
|
+
expect(r.of('set:fillStyle').filter(c => c.args[0] === CAPTION_PALETTE.fillSelected)).toHaveLength(1)
|
|
1337
|
+
expect(r.of('set:fillStyle').filter(c => c.args[0] === CAPTION_PALETTE.fill)).toHaveLength(1)
|
|
1338
|
+
expect(r.of('set:strokeStyle').filter(c => c.args[0] === CAPTION_PALETTE.ring)).toHaveLength(1)
|
|
1339
|
+
// Exactly one block's worth of handles (2 pills: in + out).
|
|
1340
|
+
expect(r.of('set:fillStyle').filter(c => c.args[0] === TIMELINE_COLORS.handleFill)).toHaveLength(2)
|
|
1341
|
+
})
|
|
1342
|
+
|
|
1343
|
+
it('honors hoveredHandle for the selected caption', () => {
|
|
1344
|
+
const p = projectWithCaptions([captionSegment({ id: 's0', start: 0, end: 2, text: 'a' })])
|
|
1345
|
+
const r = recordingContext()
|
|
1346
|
+
drawTimelineContent(r.ctx, scene({
|
|
1347
|
+
project: p,
|
|
1348
|
+
layout: computeTimelineLayout(p),
|
|
1349
|
+
selectedIds: ['s0'],
|
|
1350
|
+
hoveredHandle: { itemId: 's0', edge: 'out' },
|
|
1351
|
+
}))
|
|
1352
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.handleFillHovered)).toBe(true)
|
|
1353
|
+
})
|
|
1354
|
+
|
|
1355
|
+
it('culls segments outside the visible range', () => {
|
|
1356
|
+
const p = projectWithCaptions([
|
|
1357
|
+
captionSegment({ id: 's0', start: 0, end: 2, text: 'a' }),
|
|
1358
|
+
captionSegment({ id: 's1', start: 500, end: 502, text: 'far away' }),
|
|
1359
|
+
])
|
|
1360
|
+
const r = recordingContext()
|
|
1361
|
+
const stats = drawTimelineContent(r.ctx, scene({
|
|
1362
|
+
project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }),
|
|
1363
|
+
}))
|
|
1364
|
+
expect(stats.captionItemsDrawn).toBe(1)
|
|
1365
|
+
expect(stats.itemsCulled).toBeGreaterThanOrEqual(1)
|
|
1366
|
+
})
|
|
1367
|
+
|
|
1368
|
+
it('draws an id-less segment (before backfillCaptionIds runs) but never selects it', () => {
|
|
1369
|
+
const p = projectWithCaptions([{ text: 'no id yet', start: 0, end: 2 } as CaptionSegment])
|
|
1370
|
+
const r = recordingContext()
|
|
1371
|
+
const stats = drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
|
|
1372
|
+
expect(stats.captionItemsDrawn).toBe(1)
|
|
1373
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === CAPTION_PALETTE.fillSelected)).toBe(false)
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
it('is a no-op on a project with no captions', () => {
|
|
1377
|
+
const p = project({ tracks: [[clip({ id: 'base' })]] } as unknown as Partial<Project>)
|
|
1378
|
+
const r = recordingContext()
|
|
1379
|
+
const stats = drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
|
|
1380
|
+
expect(stats.captionItemsDrawn).toBe(0)
|
|
1381
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === CAPTION_PALETTE.fill)).toBe(false)
|
|
1382
|
+
})
|
|
1383
|
+
|
|
1384
|
+
// ── N bands (Phase 3) ──
|
|
1385
|
+
|
|
1386
|
+
it('paints every band\'s background, including a hole lane that draws no blocks', () => {
|
|
1387
|
+
const p = projectWithCaptions([
|
|
1388
|
+
captionSegment({ id: 's0', lane: 0, start: 0, end: 2, text: 'a' }),
|
|
1389
|
+
captionSegment({ id: 's2', lane: 2, start: 4, end: 6, text: 'c' }),
|
|
1390
|
+
// Lane 1 is a hole — no segment sits there, but it still gets a band.
|
|
1391
|
+
])
|
|
1392
|
+
const layout = computeTimelineLayout(p)
|
|
1393
|
+
expect(layout.captions).toHaveLength(3)
|
|
1394
|
+
const holeBand = layout.captions!.find(c => c.lane === 1)!
|
|
1395
|
+
expect(holeBand.segments).toEqual([])
|
|
1396
|
+
|
|
1397
|
+
const r = recordingContext()
|
|
1398
|
+
const stats = drawTimelineContent(r.ctx, scene({ project: p, layout, viewport: viewport({ pxPerSecond: 10 }) }))
|
|
1399
|
+
|
|
1400
|
+
// Two real segments drawn; the hole lane draws none.
|
|
1401
|
+
expect(stats.captionItemsDrawn).toBe(2)
|
|
1402
|
+
// Its background is still painted. `drawRowBackground`'s `roundRectPath`
|
|
1403
|
+
// opens with `moveTo(x + radius, y)`, so finding that call at the hole
|
|
1404
|
+
// band's own y proves the fill pass ran for it despite the empty
|
|
1405
|
+
// segment list — this is what stops the timeline jumping by a whole
|
|
1406
|
+
// row height mid-drag when a caption moves out of a lane that was
|
|
1407
|
+
// otherwise alone.
|
|
1408
|
+
expect(r.of('moveTo').some(c => c.args[0] === ROW_RADIUS_PX && c.args[1] === holeBand.y)).toBe(true)
|
|
1409
|
+
})
|
|
1410
|
+
})
|
|
1411
|
+
|
|
1412
|
+
describe('the keyframe strip (SP9b T3.3)', () => {
|
|
1413
|
+
function keyframedOverlay(over: Partial<VisualItem> = {}): VisualItem {
|
|
1414
|
+
return clip({
|
|
1415
|
+
id: 'o0', type: 'overlay', src: undefined, start: 2, end: 4,
|
|
1416
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }] }],
|
|
1417
|
+
...over,
|
|
1418
|
+
} as unknown as Partial<VisualItem>)
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
it('draws diamonds for a selected, keyframed overlay', () => {
|
|
1422
|
+
const p = project({ tracks: [[keyframedOverlay()]] } as unknown as Partial<Project>)
|
|
1423
|
+
const r = recordingContext()
|
|
1424
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['o0'] }))
|
|
1425
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(true)
|
|
1426
|
+
})
|
|
1427
|
+
|
|
1428
|
+
it('draws nothing when the overlay is not selected', () => {
|
|
1429
|
+
const p = project({ tracks: [[keyframedOverlay()]] } as unknown as Partial<Project>)
|
|
1430
|
+
const r = recordingContext()
|
|
1431
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: [] }))
|
|
1432
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(false)
|
|
1433
|
+
})
|
|
1434
|
+
|
|
1435
|
+
it('draws nothing for a selected overlay with no keyframes', () => {
|
|
1436
|
+
const p = project({ tracks: [[clip({ id: 'o0', type: 'overlay', src: undefined, start: 2, end: 4 })]] } as unknown as Partial<Project>)
|
|
1437
|
+
const r = recordingContext()
|
|
1438
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['o0'] }))
|
|
1439
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(false)
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
it('DRAWS the strip for a selected, keyframed video clip (SP9d)', () => {
|
|
1443
|
+
// Clips became keyframeable when the renderer learned to compile curves
|
|
1444
|
+
// into ffmpeg expressions, so the strip has to show up for them too — a
|
|
1445
|
+
// keyframe you cannot see is a keyframe you cannot drag or delete.
|
|
1446
|
+
const p = project({ tracks: [[keyframedOverlay({ type: 'video' })]] } as unknown as Partial<Project>)
|
|
1447
|
+
const r = recordingContext()
|
|
1448
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['o0'] }))
|
|
1449
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(true)
|
|
1450
|
+
})
|
|
1451
|
+
|
|
1452
|
+
it('still draws nothing for a selected, keyframed item of a NON-visual kind', () => {
|
|
1453
|
+
// The gate must stay a gate — `canKeyframe`, not `isKeyframed` alone.
|
|
1454
|
+
const p = project({ tracks: [[keyframedOverlay({ type: 'audio' } as unknown as Partial<VisualItem>)]] } as unknown as Partial<Project>)
|
|
1455
|
+
const r = recordingContext()
|
|
1456
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['o0'] }))
|
|
1457
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(false)
|
|
1458
|
+
})
|
|
1459
|
+
|
|
1460
|
+
it('paints the diamond matching scene.selectedKeyframe differently from the other diamond', () => {
|
|
1461
|
+
const p = project({ tracks: [[keyframedOverlay({
|
|
1462
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 1, value: 10 }] }],
|
|
1463
|
+
})]] } as unknown as Partial<Project>)
|
|
1464
|
+
const r = recordingContext()
|
|
1465
|
+
drawTimelineContent(r.ctx, scene({
|
|
1466
|
+
project: p,
|
|
1467
|
+
layout: computeTimelineLayout(p),
|
|
1468
|
+
selectedIds: ['o0'],
|
|
1469
|
+
selectedKeyframe: { itemId: 'o0', t: 1 },
|
|
1470
|
+
}))
|
|
1471
|
+
const fills = r.of('set:fillStyle').map(c => c.args[0])
|
|
1472
|
+
expect(fills).toContain(TIMELINE_COLORS.keyframeDiamondSelectedFill)
|
|
1473
|
+
expect(fills).toContain(TIMELINE_COLORS.keyframeDiamondFill)
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1476
|
+
it('paints both diamonds identically when selectedKeyframe is null', () => {
|
|
1477
|
+
const p = project({ tracks: [[keyframedOverlay({
|
|
1478
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 1, value: 10 }] }],
|
|
1479
|
+
})]] } as unknown as Partial<Project>)
|
|
1480
|
+
const r = recordingContext()
|
|
1481
|
+
drawTimelineContent(r.ctx, scene({
|
|
1482
|
+
project: p,
|
|
1483
|
+
layout: computeTimelineLayout(p),
|
|
1484
|
+
selectedIds: ['o0'],
|
|
1485
|
+
selectedKeyframe: null,
|
|
1486
|
+
}))
|
|
1487
|
+
const diamondFills = r.of('set:fillStyle')
|
|
1488
|
+
.map(c => c.args[0])
|
|
1489
|
+
.filter(fill => fill === TIMELINE_COLORS.keyframeDiamondFill || fill === TIMELINE_COLORS.keyframeDiamondSelectedFill)
|
|
1490
|
+
expect(diamondFills).toHaveLength(2)
|
|
1491
|
+
expect(new Set(diamondFills).size).toBe(1)
|
|
1492
|
+
})
|
|
1493
|
+
|
|
1494
|
+
it('paints nothing selected when selectedKeyframe.itemId names a different item', () => {
|
|
1495
|
+
const p = project({ tracks: [[keyframedOverlay({
|
|
1496
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 1, value: 10 }] }],
|
|
1497
|
+
})]] } as unknown as Partial<Project>)
|
|
1498
|
+
const r = recordingContext()
|
|
1499
|
+
drawTimelineContent(r.ctx, scene({
|
|
1500
|
+
project: p,
|
|
1501
|
+
layout: computeTimelineLayout(p),
|
|
1502
|
+
selectedIds: ['o0'],
|
|
1503
|
+
selectedKeyframe: { itemId: 'some-other-item', t: 1 },
|
|
1504
|
+
}))
|
|
1505
|
+
const diamondFills = r.of('set:fillStyle')
|
|
1506
|
+
.map(c => c.args[0])
|
|
1507
|
+
.filter(fill => fill === TIMELINE_COLORS.keyframeDiamondFill || fill === TIMELINE_COLORS.keyframeDiamondSelectedFill)
|
|
1508
|
+
expect(diamondFills).toHaveLength(2)
|
|
1509
|
+
expect(new Set(diamondFills).size).toBe(1)
|
|
1510
|
+
})
|
|
1511
|
+
})
|
|
1512
|
+
})
|
|
1513
|
+
|
|
1514
|
+
describe('drawTimelineOverlay', () => {
|
|
1515
|
+
it('redraws only the playhead, at the viewport position', () => {
|
|
1516
|
+
const r = recordingContext()
|
|
1517
|
+
drawTimelineOverlay(r.ctx, {
|
|
1518
|
+
viewport: viewport({ pxPerSecond: 40, scrollSeconds: 2 }),
|
|
1519
|
+
currentTime: 5,
|
|
1520
|
+
surfaceWidth: 1000,
|
|
1521
|
+
surfaceHeight: 160,
|
|
1522
|
+
})
|
|
1523
|
+
expect(r.count('clearRect')).toBe(1)
|
|
1524
|
+
expect(r.of('fillRect')[0].args).toEqual([120 - PLAYHEAD_WIDTH_PX / 2, 0, PLAYHEAD_WIDTH_PX, 160])
|
|
1525
|
+
})
|
|
1526
|
+
|
|
1527
|
+
it('clears but draws nothing when the playhead is off-screen', () => {
|
|
1528
|
+
const r = recordingContext()
|
|
1529
|
+
drawTimelineOverlay(r.ctx, {
|
|
1530
|
+
viewport: viewport({ pxPerSecond: 40, scrollSeconds: 200 }),
|
|
1531
|
+
currentTime: 5,
|
|
1532
|
+
surfaceWidth: 1000,
|
|
1533
|
+
surfaceHeight: 160,
|
|
1534
|
+
})
|
|
1535
|
+
expect(r.count('clearRect')).toBe(1)
|
|
1536
|
+
expect(r.count('fillRect')).toBe(0)
|
|
1537
|
+
})
|
|
1538
|
+
|
|
1539
|
+
// ── The preview-axis cursor, on the same layer as the playhead ──
|
|
1540
|
+
|
|
1541
|
+
it('draws nothing extra when the axis is off (no cursor time)', () => {
|
|
1542
|
+
const r = recordingContext()
|
|
1543
|
+
drawTimelineOverlay(r.ctx, {
|
|
1544
|
+
viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
|
|
1545
|
+
currentTime: 5,
|
|
1546
|
+
cursorTime: null,
|
|
1547
|
+
surfaceWidth: 1000,
|
|
1548
|
+
surfaceHeight: 160,
|
|
1549
|
+
})
|
|
1550
|
+
expect(r.count('fillRect')).toBe(1)
|
|
1551
|
+
})
|
|
1552
|
+
|
|
1553
|
+
it('draws the cursor in yellow, before the playhead', () => {
|
|
1554
|
+
const r = recordingContext()
|
|
1555
|
+
drawTimelineOverlay(r.ctx, {
|
|
1556
|
+
viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
|
|
1557
|
+
currentTime: 5,
|
|
1558
|
+
cursorTime: 2,
|
|
1559
|
+
surfaceWidth: 1000,
|
|
1560
|
+
surfaceHeight: 160,
|
|
1561
|
+
})
|
|
1562
|
+
const rects = r.of('fillRect')
|
|
1563
|
+
expect(rects.length).toBe(2)
|
|
1564
|
+
// Cursor first (2s × 40px/s = 80), so the red playhead wins any overlap.
|
|
1565
|
+
expect(rects[0].args).toEqual([80 - CURSOR_WIDTH_PX / 2, 0, CURSOR_WIDTH_PX, 160])
|
|
1566
|
+
expect(rects[1].args).toEqual([200 - PLAYHEAD_WIDTH_PX / 2, 0, PLAYHEAD_WIDTH_PX, 160])
|
|
1567
|
+
const fills = r.of('set:fillStyle').map(c => c.args[0])
|
|
1568
|
+
expect(fills).toEqual([TIMELINE_COLORS.cursor, TIMELINE_COLORS.playhead])
|
|
1569
|
+
})
|
|
1570
|
+
|
|
1571
|
+
it('culls the cursor when it scrolls off-screen, keeping the playhead', () => {
|
|
1572
|
+
const r = recordingContext()
|
|
1573
|
+
drawTimelineOverlay(r.ctx, {
|
|
1574
|
+
viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
|
|
1575
|
+
currentTime: 5,
|
|
1576
|
+
cursorTime: 400,
|
|
1577
|
+
surfaceWidth: 1000,
|
|
1578
|
+
surfaceHeight: 160,
|
|
1579
|
+
})
|
|
1580
|
+
expect(r.count('fillRect')).toBe(1)
|
|
1581
|
+
expect(r.of('fillRect')[0].args).toEqual([200 - PLAYHEAD_WIDTH_PX / 2, 0, PLAYHEAD_WIDTH_PX, 160])
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1584
|
+
it('draws the cursor independently of the playhead — the two diverge by design', () => {
|
|
1585
|
+
// This is the whole feature: the pointer is at 3s, playback is still at 0.
|
|
1586
|
+
const r = recordingContext()
|
|
1587
|
+
drawTimelineOverlay(r.ctx, {
|
|
1588
|
+
viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
|
|
1589
|
+
currentTime: 0,
|
|
1590
|
+
cursorTime: 3,
|
|
1591
|
+
surfaceWidth: 1000,
|
|
1592
|
+
surfaceHeight: 160,
|
|
1593
|
+
})
|
|
1594
|
+
const rects = r.of('fillRect')
|
|
1595
|
+
expect(rects[0].args[0]).toBe(120 - CURSOR_WIDTH_PX / 2)
|
|
1596
|
+
expect(rects[1].args[0]).toBe(0 - PLAYHEAD_WIDTH_PX / 2)
|
|
1597
|
+
})
|
|
1598
|
+
|
|
1599
|
+
// ── The snap guide: what makes a strong magnet legible ──
|
|
1600
|
+
|
|
1601
|
+
it('draws no guide when no gesture is snapped', () => {
|
|
1602
|
+
const r = recordingContext()
|
|
1603
|
+
drawTimelineOverlay(r.ctx, {
|
|
1604
|
+
viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
|
|
1605
|
+
currentTime: 5,
|
|
1606
|
+
snapTime: null,
|
|
1607
|
+
surfaceWidth: 1000,
|
|
1608
|
+
surfaceHeight: 160,
|
|
1609
|
+
})
|
|
1610
|
+
expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.snapGuide)).toBe(false)
|
|
1611
|
+
})
|
|
1612
|
+
|
|
1613
|
+
it('marks the snapped boundary in cyan, with an arrowhead at each end', () => {
|
|
1614
|
+
const r = recordingContext()
|
|
1615
|
+
drawTimelineOverlay(r.ctx, {
|
|
1616
|
+
viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
|
|
1617
|
+
currentTime: 5,
|
|
1618
|
+
snapTime: 2,
|
|
1619
|
+
surfaceWidth: 1000,
|
|
1620
|
+
surfaceHeight: 160,
|
|
1621
|
+
})
|
|
1622
|
+
const rects = r.of('fillRect')
|
|
1623
|
+
// Playhead first (5s × 40 = 200), then the guide at 2s × 40 = 80.
|
|
1624
|
+
expect(rects).toHaveLength(2)
|
|
1625
|
+
expect(rects[1].args).toEqual([80 - SNAP_GUIDE_WIDTH_PX / 2, 0, SNAP_GUIDE_WIDTH_PX, 160])
|
|
1626
|
+
// Two triangles — the caps are what stop this reading as a third playhead.
|
|
1627
|
+
expect(r.of('closePath')).toHaveLength(2)
|
|
1628
|
+
expect(r.of('moveTo').map(c => c.args)).toEqual([
|
|
1629
|
+
[80 - SNAP_GUIDE_CAP_HALF_WIDTH_PX, 0],
|
|
1630
|
+
[80 - SNAP_GUIDE_CAP_HALF_WIDTH_PX, 160],
|
|
1631
|
+
])
|
|
1632
|
+
})
|
|
1633
|
+
|
|
1634
|
+
it('draws the guide LAST, so snapping to the playhead still looks snapped', () => {
|
|
1635
|
+
const r = recordingContext()
|
|
1636
|
+
drawTimelineOverlay(r.ctx, {
|
|
1637
|
+
viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
|
|
1638
|
+
currentTime: 3,
|
|
1639
|
+
cursorTime: 1,
|
|
1640
|
+
snapTime: 3,
|
|
1641
|
+
surfaceWidth: 1000,
|
|
1642
|
+
surfaceHeight: 160,
|
|
1643
|
+
})
|
|
1644
|
+
expect(r.of('set:fillStyle').map(c => c.args[0])).toEqual([
|
|
1645
|
+
TIMELINE_COLORS.cursor,
|
|
1646
|
+
TIMELINE_COLORS.playhead,
|
|
1647
|
+
TIMELINE_COLORS.snapGuide,
|
|
1648
|
+
])
|
|
1649
|
+
})
|
|
1650
|
+
|
|
1651
|
+
it('carries the tier through to the painter', () => {
|
|
1652
|
+
const r = recordingContext()
|
|
1653
|
+
drawTimelineOverlay(r.ctx, {
|
|
1654
|
+
viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
|
|
1655
|
+
currentTime: 5,
|
|
1656
|
+
snapTime: 2,
|
|
1657
|
+
snapStrength: 'weak',
|
|
1658
|
+
surfaceWidth: 1000,
|
|
1659
|
+
surfaceHeight: 160,
|
|
1660
|
+
})
|
|
1661
|
+
expect(r.of('set:fillStyle').map(c => c.args[0])).toEqual([
|
|
1662
|
+
TIMELINE_COLORS.playhead,
|
|
1663
|
+
TIMELINE_COLORS.snapGuideWeak,
|
|
1664
|
+
])
|
|
1665
|
+
})
|
|
1666
|
+
|
|
1667
|
+
it('culls the guide when it scrolls off-screen', () => {
|
|
1668
|
+
const r = recordingContext()
|
|
1669
|
+
drawTimelineOverlay(r.ctx, {
|
|
1670
|
+
viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
|
|
1671
|
+
currentTime: 5,
|
|
1672
|
+
snapTime: 400,
|
|
1673
|
+
surfaceWidth: 1000,
|
|
1674
|
+
surfaceHeight: 160,
|
|
1675
|
+
})
|
|
1676
|
+
expect(r.count('fillRect')).toBe(1)
|
|
1677
|
+
})
|
|
1678
|
+
})
|
|
1679
|
+
|
|
1680
|
+
// ── Culling: the virtualization acceptance probe ─────────────────────────
|
|
1681
|
+
|
|
1682
|
+
describe('draw culling', () => {
|
|
1683
|
+
/** 40 clips over two tracks and 60 audio segments, spanning ~400s. */
|
|
1684
|
+
function bigProject(): Project {
|
|
1685
|
+
const base: VisualItem[] = []
|
|
1686
|
+
const overlays: VisualItem[] = []
|
|
1687
|
+
for (let i = 0; i < 20; i++) {
|
|
1688
|
+
base.push(clip({ id: `base-${i}`, start: i * 20, end: i * 20 + 19 }))
|
|
1689
|
+
overlays.push(clip({ id: `ov-${i}`, type: 'overlay', start: i * 20 + 5, end: i * 20 + 12 }))
|
|
1690
|
+
}
|
|
1691
|
+
const audioTracks: AudioTrack[] = []
|
|
1692
|
+
for (let i = 0; i < 60; i++) {
|
|
1693
|
+
audioTracks.push(audio({ id: `au-${i}`, start: i * 6.5, end: i * 6.5 + 6, lane: i % 2 }))
|
|
1694
|
+
}
|
|
1695
|
+
return project({ tracks: [base, overlays], audio: { tracks: audioTracks } } as unknown as Partial<Project>)
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
it('draws only what intersects the visible window', () => {
|
|
1699
|
+
const p = bigProject()
|
|
1700
|
+
const layout = computeTimelineLayout(p)
|
|
1701
|
+
// 1000px surface at 100 px/s = a 10-second window at t=100.
|
|
1702
|
+
const zoomedIn = scene({ project: p, layout, viewport: viewport({ pxPerSecond: 100, scrollSeconds: 100 }) })
|
|
1703
|
+
|
|
1704
|
+
const r = recordingContext()
|
|
1705
|
+
const stats = drawTimelineContent(r.ctx, zoomedIn)
|
|
1706
|
+
|
|
1707
|
+
const totalItems = 40 + 60
|
|
1708
|
+
const drawn = stats.visualItemsDrawn + stats.audioItemsDrawn
|
|
1709
|
+
expect(drawn + stats.itemsCulled).toBe(totalItems)
|
|
1710
|
+
|
|
1711
|
+
// A 10s window can hold at most 1 base clip + 1 overlay + 2 audio bars per
|
|
1712
|
+
// lane. Bound it generously and still be an order below the project size.
|
|
1713
|
+
expect(drawn).toBeLessThanOrEqual(8)
|
|
1714
|
+
expect(stats.itemsCulled).toBeGreaterThanOrEqual(totalItems - 8)
|
|
1715
|
+
|
|
1716
|
+
// And the draw-call list is bounded by the viewport too — rows, the drawn
|
|
1717
|
+
// items and their labels, nothing per-culled-item.
|
|
1718
|
+
const rowCount = layout.rows.length + layout.lanes.length
|
|
1719
|
+
const base = rulerBaseline(zoomedIn.viewport)
|
|
1720
|
+
expect(r.count('fillText') - base.fillText).toBeLessThanOrEqual(drawn)
|
|
1721
|
+
expect(r.count('fillRect') - base.fillRect).toBeLessThanOrEqual(rowCount + drawn * 4)
|
|
1722
|
+
})
|
|
1723
|
+
|
|
1724
|
+
it('bounds draw calls by viewport size, not project size', () => {
|
|
1725
|
+
const small = bigProject()
|
|
1726
|
+
const huge = project({
|
|
1727
|
+
// Ten times the items over ten times the duration, same visible window.
|
|
1728
|
+
tracks: [Array.from({ length: 400 }, (_, i) => clip({ id: `c-${i}`, start: i * 10, end: i * 10 + 9 }))],
|
|
1729
|
+
audio: { tracks: Array.from({ length: 600 }, (_, i) => audio({ id: `a-${i}`, start: i * 6.5, end: i * 6.5 + 6, lane: i % 2 })) },
|
|
1730
|
+
} as unknown as Partial<Project>)
|
|
1731
|
+
|
|
1732
|
+
const window = { pxPerSecond: 100, scrollSeconds: 100 }
|
|
1733
|
+
const a = recordingContext()
|
|
1734
|
+
drawTimelineContent(a.ctx, scene({ project: small, layout: computeTimelineLayout(small), viewport: viewport(window) }))
|
|
1735
|
+
const b = recordingContext()
|
|
1736
|
+
drawTimelineContent(b.ctx, scene({ project: huge, layout: computeTimelineLayout(huge), viewport: viewport(window) }))
|
|
1737
|
+
|
|
1738
|
+
// 10× the project, the same handful of draw calls (within one row's worth).
|
|
1739
|
+
// The ruler is a fixed cost at a fixed viewport, so it cancels in the
|
|
1740
|
+
// difference and is subtracted from the absolute bound.
|
|
1741
|
+
const base = rulerBaseline(viewport(window))
|
|
1742
|
+
expect(Math.abs(b.calls.length - a.calls.length)).toBeLessThan(60)
|
|
1743
|
+
expect(b.calls.length - base.calls).toBeLessThan(200)
|
|
1744
|
+
})
|
|
1745
|
+
|
|
1746
|
+
it('draws more, but still only what fits, when zoomed out to the whole project', () => {
|
|
1747
|
+
const p = bigProject()
|
|
1748
|
+
const layout = computeTimelineLayout(p)
|
|
1749
|
+
const r = recordingContext()
|
|
1750
|
+
const stats = drawTimelineContent(r.ctx, scene({
|
|
1751
|
+
project: p, layout,
|
|
1752
|
+
viewport: viewport({ pxPerSecond: 2.5, scrollSeconds: 0 }), // 400s across 1000px
|
|
1753
|
+
}))
|
|
1754
|
+
expect(stats.visualItemsDrawn + stats.audioItemsDrawn).toBe(100)
|
|
1755
|
+
expect(stats.itemsCulled).toBe(0)
|
|
1756
|
+
})
|
|
1757
|
+
})
|
|
1758
|
+
|
|
1759
|
+
describe('skipped tracks', () => {
|
|
1760
|
+
it('marks a disabled track\'s row so the painter dims it', () => {
|
|
1761
|
+
const p = {
|
|
1762
|
+
id: 'p',
|
|
1763
|
+
tracks: [
|
|
1764
|
+
{ id: 't0', items: [clip({ id: 'c0' })] },
|
|
1765
|
+
{ id: 't1', items: [clip({ id: 'o0', type: 'overlay' })], enabled: false },
|
|
1766
|
+
],
|
|
1767
|
+
} as unknown as Project
|
|
1768
|
+
const layout = computeTimelineLayout(p)
|
|
1769
|
+
expect(layout.rows.find(r => r.trackIdx === 1)?.disabled).toBe(true)
|
|
1770
|
+
expect(layout.rows.find(r => r.trackIdx === 0)?.disabled).toBe(false)
|
|
1771
|
+
})
|
|
1772
|
+
|
|
1773
|
+
it('marks a muted track\'s row so the painter can gray its clips\' waveforms', () => {
|
|
1774
|
+
const p = {
|
|
1775
|
+
id: 'p',
|
|
1776
|
+
tracks: [
|
|
1777
|
+
{ id: 't0', items: [clip({ id: 'c0' })] },
|
|
1778
|
+
{ id: 't1', items: [clip({ id: 'o0', type: 'overlay' })], muted: true },
|
|
1779
|
+
],
|
|
1780
|
+
} as unknown as Project
|
|
1781
|
+
const layout = computeTimelineLayout(p)
|
|
1782
|
+
expect(layout.rows.find(r => r.trackIdx === 1)?.trackMuted).toBe(true)
|
|
1783
|
+
expect(layout.rows.find(r => r.trackIdx === 0)?.trackMuted).toBe(false)
|
|
1784
|
+
})
|
|
1785
|
+
|
|
1786
|
+
it('keeps a skipped row at full height and in place — it stays editable', () => {
|
|
1787
|
+
// Dimmed, not collapsed: you have to be able to see a skipped track and
|
|
1788
|
+
// click its clips to turn it back on.
|
|
1789
|
+
const enabled = { id: 'p', tracks: [{ id: 't0', items: [clip({ id: 'c0' })] }] } as unknown as Project
|
|
1790
|
+
const skipped = { id: 'p', tracks: [{ id: 't0', items: [clip({ id: 'c0' })], enabled: false }] } as unknown as Project
|
|
1791
|
+
expect(computeTimelineLayout(skipped).rows[0].height).toBe(computeTimelineLayout(enabled).rows[0].height)
|
|
1792
|
+
expect(computeTimelineLayout(skipped).height).toBe(computeTimelineLayout(enabled).height)
|
|
1793
|
+
})
|
|
1794
|
+
|
|
1795
|
+
it('draws a skipped row at reduced alpha', () => {
|
|
1796
|
+
const p = { id: 'p', tracks: [{ id: 't0', items: [clip({ id: 'c0', start: 0, end: 2 })], enabled: false }] } as unknown as Project
|
|
1797
|
+
const r = recordingContext()
|
|
1798
|
+
drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
|
|
1799
|
+
expect(r.calls.some(c => c.method === 'set:globalAlpha' && c.args[0] === 0.3)).toBe(true)
|
|
1800
|
+
})
|
|
1801
|
+
})
|
|
1802
|
+
|
|
1803
|
+
describe('drawTimelineContent — an audio track with no start/end', () => {
|
|
1804
|
+
// Regression: `intersectsRange(undefined, undefined, range)` is false, so the
|
|
1805
|
+
// lane was culled and the timeline showed an empty row while the export was
|
|
1806
|
+
// correct. See `resolveAudioWindow` in timeline-model.ts.
|
|
1807
|
+
const p = {
|
|
1808
|
+
id: 'p',
|
|
1809
|
+
tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10 }]],
|
|
1810
|
+
audio: { tracks: [{ id: 'bed', src: 'song.mp3' }] },
|
|
1811
|
+
} as unknown as Project
|
|
1812
|
+
|
|
1813
|
+
it('draws the bar instead of culling it', () => {
|
|
1814
|
+
const layout = computeTimelineLayout(p)
|
|
1815
|
+
expect(layout.lanes).toHaveLength(1)
|
|
1816
|
+
const r = recordingContext()
|
|
1817
|
+
const stats = drawTimelineContent(r.ctx, scene({ project: p, layout }))
|
|
1818
|
+
expect(stats.audioItemsDrawn).toBe(1)
|
|
1819
|
+
expect(stats.itemsCulled).toBe(0)
|
|
1820
|
+
})
|
|
1821
|
+
|
|
1822
|
+
it('gives it a positive width', () => {
|
|
1823
|
+
const layout = computeTimelineLayout(p)
|
|
1824
|
+
const [t] = layout.lanes[0].tracks
|
|
1825
|
+
expect(t.end - t.start).toBeGreaterThan(0)
|
|
1826
|
+
})
|
|
1827
|
+
})
|
|
1828
|
+
|
|
1829
|
+
// ── Light/dark palette ───────────────────────────────────────────────────
|
|
1830
|
+
//
|
|
1831
|
+
// The canvas can't read a CSS variable, so the whole light theme reaches these
|
|
1832
|
+
// pixels through `timelinePalette(mode)`. Two things have to hold at once and
|
|
1833
|
+
// they pull in opposite directions: the LIGHT set must genuinely be a different
|
|
1834
|
+
// palette (not the dark numbers nudged), and the DARK set must be
|
|
1835
|
+
// byte-identical to what it always was — which is why every painter takes the
|
|
1836
|
+
// palette as an OPTIONAL trailing argument and every assertion above this
|
|
1837
|
+
// block still calls them without one.
|
|
1838
|
+
|
|
1839
|
+
/** Every colour string a palette can put on the canvas, flattened. Used to
|
|
1840
|
+
* ask "did any value from the OTHER mode leak into this paint?", which is the
|
|
1841
|
+
* question a per-key spot-check can't answer. */
|
|
1842
|
+
function paletteValues(p: TimelinePalette): string[] {
|
|
1843
|
+
return [
|
|
1844
|
+
...Object.values(p.colors),
|
|
1845
|
+
...p.tracks.flatMap(t => Object.values(t)),
|
|
1846
|
+
...Object.values(p.caption),
|
|
1847
|
+
p.captionRailAccent,
|
|
1848
|
+
...Object.values(p.waveform),
|
|
1849
|
+
p.labelShadow,
|
|
1850
|
+
]
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/** Values this mode uses that the OTHER mode never does — the literals whose
|
|
1854
|
+
* appearance in a paint proves the wrong palette was consulted. Computed
|
|
1855
|
+
* rather than listed so a future key can't quietly escape the check. */
|
|
1856
|
+
function exclusiveTo(mode: TimelinePalette, other: TimelinePalette): string[] {
|
|
1857
|
+
const otherValues = new Set(paletteValues(other))
|
|
1858
|
+
return [...new Set(paletteValues(mode))].filter(v => !otherValues.has(v))
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/** Every colour actually written to the context during a paint. */
|
|
1862
|
+
function stylesWritten(r: Recorder): unknown[] {
|
|
1863
|
+
return r.calls
|
|
1864
|
+
.filter(c => c.method === 'set:fillStyle' || c.method === 'set:strokeStyle' || c.method === 'set:shadowColor')
|
|
1865
|
+
.map(c => c.args[0])
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/** A project exercising every painter that reads a colour: a clip (row
|
|
1869
|
+
* background, track fill, selection outline, handles, waveform band), a
|
|
1870
|
+
* labelled overlay (label text + its shadow), a caption block, an audio bar
|
|
1871
|
+
* with a fade, and — via `selectedIds` — the selected states of each. */
|
|
1872
|
+
function paletteProject(): Project {
|
|
1873
|
+
return project({
|
|
1874
|
+
tracks: [
|
|
1875
|
+
[clip({ id: 'c0', start: 0, end: 4 })],
|
|
1876
|
+
// Wide enough (60px at the fixture viewport) to clear MIN_LABEL_WIDTH_PX,
|
|
1877
|
+
// so the label — and the halo behind it — actually paints.
|
|
1878
|
+
[clip({ id: 'ov0', type: 'overlay', src: 'title.jsx', start: 0, end: 6 })],
|
|
1879
|
+
],
|
|
1880
|
+
audio: { tracks: [audio({ id: 'a0', start: 0, end: 3, fadeIn: 0.5, fadeOut: 0.5 })] },
|
|
1881
|
+
captions: { segments: [captionSegment({ id: 'seg0', start: 0, end: 2 })] },
|
|
1882
|
+
} as unknown as Partial<Project>)
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/** A waveform lookup that always has data, so the clip band and the audio-lane
|
|
1886
|
+
* bars are actually painted and their colours are observable. */
|
|
1887
|
+
const ALWAYS_WAVEFORM = {
|
|
1888
|
+
clipColumns: () => [{ min: -0.5, max: 0.5 }],
|
|
1889
|
+
audioColumns: (_t: unknown, rect: { x: number; y: number; width: number; height: number }) =>
|
|
1890
|
+
({ rect, columns: [{ min: -0.5, max: 0.5 }] }),
|
|
1891
|
+
} as unknown as TimelineScene['waveforms']
|
|
1892
|
+
|
|
1893
|
+
describe('timelinePalette', () => {
|
|
1894
|
+
it('resolves dark to the exact constants the module has always exported', () => {
|
|
1895
|
+
// Identity, not deep-equality: `TIMELINE_COLORS` and friends are still the
|
|
1896
|
+
// objects every other module imports, so nothing can have been rebuilt or
|
|
1897
|
+
// re-typed underneath them.
|
|
1898
|
+
const dark = timelinePalette('dark')
|
|
1899
|
+
expect(dark).toBe(DARK_TIMELINE_PALETTE)
|
|
1900
|
+
expect(dark.colors).toBe(TIMELINE_COLORS)
|
|
1901
|
+
expect(dark.tracks).toBe(TRACK_PALETTE)
|
|
1902
|
+
expect(dark.caption).toBe(CAPTION_PALETTE)
|
|
1903
|
+
expect(dark.captionRailAccent).toBe(CAPTION_RAIL_ACCENT)
|
|
1904
|
+
expect(dark.waveform).toBe(WAVEFORM_COLORS)
|
|
1905
|
+
expect(dark.labelShadow).toBe(LABEL_SHADOW_COLOR)
|
|
1906
|
+
})
|
|
1907
|
+
|
|
1908
|
+
it('resolves light to the light constants', () => {
|
|
1909
|
+
const light = timelinePalette('light')
|
|
1910
|
+
expect(light).toBe(LIGHT_TIMELINE_PALETTE)
|
|
1911
|
+
expect(light.colors).toBe(LIGHT_TIMELINE_COLORS)
|
|
1912
|
+
expect(light.tracks).toBe(LIGHT_TRACK_PALETTE)
|
|
1913
|
+
expect(light.caption).toBe(LIGHT_CAPTION_PALETTE)
|
|
1914
|
+
expect(light.captionRailAccent).toBe(LIGHT_CAPTION_RAIL_ACCENT)
|
|
1915
|
+
expect(light.waveform).toBe(LIGHT_WAVEFORM_COLORS)
|
|
1916
|
+
expect(light.labelShadow).toBe(LIGHT_LABEL_SHADOW_COLOR)
|
|
1917
|
+
})
|
|
1918
|
+
|
|
1919
|
+
it('returns the same object every call, so callers can memoize on identity', () => {
|
|
1920
|
+
expect(timelinePalette('light')).toBe(timelinePalette('light'))
|
|
1921
|
+
expect(timelinePalette('dark')).toBe(timelinePalette('dark'))
|
|
1922
|
+
})
|
|
1923
|
+
|
|
1924
|
+
it('differs from dark on the keys that carry the theme', () => {
|
|
1925
|
+
const dark = timelinePalette('dark').colors
|
|
1926
|
+
const light = timelinePalette('light').colors
|
|
1927
|
+
// The row field itself, and the chrome strip above it.
|
|
1928
|
+
expect(light.rowBackground).not.toBe(dark.rowBackground)
|
|
1929
|
+
expect(light.rowBackgroundAlt).not.toBe(dark.rowBackgroundAlt)
|
|
1930
|
+
expect(light.rulerBackground).not.toBe(dark.rulerBackground)
|
|
1931
|
+
// The selection vocabulary — white in dark, and it cannot stay white.
|
|
1932
|
+
expect(light.clipSelectedOutline).not.toBe(dark.clipSelectedOutline)
|
|
1933
|
+
expect(light.handleFill).not.toBe(dark.handleFill)
|
|
1934
|
+
expect(light.marqueeBorder).not.toBe(dark.marqueeBorder)
|
|
1935
|
+
// The colourless overlap treatment.
|
|
1936
|
+
expect(light.overlapHatch).not.toBe(dark.overlapHatch)
|
|
1937
|
+
expect(light.overlapEdge).not.toBe(dark.overlapEdge)
|
|
1938
|
+
// Track/caption labels have to be readable on their own fills.
|
|
1939
|
+
expect(light.audioText).not.toBe(dark.audioText)
|
|
1940
|
+
expect(LIGHT_TRACK_PALETTE[0].text).not.toBe(TRACK_PALETTE[0].text)
|
|
1941
|
+
expect(LIGHT_CAPTION_PALETTE.text).not.toBe(CAPTION_PALETTE.text)
|
|
1942
|
+
})
|
|
1943
|
+
|
|
1944
|
+
it('keeps a light entry for every dark key and vice versa', () => {
|
|
1945
|
+
expect(Object.keys(LIGHT_TIMELINE_COLORS).sort()).toEqual(Object.keys(TIMELINE_COLORS).sort())
|
|
1946
|
+
expect(Object.keys(LIGHT_WAVEFORM_COLORS).sort()).toEqual(Object.keys(WAVEFORM_COLORS).sort())
|
|
1947
|
+
expect(LIGHT_TRACK_PALETTE).toHaveLength(TRACK_PALETTE.length)
|
|
1948
|
+
})
|
|
1949
|
+
})
|
|
1950
|
+
|
|
1951
|
+
describe('the light palette is a real inversion, not a copy', () => {
|
|
1952
|
+
/** The two entries that are IDENTICAL across modes on purpose:
|
|
1953
|
+
* - `playhead` is red-500 and unmistakable on either ground;
|
|
1954
|
+
* - `keyframeDiamondFill` is amber because no other hue on this surface is
|
|
1955
|
+
* free to mean "keyframe", and a diamond is painted over the clip's own
|
|
1956
|
+
* content (an arbitrary filmstrip frame), not over the row background, so
|
|
1957
|
+
* it is not tuned to either ground in the first place.
|
|
1958
|
+
* Everything else must move. */
|
|
1959
|
+
const DELIBERATELY_SHARED = ['playhead', 'keyframeDiamondFill'] as const
|
|
1960
|
+
|
|
1961
|
+
it('changes every colour except the two that are deliberately shared', () => {
|
|
1962
|
+
const shared = (Object.keys(TIMELINE_COLORS) as (keyof typeof TIMELINE_COLORS)[])
|
|
1963
|
+
.filter(k => LIGHT_TIMELINE_COLORS[k] === TIMELINE_COLORS[k])
|
|
1964
|
+
expect(shared.sort()).toEqual([...DELIBERATELY_SHARED].sort())
|
|
1965
|
+
})
|
|
1966
|
+
|
|
1967
|
+
it('inverts every white-on-dark overlay to a dark-on-light one', () => {
|
|
1968
|
+
// These are the trap: `rgba(255,255,255,…)` reads as "the mark" on a dark
|
|
1969
|
+
// row and as "nothing at all" on a light one. Each must have stopped being
|
|
1970
|
+
// white — asserted structurally rather than by naming the replacement, so
|
|
1971
|
+
// the check survives a future tweak to the exact near-black used.
|
|
1972
|
+
const whiteInDark = [
|
|
1973
|
+
'overlapFill', 'overlapHatch', 'overlapEdge',
|
|
1974
|
+
'fadeEnvelopeLine', 'fadeGripSubtle', 'fadeGripActive',
|
|
1975
|
+
'clipSelectedOutline', 'handleFill', 'handleFillHovered',
|
|
1976
|
+
'keyframeDiamondSelectedFill', 'marqueeFill', 'marqueeBorder',
|
|
1977
|
+
'audioMutedFill',
|
|
1978
|
+
] as const
|
|
1979
|
+
for (const key of whiteInDark) {
|
|
1980
|
+
expect(TIMELINE_COLORS[key]).toMatch(/^(#ffffff|rgba\(255,255,255)/)
|
|
1981
|
+
expect(LIGHT_TIMELINE_COLORS[key]).not.toMatch(/^(#ffffff|rgba\(255,255,255)/)
|
|
1982
|
+
}
|
|
1983
|
+
expect(WAVEFORM_COLORS.clip).toMatch(/^rgba\(255,255,255/)
|
|
1984
|
+
expect(LIGHT_WAVEFORM_COLORS.clip).not.toMatch(/^rgba\(255,255,255/)
|
|
1985
|
+
expect(LIGHT_WAVEFORM_COLORS.clipMuted).not.toMatch(/^rgba\(255,255,255/)
|
|
1986
|
+
})
|
|
1987
|
+
|
|
1988
|
+
it('flips the hatch under-stroke so a dark hatch still has a ground', () => {
|
|
1989
|
+
// Dark: white stripes over a BLACK under-stroke. Light: near-black stripes
|
|
1990
|
+
// over a WHITE one. The under-stroke exists to be the opposite value of
|
|
1991
|
+
// the stripe, whatever filmstrip frame is behind them.
|
|
1992
|
+
expect(TIMELINE_COLORS.overlapHatchShadow).toMatch(/^rgba\(0,0,0/)
|
|
1993
|
+
expect(LIGHT_TIMELINE_COLORS.overlapHatchShadow).toMatch(/^rgba\(255,255,255/)
|
|
1994
|
+
// Same reasoning for the keyframe diamond's halo and the clip label's.
|
|
1995
|
+
expect(TIMELINE_COLORS.keyframeDiamondStroke).toMatch(/^rgba\(0,0,0/)
|
|
1996
|
+
expect(LIGHT_TIMELINE_COLORS.keyframeDiamondStroke).toMatch(/^rgba\(255,255,255/)
|
|
1997
|
+
expect(LABEL_SHADOW_COLOR).toMatch(/^rgba\(0,0,0/)
|
|
1998
|
+
expect(LIGHT_LABEL_SHADOW_COLOR).toMatch(/^rgba\(255,255,255/)
|
|
1999
|
+
})
|
|
2000
|
+
|
|
2001
|
+
it('keeps "no two meanings share one literal" for the overlap/mute pair', () => {
|
|
2002
|
+
// The dark set says 0.12 rather than the natural 0.1 precisely because
|
|
2003
|
+
// `audioMutedFill` already IS 0.1 — a muted bar and an overlap band must
|
|
2004
|
+
// stay distinguishable by colour alone. That property is a rule about the
|
|
2005
|
+
// palette, not about dark, so it has to hold in light too.
|
|
2006
|
+
expect(TIMELINE_COLORS.overlapFill).not.toBe(TIMELINE_COLORS.audioMutedFill)
|
|
2007
|
+
expect(LIGHT_TIMELINE_COLORS.overlapFill).not.toBe(LIGHT_TIMELINE_COLORS.audioMutedFill)
|
|
2008
|
+
expect(LIGHT_TIMELINE_COLORS.marqueeFill).not.toBe(LIGHT_TIMELINE_COLORS.audioMutedFill)
|
|
2009
|
+
})
|
|
2010
|
+
|
|
2011
|
+
it('gives every track hue its own entry in both modes', () => {
|
|
2012
|
+
// Six distinct hues is the whole point of the cycle — a light palette that
|
|
2013
|
+
// collapsed two of them would silently make two tracks look the same.
|
|
2014
|
+
expect(new Set(LIGHT_TRACK_PALETTE.map(t => t.fill)).size).toBe(LIGHT_TRACK_PALETTE.length)
|
|
2015
|
+
expect(new Set(LIGHT_TRACK_PALETTE.map(t => t.text)).size).toBe(LIGHT_TRACK_PALETTE.length)
|
|
2016
|
+
})
|
|
2017
|
+
})
|
|
2018
|
+
|
|
2019
|
+
describe('painters use the palette they are handed, not the module global', () => {
|
|
2020
|
+
// This is the assertion that catches the real bug: a painter that still
|
|
2021
|
+
// reads `TIMELINE_COLORS` directly passes any test that only checks the
|
|
2022
|
+
// palette CONSTANTS differ.
|
|
2023
|
+
|
|
2024
|
+
it('drawRowBackground paints the light row and its light divider', () => {
|
|
2025
|
+
const r = recordingContext()
|
|
2026
|
+
drawRowBackground(r.ctx, { x: 0, y: 0, width: 100, height: 20 }, undefined, LIGHT_TIMELINE_PALETTE)
|
|
2027
|
+
expect(r.of('set:fillStyle').map(c => c.args[0])).toContain(LIGHT_TIMELINE_COLORS.rowBackground)
|
|
2028
|
+
expect(r.of('set:strokeStyle').map(c => c.args[0])).toContain(LIGHT_TIMELINE_COLORS.rowDivider)
|
|
2029
|
+
expect(r.of('set:fillStyle').map(c => c.args[0])).not.toContain(TIMELINE_COLORS.rowBackground)
|
|
2030
|
+
expect(r.of('set:strokeStyle').map(c => c.args[0])).not.toContain(TIMELINE_COLORS.rowDivider)
|
|
2031
|
+
})
|
|
2032
|
+
|
|
2033
|
+
it('drawItemHandles paints the light pill and the light grip ticks', () => {
|
|
2034
|
+
const r = recordingContext()
|
|
2035
|
+
drawItemHandles(r.ctx, { x: 0, y: 0, width: 200, height: 40 }, CLIP_HANDLE_WIDTH_PX, 'in', undefined, LIGHT_TIMELINE_PALETTE)
|
|
2036
|
+
const fills = r.of('set:fillStyle').map(c => c.args[0])
|
|
2037
|
+
expect(fills).toContain(LIGHT_TIMELINE_COLORS.handleFillHovered)
|
|
2038
|
+
expect(fills).toContain(LIGHT_TIMELINE_COLORS.handleGripHovered)
|
|
2039
|
+
expect(fills).not.toContain(TIMELINE_COLORS.handleFill)
|
|
2040
|
+
expect(fills).not.toContain(TIMELINE_COLORS.handleGrip)
|
|
2041
|
+
})
|
|
2042
|
+
|
|
2043
|
+
it('drawCaptionBlock takes its hue from the palette, not CAPTION_PALETTE', () => {
|
|
2044
|
+
const r = recordingContext()
|
|
2045
|
+
drawCaptionBlock(r.ctx, { rect: { x: 0, y: 0, width: 120, height: 20 }, selected: false, label: 'hi' }, LIGHT_TIMELINE_PALETTE)
|
|
2046
|
+
const fills = r.of('set:fillStyle').map(c => c.args[0])
|
|
2047
|
+
expect(fills).toContain(LIGHT_CAPTION_PALETTE.fill)
|
|
2048
|
+
expect(fills).toContain(LIGHT_CAPTION_PALETTE.text)
|
|
2049
|
+
expect(fills).not.toContain(CAPTION_PALETTE.fill)
|
|
2050
|
+
expect(r.of('set:strokeStyle').map(c => c.args[0])).toContain(LIGHT_CAPTION_PALETTE.border)
|
|
2051
|
+
})
|
|
2052
|
+
|
|
2053
|
+
it('drawOverlapBand paints the light hatch over the light under-stroke', () => {
|
|
2054
|
+
const r = recordingContext()
|
|
2055
|
+
drawOverlapBand(r.ctx, { x: 0, y: 0, width: 60, height: 40 }, LIGHT_TIMELINE_PALETTE)
|
|
2056
|
+
const strokes = r.of('set:strokeStyle').map(c => c.args[0])
|
|
2057
|
+
expect(strokes).toEqual([LIGHT_TIMELINE_COLORS.overlapHatchShadow, LIGHT_TIMELINE_COLORS.overlapHatch])
|
|
2058
|
+
expect(r.of('set:fillStyle').map(c => c.args[0])).toContain(LIGHT_TIMELINE_COLORS.overlapEdge)
|
|
2059
|
+
})
|
|
2060
|
+
|
|
2061
|
+
it('drawRuler paints the light chrome strip, ticks and labels', () => {
|
|
2062
|
+
const r = recordingContext()
|
|
2063
|
+
drawRuler(r.ctx, viewport(), { y: 0, height: RULER_HEIGHT_PX }, 1000, LIGHT_TIMELINE_PALETTE)
|
|
2064
|
+
const fills = r.of('set:fillStyle').map(c => c.args[0])
|
|
2065
|
+
expect(fills).toContain(LIGHT_TIMELINE_COLORS.rulerBackground)
|
|
2066
|
+
expect(fills).toContain(LIGHT_TIMELINE_COLORS.rulerTick)
|
|
2067
|
+
expect(fills).toContain(LIGHT_TIMELINE_COLORS.rulerText)
|
|
2068
|
+
expect(fills).not.toContain(TIMELINE_COLORS.rulerBackground)
|
|
2069
|
+
})
|
|
2070
|
+
|
|
2071
|
+
it('drawMarquee, drawPlayhead, drawCursorLine and drawSnapGuide take theirs too', () => {
|
|
2072
|
+
const r = recordingContext()
|
|
2073
|
+
drawMarquee(r.ctx, { x: 0, y: 0, width: 40, height: 40 }, LIGHT_TIMELINE_PALETTE)
|
|
2074
|
+
drawPlayhead(r.ctx, 10, 0, 40, LIGHT_TIMELINE_PALETTE)
|
|
2075
|
+
drawCursorLine(r.ctx, 12, 0, 40, LIGHT_TIMELINE_PALETTE)
|
|
2076
|
+
drawSnapGuide(r.ctx, 14, 0, 40, 'strong', LIGHT_TIMELINE_PALETTE)
|
|
2077
|
+
const styles = stylesWritten(r)
|
|
2078
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.marqueeFill)
|
|
2079
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.marqueeBorder)
|
|
2080
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.cursor)
|
|
2081
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.snapGuide)
|
|
2082
|
+
// Shared on purpose — the light pass still paints red-500 here.
|
|
2083
|
+
expect(styles).toContain(TIMELINE_COLORS.playhead)
|
|
2084
|
+
expect(styles).not.toContain(TIMELINE_COLORS.cursor)
|
|
2085
|
+
expect(styles).not.toContain(TIMELINE_COLORS.marqueeBorder)
|
|
2086
|
+
})
|
|
2087
|
+
})
|
|
2088
|
+
|
|
2089
|
+
describe('drawTimelineContent / drawTimelineOverlay — mode', () => {
|
|
2090
|
+
it('paints the whole content layer from the light set and never from the dark one', () => {
|
|
2091
|
+
const p = paletteProject()
|
|
2092
|
+
const r = recordingContext()
|
|
2093
|
+
drawTimelineContent(r.ctx, scene({
|
|
2094
|
+
project: p,
|
|
2095
|
+
layout: computeTimelineLayout(p),
|
|
2096
|
+
selectedIds: ['c0', 'ov0', 'a0', 'seg0'],
|
|
2097
|
+
waveforms: ALWAYS_WAVEFORM,
|
|
2098
|
+
mode: 'light',
|
|
2099
|
+
}))
|
|
2100
|
+
const styles = stylesWritten(r)
|
|
2101
|
+
|
|
2102
|
+
// Spot-checks across every painter the pass runs.
|
|
2103
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.rowBackground)
|
|
2104
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.rowBackgroundAlt)
|
|
2105
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.rulerBackground)
|
|
2106
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.clipSelectedOutline)
|
|
2107
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.handleFill)
|
|
2108
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.audioFill)
|
|
2109
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.fadeEnvelopeDim)
|
|
2110
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.fadeEnvelopeLine)
|
|
2111
|
+
expect(styles).toContain(LIGHT_TRACK_PALETTE[0].fillSelected)
|
|
2112
|
+
expect(styles).toContain(LIGHT_TRACK_PALETTE[1].text)
|
|
2113
|
+
expect(styles).toContain(LIGHT_CAPTION_PALETTE.fillSelected)
|
|
2114
|
+
// Threaded into the waveform painters from the SAME resolved palette.
|
|
2115
|
+
expect(styles).toContain(LIGHT_WAVEFORM_COLORS.clipBand)
|
|
2116
|
+
expect(styles).toContain(LIGHT_WAVEFORM_COLORS.clip)
|
|
2117
|
+
expect(styles).toContain(LIGHT_WAVEFORM_COLORS.audioLane)
|
|
2118
|
+
// The clip label's halo (a shadowColor, not a fill) follows too.
|
|
2119
|
+
expect(styles).toContain(LIGHT_LABEL_SHADOW_COLOR)
|
|
2120
|
+
|
|
2121
|
+
// And nothing dark-only leaked through — the check a spot-check can't make.
|
|
2122
|
+
for (const darkOnly of exclusiveTo(DARK_TIMELINE_PALETTE, LIGHT_TIMELINE_PALETTE)) {
|
|
2123
|
+
expect(styles).not.toContain(darkOnly)
|
|
2124
|
+
}
|
|
2125
|
+
})
|
|
2126
|
+
|
|
2127
|
+
it('defaults to dark when no mode is given, with no light value anywhere', () => {
|
|
2128
|
+
// The no-regression proof: every caller and every test that predates modes
|
|
2129
|
+
// goes down this path.
|
|
2130
|
+
const p = paletteProject()
|
|
2131
|
+
const r = recordingContext()
|
|
2132
|
+
drawTimelineContent(r.ctx, scene({
|
|
2133
|
+
project: p,
|
|
2134
|
+
layout: computeTimelineLayout(p),
|
|
2135
|
+
selectedIds: ['c0', 'ov0', 'a0', 'seg0'],
|
|
2136
|
+
waveforms: ALWAYS_WAVEFORM,
|
|
2137
|
+
}))
|
|
2138
|
+
const styles = stylesWritten(r)
|
|
2139
|
+
expect(styles).toContain(TIMELINE_COLORS.rowBackground)
|
|
2140
|
+
expect(styles).toContain(TIMELINE_COLORS.clipSelectedOutline)
|
|
2141
|
+
expect(styles).toContain(WAVEFORM_COLORS.clip)
|
|
2142
|
+
expect(styles).toContain(LABEL_SHADOW_COLOR)
|
|
2143
|
+
for (const lightOnly of exclusiveTo(LIGHT_TIMELINE_PALETTE, DARK_TIMELINE_PALETTE)) {
|
|
2144
|
+
expect(styles).not.toContain(lightOnly)
|
|
2145
|
+
}
|
|
2146
|
+
})
|
|
2147
|
+
|
|
2148
|
+
it('paints an explicit dark mode identically to an omitted one', () => {
|
|
2149
|
+
const p = paletteProject()
|
|
2150
|
+
const omitted = recordingContext()
|
|
2151
|
+
const explicit = recordingContext()
|
|
2152
|
+
const args = { project: p, layout: computeTimelineLayout(p), selectedIds: ['c0'], waveforms: ALWAYS_WAVEFORM }
|
|
2153
|
+
drawTimelineContent(omitted.ctx, scene(args))
|
|
2154
|
+
drawTimelineContent(explicit.ctx, scene({ ...args, mode: 'dark' }))
|
|
2155
|
+
expect(explicit.calls).toEqual(omitted.calls)
|
|
2156
|
+
})
|
|
2157
|
+
|
|
2158
|
+
it('paints the overlay layer from the light set', () => {
|
|
2159
|
+
const r = recordingContext()
|
|
2160
|
+
drawTimelineOverlay(r.ctx, {
|
|
2161
|
+
viewport: viewport(),
|
|
2162
|
+
currentTime: 1,
|
|
2163
|
+
cursorTime: 1.5,
|
|
2164
|
+
snapTime: 2,
|
|
2165
|
+
snapStrength: 'weak',
|
|
2166
|
+
marquee: { x: 0, y: 0, width: 40, height: 40 },
|
|
2167
|
+
surfaceWidth: 1000,
|
|
2168
|
+
surfaceHeight: 200,
|
|
2169
|
+
mode: 'light',
|
|
2170
|
+
})
|
|
2171
|
+
const styles = stylesWritten(r)
|
|
2172
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.marqueeFill)
|
|
2173
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.cursor)
|
|
2174
|
+
expect(styles).toContain(LIGHT_TIMELINE_COLORS.snapGuideWeak)
|
|
2175
|
+
expect(styles).toContain(TIMELINE_COLORS.playhead) // shared on purpose
|
|
2176
|
+
expect(styles).not.toContain(TIMELINE_COLORS.cursor)
|
|
2177
|
+
expect(styles).not.toContain(TIMELINE_COLORS.snapGuideWeak)
|
|
2178
|
+
expect(styles).not.toContain(TIMELINE_COLORS.marqueeFill)
|
|
2179
|
+
})
|
|
2180
|
+
|
|
2181
|
+
it('leaves the overlay dark when no mode is given', () => {
|
|
2182
|
+
const r = recordingContext()
|
|
2183
|
+
drawTimelineOverlay(r.ctx, {
|
|
2184
|
+
viewport: viewport(),
|
|
2185
|
+
currentTime: 1,
|
|
2186
|
+
cursorTime: 1.5,
|
|
2187
|
+
snapTime: 2,
|
|
2188
|
+
marquee: { x: 0, y: 0, width: 40, height: 40 },
|
|
2189
|
+
surfaceWidth: 1000,
|
|
2190
|
+
surfaceHeight: 200,
|
|
2191
|
+
})
|
|
2192
|
+
const styles = stylesWritten(r)
|
|
2193
|
+
expect(styles).toContain(TIMELINE_COLORS.cursor)
|
|
2194
|
+
expect(styles).toContain(TIMELINE_COLORS.snapGuide)
|
|
2195
|
+
expect(styles).not.toContain(LIGHT_TIMELINE_COLORS.cursor)
|
|
2196
|
+
expect(styles).not.toContain(LIGHT_TIMELINE_COLORS.marqueeFill)
|
|
2197
|
+
})
|
|
2198
|
+
})
|