@bycrux/editor 0.12.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +161 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,2187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas timeline painter (SP5 T4) — pure draw functions and the scene
|
|
3
|
+
* composition that culls to the visible time range.
|
|
4
|
+
*
|
|
5
|
+
* Every export here is pure: no React, no DOM reads, no measuring. Each draw
|
|
6
|
+
* function takes a 2D-context-shaped object and explicit layout arguments, so
|
|
7
|
+
* tests drive them with a recording stub and assert on the call list. The
|
|
8
|
+
* component (`TimelineCanvas`) owns the surface, the schedule and the state;
|
|
9
|
+
* this module owns nothing but ink.
|
|
10
|
+
*
|
|
11
|
+
* The palette mirrors the DOM rows' Tailwind classes (VisualTrackRow's
|
|
12
|
+
* six-colour track cycle, AudioTrackRow's emerald bars) resolved to literal
|
|
13
|
+
* colours, because a canvas can't read a class. Keep them in sync by hand: the
|
|
14
|
+
* canvas is meant to look like the timeline users already know, not like a new
|
|
15
|
+
* design.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AudioTrack, CaptionSegment, VisualItem } from '../../../schema'
|
|
19
|
+
import type { Project } from '../../../types'
|
|
20
|
+
import { groupCaptionLanes } from '../../captionLanes'
|
|
21
|
+
import {
|
|
22
|
+
AUDIO_LANE_HEIGHT_PX,
|
|
23
|
+
BASE_VISUAL_ROW_RENDER_HEIGHT_PX,
|
|
24
|
+
CAPTION_ROW_HEIGHT_PX,
|
|
25
|
+
visualItemLabel,
|
|
26
|
+
ROW_GAP_PX,
|
|
27
|
+
VISUAL_ROW_RENDER_HEIGHT_PX,
|
|
28
|
+
computeDerivedTiming,
|
|
29
|
+
groupAudioLanes,
|
|
30
|
+
normalizeTracks,
|
|
31
|
+
trackItems,
|
|
32
|
+
} from '../timeline-model'
|
|
33
|
+
import { DEFAULT_FADE_CURVE, fadeGain, makeFadeGainAt, type FadeCurve } from './fade-curve'
|
|
34
|
+
import { canKeyframe, isKeyframed } from '../../keyframeOps'
|
|
35
|
+
import { KEYFRAME_DIAMOND_SIZE_PX, KEYFRAME_STRIP_BOTTOM_PAD_PX, keyframeDiamondX, keyframeUnionTimes } from './keyframe-strip'
|
|
36
|
+
import type { SnapStrength } from './snap'
|
|
37
|
+
import { timeToX, visibleRange, type Viewport } from './viewport'
|
|
38
|
+
import {
|
|
39
|
+
LIGHT_WAVEFORM_COLORS,
|
|
40
|
+
WAVEFORM_COLORS,
|
|
41
|
+
drawAudioLaneWaveform,
|
|
42
|
+
drawClipWaveform,
|
|
43
|
+
type WaveformColors,
|
|
44
|
+
type WaveformSceneLookup,
|
|
45
|
+
} from './waveforms'
|
|
46
|
+
import { drawFilmstripTiles, type FilmstripSceneLookup } from './filmstrips'
|
|
47
|
+
|
|
48
|
+
// ── The context surface the painter needs ────────────────────────────────
|
|
49
|
+
// A structural subset of CanvasRenderingContext2D: a real context satisfies it,
|
|
50
|
+
// and a recording stub can too. Keeping it narrow is what makes the draw calls
|
|
51
|
+
// assertable.
|
|
52
|
+
|
|
53
|
+
export interface DrawContext {
|
|
54
|
+
save(): void
|
|
55
|
+
restore(): void
|
|
56
|
+
beginPath(): void
|
|
57
|
+
closePath(): void
|
|
58
|
+
moveTo(x: number, y: number): void
|
|
59
|
+
lineTo(x: number, y: number): void
|
|
60
|
+
/** Unused by any painter today — the fade-envelope curve
|
|
61
|
+
* (`drawFadeEnvelope`) was its one caller until fade shapes replaced the
|
|
62
|
+
* single quadratic ease with a sampled polyline. Kept on the interface
|
|
63
|
+
* since it's part of the real `CanvasRenderingContext2D` shape a live
|
|
64
|
+
* context satisfies for free, and a future curve is one candidate use. */
|
|
65
|
+
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void
|
|
66
|
+
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void
|
|
67
|
+
rect(x: number, y: number, w: number, h: number): void
|
|
68
|
+
clip(): void
|
|
69
|
+
fill(): void
|
|
70
|
+
stroke(): void
|
|
71
|
+
fillRect(x: number, y: number, w: number, h: number): void
|
|
72
|
+
strokeRect(x: number, y: number, w: number, h: number): void
|
|
73
|
+
clearRect(x: number, y: number, w: number, h: number): void
|
|
74
|
+
fillText(text: string, x: number, y: number, maxWidth?: number): void
|
|
75
|
+
createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient
|
|
76
|
+
/** T7 — filmstrip tiles and the hover-scrub thumb are the only content
|
|
77
|
+
* drawn as images rather than shapes/text. */
|
|
78
|
+
drawImage(
|
|
79
|
+
image: CanvasImageSource,
|
|
80
|
+
sx: number, sy: number, sw: number, sh: number,
|
|
81
|
+
dx: number, dy: number, dw: number, dh: number,
|
|
82
|
+
): void
|
|
83
|
+
fillStyle: string | CanvasGradient | CanvasPattern
|
|
84
|
+
strokeStyle: string | CanvasGradient | CanvasPattern
|
|
85
|
+
lineWidth: number
|
|
86
|
+
font: string
|
|
87
|
+
textBaseline: CanvasTextBaseline
|
|
88
|
+
globalAlpha: number
|
|
89
|
+
/** Set only around clip labels, which sit over filmstrip frames; `restore()`
|
|
90
|
+
* clears it so no shape painter inherits a shadow. */
|
|
91
|
+
shadowColor: string
|
|
92
|
+
shadowBlur: number
|
|
93
|
+
/** Dash pattern for subsequent strokes; `[]` is a solid line. Used by exactly
|
|
94
|
+
* one painter (`drawPendingDropBand`), because "dashed" is the one outline
|
|
95
|
+
* on this surface that means "not real yet". It is set INSIDE that painter's
|
|
96
|
+
* own save/restore pair — a dash left on the context leaks into every later
|
|
97
|
+
* stroke on the same layer, which on the overlay would mean a dashed
|
|
98
|
+
* playhead. */
|
|
99
|
+
setLineDash(segments: number[]): void
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Palette ──────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export interface TrackPalette {
|
|
105
|
+
fill: string
|
|
106
|
+
fillSelected: string
|
|
107
|
+
ring: string
|
|
108
|
+
border: string
|
|
109
|
+
text: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** VisualTrackRow's `trackColors`, resolved. Same order, same cycle length —
|
|
113
|
+
* a clip keeps its colour when the flag flips. */
|
|
114
|
+
export const TRACK_PALETTE: TrackPalette[] = [
|
|
115
|
+
{ fill: 'rgba(71,85,105,0.8)', fillSelected: 'rgba(100,116,139,0.9)', ring: 'rgba(203,213,225,0.8)', border: 'rgba(148,163,184,0.5)', text: '#e2e8f0' }, // slate
|
|
116
|
+
{ fill: 'rgba(3,105,161,0.8)', fillSelected: 'rgba(2,132,199,0.9)', ring: 'rgba(125,211,252,0.8)', border: 'rgba(56,189,248,0.5)', text: '#bae6fd' }, // sky
|
|
117
|
+
{ fill: 'rgba(109,40,217,0.8)', fillSelected: 'rgba(124,58,237,0.9)', ring: 'rgba(196,181,253,0.8)', border: 'rgba(167,139,250,0.5)', text: '#ddd6fe' }, // violet
|
|
118
|
+
{ fill: 'rgba(4,120,87,0.8)', fillSelected: 'rgba(5,150,105,0.9)', ring: 'rgba(110,231,183,0.8)', border: 'rgba(52,211,153,0.5)', text: '#a7f3d0' }, // emerald
|
|
119
|
+
{ fill: 'rgba(190,18,60,0.8)', fillSelected: 'rgba(225,29,72,0.9)', ring: 'rgba(253,164,175,0.8)', border: 'rgba(251,113,133,0.5)', text: '#fecdd3' }, // rose
|
|
120
|
+
{ fill: 'rgba(180,83,9,0.6)', fillSelected: 'rgba(217,119,6,0.8)', ring: 'rgba(251,191,36,0.8)', border: 'rgba(245,158,11,0.5)', text: '#fde68a' }, // amber
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
/** `TRACK_PALETTE` for a LIGHT row.
|
|
124
|
+
*
|
|
125
|
+
* Same six hue identities in the same order — slate, sky, violet, emerald,
|
|
126
|
+
* rose, amber — because a clip's colour is how you recognize which track it
|
|
127
|
+
* came from, and that must not change when the host flips theme. What DOES
|
|
128
|
+
* change is which end of each hue's ramp is used: dark rows take a saturated
|
|
129
|
+
* 500/700-level fill with a 100/200-level LABEL on it, light rows take a
|
|
130
|
+
* 200/300-level fill with a 800/900-level label. The border/ring pair follows
|
|
131
|
+
* the same inversion (a 500-level hairline, a 700-level selection ring), so
|
|
132
|
+
* "the ring is brighter than the fill" becomes "the ring is DARKER than the
|
|
133
|
+
* fill" — the same statement about contrast, read on the other ground.
|
|
134
|
+
*
|
|
135
|
+
* Fills stay at high alpha (0.85/0.95 rather than the dark set's 0.8/0.9)
|
|
136
|
+
* because a pale tint at 0.8 over an already-pale row washes out into it; the
|
|
137
|
+
* amber entry keeps its own lower pair (0.7/0.9 against the dark set's
|
|
138
|
+
* 0.6/0.8) so it stays the quietest of the six here too. */
|
|
139
|
+
export const LIGHT_TRACK_PALETTE: TrackPalette[] = [
|
|
140
|
+
{ fill: 'rgba(203,213,225,0.85)', fillSelected: 'rgba(148,163,184,0.95)', ring: 'rgba(51,65,85,0.85)', border: 'rgba(100,116,139,0.6)', text: '#1e293b' }, // slate
|
|
141
|
+
{ fill: 'rgba(186,230,253,0.85)', fillSelected: 'rgba(125,211,252,0.95)', ring: 'rgba(3,105,161,0.85)', border: 'rgba(14,165,233,0.6)', text: '#0c4a6e' }, // sky
|
|
142
|
+
{ fill: 'rgba(221,214,254,0.85)', fillSelected: 'rgba(196,181,253,0.95)', ring: 'rgba(109,40,217,0.85)', border: 'rgba(139,92,246,0.6)', text: '#4c1d95' }, // violet
|
|
143
|
+
{ fill: 'rgba(167,243,208,0.85)', fillSelected: 'rgba(110,231,183,0.95)', ring: 'rgba(4,120,87,0.85)', border: 'rgba(16,185,129,0.6)', text: '#064e3b' }, // emerald
|
|
144
|
+
{ fill: 'rgba(254,205,211,0.85)', fillSelected: 'rgba(253,164,175,0.95)', ring: 'rgba(190,18,60,0.85)', border: 'rgba(244,63,94,0.6)', text: '#881337' }, // rose
|
|
145
|
+
{ fill: 'rgba(253,230,138,0.7)', fillSelected: 'rgba(252,211,77,0.9)', ring: 'rgba(180,83,9,0.85)', border: 'rgba(245,158,11,0.6)', text: '#78350f' }, // amber
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
/** The caption block palette. Unlike `TRACK_PALETTE`, which cycles a hue per
|
|
149
|
+
* track, every caption block shares this ONE palette — captions are a single
|
|
150
|
+
* row, not several, so there is nothing to cycle. Deliberately CYAN, distinct
|
|
151
|
+
* from the overlay tracks' violet and the audio lane's emerald, so a caption
|
|
152
|
+
* reads as its own element type on the timeline rather than blending into an
|
|
153
|
+
* overlay. `fill`/`border` are the unselected state; `fillSelected`/`ring` the
|
|
154
|
+
* selected one; `text` is the label color. (The selected/unselected keyframe
|
|
155
|
+
* diamonds are white / amber, both legible on this cyan.) */
|
|
156
|
+
export const CAPTION_PALETTE: TrackPalette = {
|
|
157
|
+
fill: 'rgba(8,145,178,0.4)',
|
|
158
|
+
fillSelected: 'rgba(6,182,212,0.75)',
|
|
159
|
+
ring: 'rgba(165,243,252,0.85)',
|
|
160
|
+
border: 'rgba(34,211,238,0.5)',
|
|
161
|
+
text: '#cffafe',
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `CAPTION_PALETTE` on a LIGHT row. Still CYAN — the whole point of that
|
|
165
|
+
* choice is that a caption is neither the overlays' violet nor the audio
|
|
166
|
+
* lane's emerald, and that separation has to survive the theme flip — read
|
|
167
|
+
* off the other end of the cyan ramp: a 200-level fill with a 900-level
|
|
168
|
+
* label, a 500-level border and a 700-level selection ring. The unselected
|
|
169
|
+
* fill keeps its deliberately-low alpha relative to the selected one (0.55 vs
|
|
170
|
+
* 0.85, mirroring the dark set's 0.4 vs 0.75), so selecting a caption is
|
|
171
|
+
* still a visible jump in weight rather than only a change of outline. */
|
|
172
|
+
export const LIGHT_CAPTION_PALETTE: TrackPalette = {
|
|
173
|
+
fill: 'rgba(165,243,252,0.55)',
|
|
174
|
+
fillSelected: 'rgba(103,232,249,0.85)',
|
|
175
|
+
ring: 'rgba(14,116,144,0.85)',
|
|
176
|
+
border: 'rgba(6,182,212,0.65)',
|
|
177
|
+
text: '#164e63',
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** TrackGutter's rail-cell accent for the caption row. Same cyan hue as
|
|
181
|
+
* `CAPTION_PALETTE.border`, at a higher alpha — a rail chip reads best brighter
|
|
182
|
+
* than a canvas fill. Exported beside `CAPTION_PALETTE`, the single source for
|
|
183
|
+
* the caption color, instead of letting TrackGutter hardcode its own copy with
|
|
184
|
+
* nothing tying the two together. */
|
|
185
|
+
export const CAPTION_RAIL_ACCENT = 'rgba(34,211,238,0.6)'
|
|
186
|
+
|
|
187
|
+
/** `CAPTION_RAIL_ACCENT` on the LIGHT rail. Same relationship to
|
|
188
|
+
* `LIGHT_CAPTION_PALETTE.border` the dark pair has to its own — a rail chip
|
|
189
|
+
* reads best with MORE weight than the canvas fill beside it — which on a
|
|
190
|
+
* near-white rail means stepping the hue DOWN the ramp (cyan-600) rather than
|
|
191
|
+
* up it, and nudging the alpha up to hold the 2px chip together. */
|
|
192
|
+
export const LIGHT_CAPTION_RAIL_ACCENT = 'rgba(8,145,178,0.75)'
|
|
193
|
+
|
|
194
|
+
export const TIMELINE_COLORS = {
|
|
195
|
+
/** `bg-gray-900` — the dark-mode row background both row kinds use. */
|
|
196
|
+
rowBackground: '#111827',
|
|
197
|
+
/** A hair lighter than `rowBackground`, alternated with it per row so
|
|
198
|
+
* adjacent track lanes read as distinct panels instead of one dark field. */
|
|
199
|
+
rowBackgroundAlt: '#161f30',
|
|
200
|
+
/** Subtle outline around every row band — the visible divider that separates
|
|
201
|
+
* one track lane from the next, rather than relying on the gap alone. */
|
|
202
|
+
rowDivider: 'rgba(148,163,184,0.16)',
|
|
203
|
+
/** `bg-emerald-500/40` + `border-emerald-500/60` on AudioTrackRow's bars. */
|
|
204
|
+
audioFill: 'rgba(16,185,129,0.4)',
|
|
205
|
+
audioBorder: 'rgba(16,185,129,0.6)',
|
|
206
|
+
/** `bg-white/10` — a muted audio bar. */
|
|
207
|
+
audioMutedFill: 'rgba(255,255,255,0.1)',
|
|
208
|
+
audioRing: 'rgba(110,231,183,0.8)',
|
|
209
|
+
audioText: '#a7f3d0',
|
|
210
|
+
/** Fade envelope: the dimmed tint across the WHOLE fade-width band
|
|
211
|
+
* (`drawFadeEnvelope`; both sides of the curve, not a wedge on one side),
|
|
212
|
+
* and the curve's own stroke drawn on top of it. Replaces a flat
|
|
213
|
+
* linear-gradient wash — the curve is what makes the RAMP, and its
|
|
214
|
+
* length, legible, not just "audio is quieter somewhere near here". */
|
|
215
|
+
fadeEnvelopeDim: 'rgba(0,0,0,0.35)',
|
|
216
|
+
fadeEnvelopeLine: 'rgba(255,255,255,0.55)',
|
|
217
|
+
/** The fade-grip triangle at an audio bar's top corner (`drawFadeGrip`).
|
|
218
|
+
* Subtle by default so it doesn't compete with the label; brighter when
|
|
219
|
+
* hovered or the bar is selected — the same "clearer when it matters"
|
|
220
|
+
* language the trim handles use. */
|
|
221
|
+
fadeGripSubtle: 'rgba(255,255,255,0.35)',
|
|
222
|
+
fadeGripActive: 'rgba(255,255,255,0.95)',
|
|
223
|
+
/** The keyframe-strip diamond (`drawKeyframeDiamond`). Amber rather than
|
|
224
|
+
* reusing white (the selection/handle vocabulary) or cyan (the snap
|
|
225
|
+
* guide) or yellow (`cursor`, the preview-axis line) — every other
|
|
226
|
+
* bright hue on this surface already means something else, and a
|
|
227
|
+
* keyframe is its own kind of mark. */
|
|
228
|
+
keyframeDiamondFill: '#fb923c',
|
|
229
|
+
/** Outline so the diamond reads as a shape rather than a blob against a
|
|
230
|
+
* filmstrip frame of any brightness — same trick `LABEL_SHADOW_COLOR`
|
|
231
|
+
* uses for the clip label. */
|
|
232
|
+
keyframeDiamondStroke: 'rgba(0,0,0,0.7)',
|
|
233
|
+
/** The ONE diamond matching the host's `selectedKeyframe`, drawn with this
|
|
234
|
+
* fill instead of `keyframeDiamondFill`. White, not a new hue: it is
|
|
235
|
+
* already the selection vocabulary on this surface (`clipSelectedOutline`,
|
|
236
|
+
* `handleFill`), and reads clearly against the amber every other diamond
|
|
237
|
+
* in the strip keeps. */
|
|
238
|
+
keyframeDiamondSelectedFill: '#ffffff',
|
|
239
|
+
/** Where two items on the same row overlap in time.
|
|
240
|
+
*
|
|
241
|
+
* These were 0.15 amber fill / 0.3 amber border. Two things were wrong with
|
|
242
|
+
* that. It was invisible over filmstrip frames — legible back when a clip
|
|
243
|
+
* was a flat colour block, gone the moment clips filled with picture. And
|
|
244
|
+
* amber was the wrong thing to say: yellow on a timeline reads as a
|
|
245
|
+
* warning, and an overlap is a fact about the edit, not a fault in it. It
|
|
246
|
+
* was also all but the same hue as the preview-axis cursor.
|
|
247
|
+
*
|
|
248
|
+
* Deliberately colourless now. White stripes over a dark under-stroke are
|
|
249
|
+
* the barber-pole "this region is special" pattern, and carry no status.
|
|
250
|
+
* The hatching is what does the work — a wash of any strength competes with
|
|
251
|
+
* the picture underneath, and diagonals do not. */
|
|
252
|
+
// 0.12, not the 0.1 that would read most naturally here: `audioMutedFill` is
|
|
253
|
+
// already exactly `rgba(255,255,255,0.1)`, and two different meanings sharing
|
|
254
|
+
// one literal is a trap for anything reasoning about the paint — a muted bar
|
|
255
|
+
// and an overlap band would have been indistinguishable by colour alone.
|
|
256
|
+
overlapFill: 'rgba(255,255,255,0.12)',
|
|
257
|
+
overlapHatch: 'rgba(255,255,255,0.75)',
|
|
258
|
+
/** Laid under each hatch line, a little wider, so the white has something to
|
|
259
|
+
* read against. White on a bright sky is nearly invisible and white on a
|
|
260
|
+
* dark frame is fine; a dark outline makes one treatment work on both, the
|
|
261
|
+
* same trick the clip labels use to survive an arbitrary frame. */
|
|
262
|
+
overlapHatchShadow: 'rgba(0,0,0,0.45)',
|
|
263
|
+
/** Translucent and thin, unlike the solid amber rules this replaces. White
|
|
264
|
+
* is already the selection vocabulary (the outline and the trim handles),
|
|
265
|
+
* and a hard white rule down each side of a band made an overlap read as a
|
|
266
|
+
* selected clip. Thin enough to bound the span, faint enough not to be
|
|
267
|
+
* mistaken for a border. */
|
|
268
|
+
overlapEdge: 'rgba(255,255,255,0.55)',
|
|
269
|
+
/** `bg-red-500` — the playhead, i.e. where playback/preview actually is. */
|
|
270
|
+
playhead: '#ef4444',
|
|
271
|
+
/** The selected clip's outline. White rather than the track's own hue: it is
|
|
272
|
+
* the one colour that stays obvious over an arbitrary video frame. */
|
|
273
|
+
clipSelectedOutline: '#ffffff',
|
|
274
|
+
/** `bg-yellow-400` — the preview-axis cursor: the line that tracks the
|
|
275
|
+
* pointer while the axis is on. Yellow so it can never be mistaken for the
|
|
276
|
+
* red playback line it moves independently of; it inherits the colour of
|
|
277
|
+
* the DOM hover indicator it replaces (Timeline.tsx's `bg-yellow-400/80`). */
|
|
278
|
+
cursor: '#facc15',
|
|
279
|
+
/** The trim-handle pill on a selected clip's in/out edge. Same white as the
|
|
280
|
+
* selection outline, deliberately: the handles read as thickenings of that
|
|
281
|
+
* border rather than as separate furniture. */
|
|
282
|
+
handleFill: 'rgba(255,255,255,0.92)',
|
|
283
|
+
/** The handle under the pointer. Opaque, so "this is the one you'd grab"
|
|
284
|
+
* survives a bright filmstrip frame behind it. */
|
|
285
|
+
handleFillHovered: '#ffffff',
|
|
286
|
+
/** The grip ticks inside the pill — dark on white, the universal
|
|
287
|
+
* drag-me-sideways glyph. */
|
|
288
|
+
handleGrip: 'rgba(15,23,42,0.55)',
|
|
289
|
+
handleGripHovered: 'rgba(15,23,42,0.9)',
|
|
290
|
+
/** The snap guide: the line marking the boundary a gesture is magnetized to.
|
|
291
|
+
* Cyan because every other line on this surface is spoken for — red is the
|
|
292
|
+
* playhead, yellow the preview axis, emerald the audio bars — and a guide
|
|
293
|
+
* that could be confused with the playhead would be worse than none. */
|
|
294
|
+
snapGuide: '#22d3ee',
|
|
295
|
+
/** A guide for a boundary on some OTHER track. Same hue so it is obviously
|
|
296
|
+
* the same idea, but faint and capless: a cross-track alignment is a hint,
|
|
297
|
+
* not the thing you were aiming at, and at full strength these were
|
|
298
|
+
* indistinguishable from the alignment that actually matters. */
|
|
299
|
+
snapGuideWeak: 'rgba(34,211,238,0.4)',
|
|
300
|
+
/** The time ruler across the top. A shade off `rowBackground` so the strip
|
|
301
|
+
* reads as chrome rather than as one more track you could drop a clip on. */
|
|
302
|
+
rulerBackground: '#0b1220',
|
|
303
|
+
/** Tick marks and their labels. Deliberately low-contrast: the ruler is a
|
|
304
|
+
* reference you consult, not something that should compete with the clips. */
|
|
305
|
+
rulerTick: 'rgba(148,163,184,0.55)',
|
|
306
|
+
rulerTickMinor: 'rgba(148,163,184,0.25)',
|
|
307
|
+
rulerText: 'rgba(148,163,184,0.9)',
|
|
308
|
+
/** The marquee (rubber-band) selection box. Same white as the selection
|
|
309
|
+
* vocabulary, since what it is doing IS selecting. */
|
|
310
|
+
marqueeFill: 'rgba(255,255,255,0.08)',
|
|
311
|
+
marqueeBorder: 'rgba(255,255,255,0.65)',
|
|
312
|
+
/** The ghost band for a file still being imported (`drawPendingDropBand`).
|
|
313
|
+
*
|
|
314
|
+
* Deliberately colourless — slate, the same neutral the row divider and the
|
|
315
|
+
* ruler ticks are drawn in. Every saturated hue on this surface is spoken
|
|
316
|
+
* for and each one is a claim: red is where playback is, cyan is an
|
|
317
|
+
* alignment, amber is a keyframe, emerald is audio, white is selection. A
|
|
318
|
+
* pending import is none of those; it is a placeholder, and a placeholder
|
|
319
|
+
* that borrowed any of those hues would be read as the thing that hue
|
|
320
|
+
* means. What says "not real yet" is the DASHED outline, not the colour.
|
|
321
|
+
*
|
|
322
|
+
* The fill is one step up in alpha from `rowDivider` (0.18 vs 0.16) so the
|
|
323
|
+
* two are never the same literal — a hairline divider and a whole band are
|
|
324
|
+
* different marks and must not be confusable by colour alone. */
|
|
325
|
+
pendingDropFill: 'rgba(148,163,184,0.18)',
|
|
326
|
+
/** Slate-200: brighter than the fill, so the dashes hold together as an
|
|
327
|
+
* outline over whatever row background the band lands on. */
|
|
328
|
+
pendingDropStroke: 'rgba(226,232,240,0.7)',
|
|
329
|
+
/** The filename inside the band. A shade brighter again than the stroke —
|
|
330
|
+
* the band is translucent, so the text is competing with the row (and any
|
|
331
|
+
* clip) showing through it. */
|
|
332
|
+
pendingDropText: 'rgba(241,245,249,0.9)',
|
|
333
|
+
} as const
|
|
334
|
+
|
|
335
|
+
/** The shape both palettes satisfy — `TIMELINE_COLORS`'s own keys, widened
|
|
336
|
+
* from its `as const` string literals to plain `string` so the light set can
|
|
337
|
+
* be typed against it and so no consumer accidentally depends on a specific
|
|
338
|
+
* literal being present at a key. */
|
|
339
|
+
export type TimelineColors = { [K in keyof typeof TIMELINE_COLORS]: string }
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* `TIMELINE_COLORS` for a LIGHT host theme.
|
|
343
|
+
*
|
|
344
|
+
* Every entry is the SEMANTIC of its dark counterpart re-derived against a
|
|
345
|
+
* light row, not the dark number lightened. Two rules do most of the work:
|
|
346
|
+
*
|
|
347
|
+
* 1. Anything whose dark rationale was "white, because it survives an
|
|
348
|
+
* arbitrary video frame / because white is the selection vocabulary here"
|
|
349
|
+
* becomes near-black (`rgba(15,23,42,…)` / `#0f172a`), because on this
|
|
350
|
+
* ground that is the colour that survives and that selection speaks. The
|
|
351
|
+
* ALPHAS are carried over verbatim wherever they were load-bearing — see
|
|
352
|
+
* `overlapFill` below.
|
|
353
|
+
* 2. Anything that was a dark scrim UNDER something white flips to a light
|
|
354
|
+
* scrim under something dark (`overlapHatchShadow`), for the same reason
|
|
355
|
+
* it existed at all: the mark needs a ground of the opposite value to read
|
|
356
|
+
* against, whatever is painted behind it.
|
|
357
|
+
*
|
|
358
|
+
* The exceptions are called out at their own keys.
|
|
359
|
+
*/
|
|
360
|
+
export const LIGHT_TIMELINE_COLORS: TimelineColors = {
|
|
361
|
+
/** Slightly DARKER than the editor surface (`#ffffff`) and than the shell
|
|
362
|
+
* (`#f3f4f6`), so a row reads as a recessed field you drop clips into. The
|
|
363
|
+
* dark pair is raised out of a near-black shell; on a near-white one the
|
|
364
|
+
* only way to say "this is the track area" is to sink it. */
|
|
365
|
+
rowBackground: '#e9ecf1',
|
|
366
|
+
/** Alternated with `rowBackground` per row, a hair DARKER — the mirror of
|
|
367
|
+
* the dark pair's "a hair lighter", and the same size of step (~8/255), so
|
|
368
|
+
* adjacent lanes separate exactly as subtly as they do in dark. */
|
|
369
|
+
rowBackgroundAlt: '#e1e5ec',
|
|
370
|
+
/** Slate-600 rather than dark's slate-400, at a slightly higher alpha: a
|
|
371
|
+
* hairline needs more weight to hold together against a light ground than
|
|
372
|
+
* against a dark one. */
|
|
373
|
+
rowDivider: 'rgba(71,85,105,0.18)',
|
|
374
|
+
/** Emerald stays emerald — the audio lane's identity. Nudged up in alpha
|
|
375
|
+
* (0.45 from 0.4) and the border down the ramp to emerald-600, because a
|
|
376
|
+
* 0.4 wash of a mid-tone hue over a pale row is much closer to the row than
|
|
377
|
+
* the same wash over a near-black one. */
|
|
378
|
+
audioFill: 'rgba(16,185,129,0.45)',
|
|
379
|
+
audioBorder: 'rgba(5,150,105,0.75)',
|
|
380
|
+
/** A muted bar: dark uses white-at-0.1 to wash the emerald toward its
|
|
381
|
+
* background. Here the wash toward the background is a near-black one, at
|
|
382
|
+
* the SAME 0.1 — see `overlapFill`, which depends on this literal staying
|
|
383
|
+
* distinct from its own. */
|
|
384
|
+
audioMutedFill: 'rgba(15,23,42,0.1)',
|
|
385
|
+
/** Dark's ring is emerald-300 — brighter than the fill. Light's is
|
|
386
|
+
* emerald-700 — darker than the fill. Same sentence, other ground. */
|
|
387
|
+
audioRing: 'rgba(4,120,87,0.85)',
|
|
388
|
+
audioText: '#064e3b',
|
|
389
|
+
/** The fade band's tint. Dark DARKENS the band (black at 0.35); light
|
|
390
|
+
* LIGHTENS it, washing the emerald toward the row background — on a light
|
|
391
|
+
* ground "less signal" reads as "closer to empty", and a dark wash there
|
|
392
|
+
* would read as ink rather than as attenuation. The envelope line drawn on
|
|
393
|
+
* top of it is near-black, so the two still separate cleanly. */
|
|
394
|
+
fadeEnvelopeDim: 'rgba(255,255,255,0.5)',
|
|
395
|
+
fadeEnvelopeLine: 'rgba(15,23,42,0.65)',
|
|
396
|
+
/** Same subtle-vs-active pair, inverted: the grip is a dark triangle that
|
|
397
|
+
* goes from quiet to emphatic rather than a white one. */
|
|
398
|
+
fadeGripSubtle: 'rgba(15,23,42,0.4)',
|
|
399
|
+
fadeGripActive: 'rgba(15,23,42,0.9)',
|
|
400
|
+
/** IDENTICAL to dark, deliberately. Amber IS the keyframe mark — no other
|
|
401
|
+
* hue on this surface is free to mean it — and a diamond is painted over
|
|
402
|
+
* the clip's own content (filmstrip frames, waveform band), not over the
|
|
403
|
+
* row background, so it has to survive an arbitrary image in either mode
|
|
404
|
+
* rather than being tuned to the light row it happens to sit near. */
|
|
405
|
+
keyframeDiamondFill: '#fb923c',
|
|
406
|
+
/** The halo that makes the diamond read as a SHAPE rather than a blob. Dark
|
|
407
|
+
* lays a black one under an amber diamond sitting on dark content; light
|
|
408
|
+
* lays a white one, since the band under it here is pale and a black rim
|
|
409
|
+
* would merge with the dark bars of the waveform it sits in. */
|
|
410
|
+
keyframeDiamondStroke: 'rgba(255,255,255,0.85)',
|
|
411
|
+
/** The one selected diamond. Dark fills it white because white is already
|
|
412
|
+
* this surface's selection vocabulary; light fills it near-black for
|
|
413
|
+
* exactly the same reason — see `clipSelectedOutline` and `handleFill`,
|
|
414
|
+
* which moved the same way. White would also now collide with the halo. */
|
|
415
|
+
keyframeDiamondSelectedFill: '#0f172a',
|
|
416
|
+
// 0.12, not 0.1, for the SAME reason the dark set says 0.12: `audioMutedFill`
|
|
417
|
+
// above is exactly `rgba(15,23,42,0.1)`, and letting a muted bar and an
|
|
418
|
+
// overlap band share one literal would make them indistinguishable by colour
|
|
419
|
+
// to anything reasoning about the paint. The "no two meanings share one
|
|
420
|
+
// literal" property is preserved key-for-key across both modes.
|
|
421
|
+
overlapFill: 'rgba(15,23,42,0.12)',
|
|
422
|
+
overlapHatch: 'rgba(15,23,42,0.7)',
|
|
423
|
+
/** Laid under each hatch line, a little wider. Dark puts BLACK under white
|
|
424
|
+
* stripes; light puts WHITE under near-black ones — the under-stroke's
|
|
425
|
+
* whole job is to be the opposite value of the stripe, so that one hatch
|
|
426
|
+
* treatment survives both a bright and a dark filmstrip frame. */
|
|
427
|
+
overlapHatchShadow: 'rgba(255,255,255,0.6)',
|
|
428
|
+
overlapEdge: 'rgba(15,23,42,0.5)',
|
|
429
|
+
/** IDENTICAL to dark. Red-500 is unmistakable on either ground and there is
|
|
430
|
+
* no second thing on this surface it could be confused with. */
|
|
431
|
+
playhead: '#ef4444',
|
|
432
|
+
/** Dark's white outline is "the one colour that stays obvious over an
|
|
433
|
+
* arbitrary video frame"; on a light editor that colour is near-black. */
|
|
434
|
+
clipSelectedOutline: '#0f172a',
|
|
435
|
+
/** The preview-axis cursor. The ONE "keep it vivid" colour that had to move:
|
|
436
|
+
* yellow-400 against a near-white row is roughly 1.1:1 and simply is not
|
|
437
|
+
* there. Stepped to yellow-700 — still unmistakably the gold line, still
|
|
438
|
+
* impossible to confuse with the red playhead or the cyan snap guide, and
|
|
439
|
+
* actually visible (~4.7:1). */
|
|
440
|
+
cursor: '#a16207',
|
|
441
|
+
/** The trim pill, and the pill under the pointer. Same "the handles are
|
|
442
|
+
* thickenings of the selection border" rule, so these track
|
|
443
|
+
* `clipSelectedOutline` into near-black. */
|
|
444
|
+
handleFill: 'rgba(15,23,42,0.9)',
|
|
445
|
+
handleFillHovered: '#0f172a',
|
|
446
|
+
/** The grip ticks INSIDE the pill. Dark-on-white in dark mode; the pill is
|
|
447
|
+
* now dark, so the ticks are light-on-dark. The glyph is unchanged — it is
|
|
448
|
+
* the contrast against the pill that carries it, not the colour. */
|
|
449
|
+
handleGrip: 'rgba(248,250,252,0.6)',
|
|
450
|
+
handleGripHovered: 'rgba(248,250,252,0.95)',
|
|
451
|
+
/** Still cyan — every other line on this surface is spoken for in both modes
|
|
452
|
+
* — but stepped from cyan-400 to cyan-600, which is the shallowest step
|
|
453
|
+
* that clears "a 2px line you can actually see" on a light row. */
|
|
454
|
+
snapGuide: '#0891b2',
|
|
455
|
+
snapGuideWeak: 'rgba(8,145,178,0.45)',
|
|
456
|
+
/** A shade off `rowBackground` so the ruler reads as chrome rather than as a
|
|
457
|
+
* droppable track — the same relationship dark has, inverted: dark's ruler
|
|
458
|
+
* sinks BELOW its rows, light's lifts ABOVE them toward the surface. */
|
|
459
|
+
rulerBackground: '#f5f7fa',
|
|
460
|
+
/** Deliberately low-contrast, as in dark: slate-600/700 at the same alphas
|
|
461
|
+
* rather than the slate-400 that reads as low-contrast on a dark ground. */
|
|
462
|
+
rulerTick: 'rgba(71,85,105,0.55)',
|
|
463
|
+
rulerTickMinor: 'rgba(71,85,105,0.3)',
|
|
464
|
+
rulerText: 'rgba(51,65,85,0.85)',
|
|
465
|
+
/** The marquee. Selection vocabulary again, so it follows the outline and
|
|
466
|
+
* the handles into near-black — at the dark set's own alphas, which were
|
|
467
|
+
* already tuned to "a wash you can see through and a border you can't miss". */
|
|
468
|
+
marqueeFill: 'rgba(15,23,42,0.08)',
|
|
469
|
+
marqueeBorder: 'rgba(15,23,42,0.6)',
|
|
470
|
+
/** The pending-import ghost. Colourless in dark, colourless here — but the
|
|
471
|
+
* neutral has to come from the OTHER end of the slate ramp: on a pale row a
|
|
472
|
+
* slate-400 wash is barely a change of value at all, so the fill steps down
|
|
473
|
+
* to slate-600 and the outline/text to near-black, exactly the flip the
|
|
474
|
+
* marquee above makes. The alphas are carried over from the dark set, which
|
|
475
|
+
* is where the "a wash you can see through, an outline you can't miss"
|
|
476
|
+
* balance was tuned; only the hue's end of the ramp moves. */
|
|
477
|
+
pendingDropFill: 'rgba(71,85,105,0.18)',
|
|
478
|
+
pendingDropStroke: 'rgba(51,65,85,0.7)',
|
|
479
|
+
pendingDropText: 'rgba(15,23,42,0.9)',
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Height of the time ruler strip at the top of the surface.
|
|
483
|
+
*
|
|
484
|
+
* The ruler exists because canvas mode removed the overview scrubber bar (see
|
|
485
|
+
* Scrubber.tsx), which left pressing the track area as the only way to seek —
|
|
486
|
+
* and that gesture is now the marquee. Giving scrubbing its own strip is what
|
|
487
|
+
* every NLE does, and it is what makes drag-to-select in the track area
|
|
488
|
+
* possible without losing drag-to-scrub. */
|
|
489
|
+
export const RULER_HEIGHT_PX = 18
|
|
490
|
+
/** Tick height for a labelled (major) tick and an unlabelled (minor) one. */
|
|
491
|
+
export const RULER_MAJOR_TICK_PX = 7
|
|
492
|
+
export const RULER_MINOR_TICK_PX = 4
|
|
493
|
+
/** Baseline for a ruler label, measured from the top of the strip. */
|
|
494
|
+
export const RULER_LABEL_BASELINE_PX = 9
|
|
495
|
+
/** Minimum px between labelled ticks. Below this the labels collide, so the
|
|
496
|
+
* step is promoted to the next entry in `RULER_STEPS_SECONDS`. */
|
|
497
|
+
export const RULER_MIN_LABEL_SPACING_PX = 64
|
|
498
|
+
/** The step ladder, in seconds. A ruler that picked a raw "nice" number could
|
|
499
|
+
* land on 2.5s or 7s; these are the intervals an editor actually thinks in,
|
|
500
|
+
* so the labels stay readable as times rather than as arithmetic. */
|
|
501
|
+
export const RULER_STEPS_SECONDS = [
|
|
502
|
+
0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30,
|
|
503
|
+
60, 120, 300, 600, 900, 1800, 3600,
|
|
504
|
+
] as const
|
|
505
|
+
|
|
506
|
+
export const ROW_RADIUS_PX = 4 // Tailwind `rounded`
|
|
507
|
+
/** Horizontal inset per side between a clip's time span and its drawn body —
|
|
508
|
+
* two touching clips therefore show twice this as a dark gutter. */
|
|
509
|
+
export const CLIP_GUTTER_PX = 2
|
|
510
|
+
/** Corner radius on a clip body. */
|
|
511
|
+
export const CLIP_RADIUS_PX = 4
|
|
512
|
+
/** Weight of the selected-clip outline. Thick on purpose: it has to read over
|
|
513
|
+
* filmstrip frames of any brightness. */
|
|
514
|
+
export const CLIP_SELECTED_BORDER_PX = 3
|
|
515
|
+
/** Drawn width of a visual clip's trim handle. Equal to
|
|
516
|
+
* `VISUAL_EDGE_TOLERANCE_PX` in hit-test.ts ON PURPOSE — the pill you see is
|
|
517
|
+
* exactly the strip that trims, so aiming at it is never a guess. Change one
|
|
518
|
+
* and change the other. */
|
|
519
|
+
export const CLIP_HANDLE_WIDTH_PX = 10
|
|
520
|
+
/** Same contract against `AUDIO_EDGE_TOLERANCE_PX`. */
|
|
521
|
+
export const AUDIO_HANDLE_WIDTH_PX = 6
|
|
522
|
+
/** Grip ticks per handle, their width, and the gap between them. */
|
|
523
|
+
export const HANDLE_GRIP_COUNT = 2
|
|
524
|
+
export const HANDLE_GRIP_WIDTH_PX = 1.5
|
|
525
|
+
export const HANDLE_GRIP_GAP_PX = 2.5
|
|
526
|
+
/** Fraction of the handle's height the ticks span, and the cap that keeps them
|
|
527
|
+
* from becoming a full-height stripe on the tall base video row. */
|
|
528
|
+
export const HANDLE_GRIP_HEIGHT_RATIO = 0.4
|
|
529
|
+
export const HANDLE_GRIP_MAX_HEIGHT_PX = 16
|
|
530
|
+
/** Below this the pill is thinner than its own grip and draws as a smear, so a
|
|
531
|
+
* very narrow clip gets no handles — the white selection outline is still
|
|
532
|
+
* there to say it's selected. */
|
|
533
|
+
export const MIN_HANDLE_WIDTH_PX = 3
|
|
534
|
+
export const SNAP_GUIDE_WIDTH_PX = 2
|
|
535
|
+
/** The weak guide is a hairline. Half the width and no caps — visible if you
|
|
536
|
+
* look for it, invisible if you aren't. */
|
|
537
|
+
export const SNAP_GUIDE_WEAK_WIDTH_PX = 1
|
|
538
|
+
/** The arrowheads at the guide's ends. They are what make it read as "this
|
|
539
|
+
* boundary", not as another playhead. */
|
|
540
|
+
export const SNAP_GUIDE_CAP_HALF_WIDTH_PX = 5
|
|
541
|
+
export const SNAP_GUIDE_CAP_HEIGHT_PX = 7
|
|
542
|
+
/** Gap between the diagonal hatch lines in an overlap band, their weight, and
|
|
543
|
+
* the weight of the solid rule down each side of it. */
|
|
544
|
+
export const OVERLAP_HATCH_SPACING_PX = 9
|
|
545
|
+
export const OVERLAP_HATCH_WIDTH_PX = 1.5
|
|
546
|
+
/** How much wider the shadow pass is than the amber it sits under. */
|
|
547
|
+
export const OVERLAP_HATCH_SHADOW_WIDTH_PX = 3
|
|
548
|
+
export const OVERLAP_EDGE_WIDTH_PX = 1.5
|
|
549
|
+
export const AUDIO_ITEM_RADIUS_PX = 4 // Tailwind `rounded` on the bar
|
|
550
|
+
export const AUDIO_ITEM_INSET_PX = 4 // `top-1 bottom-1` on the bar
|
|
551
|
+
/** Fade-grip triangle size (px), base and height alike. Kept small
|
|
552
|
+
* deliberately — see hit-test.ts's `FADE_GRIP_HALF_WIDTH_PX`/
|
|
553
|
+
* `FADE_GRIP_ZONE_HEIGHT_PX` for why the grip's CLICKABLE zone is a
|
|
554
|
+
* matching small top-corner target rather than the full bar height. */
|
|
555
|
+
export const FADE_GRIP_SIZE_PX = 6
|
|
556
|
+
export const PLAYHEAD_WIDTH_PX = 2 // `w-[2px]`
|
|
557
|
+
export const CURSOR_WIDTH_PX = 2 // matches the playhead's weight
|
|
558
|
+
export const LABEL_FONT = '10px ui-sans-serif, system-ui, sans-serif'
|
|
559
|
+
export const LABEL_PAD_PX = 6
|
|
560
|
+
/** Below this width a label is more smear than information, so it is skipped. */
|
|
561
|
+
export const MIN_LABEL_WIDTH_PX = 28
|
|
562
|
+
/** Middle-baseline offset from a clip's top edge — half the font's 10px plus a
|
|
563
|
+
* 4px margin, so the label rides just inside the clip's top border. */
|
|
564
|
+
export const LABEL_TOP_OFFSET_PX = 9
|
|
565
|
+
/** Drop shadow behind a clip label, so it stays readable over a filmstrip
|
|
566
|
+
* frame of any brightness without needing a solid plate behind it. */
|
|
567
|
+
export const LABEL_SHADOW_COLOR = 'rgba(0,0,0,0.85)'
|
|
568
|
+
/** `LABEL_SHADOW_COLOR` for a light theme. The shadow exists to give the label
|
|
569
|
+
* a ground of the OPPOSITE value to its own text, so it reads over a
|
|
570
|
+
* filmstrip frame of any brightness. A light theme's clip labels are dark
|
|
571
|
+
* (see `LIGHT_TRACK_PALETTE`'s `text`), so a black halo behind them would do
|
|
572
|
+
* nothing at all — the halo has to be white. */
|
|
573
|
+
export const LIGHT_LABEL_SHADOW_COLOR = 'rgba(255,255,255,0.9)'
|
|
574
|
+
export const LABEL_SHADOW_BLUR_PX = 3
|
|
575
|
+
|
|
576
|
+
// ── Mode resolution ──────────────────────────────────────────────────────
|
|
577
|
+
|
|
578
|
+
/** Which ground the surface is being painted on. Named for the HOST theme's
|
|
579
|
+
* light/dark classification (`isLightTheme` in theme.ts), not for a Montaj
|
|
580
|
+
* theme name, because that is the only thing this module needs to know. */
|
|
581
|
+
export type TimelineMode = 'light' | 'dark'
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Everything a single draw pass needs to know about colour, resolved once.
|
|
585
|
+
*
|
|
586
|
+
* One object rather than a mode flag threaded down: the painters must never
|
|
587
|
+
* each re-derive "which mode am I?", or a future palette that resolves from
|
|
588
|
+
* something richer than a two-valued enum would have to be plumbed to every
|
|
589
|
+
* one of them again. They take the resolved set and read it.
|
|
590
|
+
*
|
|
591
|
+
* `waveform` rides along here too, even though `WAVEFORM_COLORS` lives in
|
|
592
|
+
* waveforms.ts, so the clip/audio waveform painters are handed the SAME
|
|
593
|
+
* resolution the rest of the pass is using instead of doing a second,
|
|
594
|
+
* independent mode lookup that could silently disagree with it.
|
|
595
|
+
*/
|
|
596
|
+
export interface TimelinePalette {
|
|
597
|
+
mode: TimelineMode
|
|
598
|
+
colors: TimelineColors
|
|
599
|
+
/** Cycled per track index — see `TRACK_PALETTE`. */
|
|
600
|
+
tracks: TrackPalette[]
|
|
601
|
+
/** The single caption-block palette — see `CAPTION_PALETTE`. */
|
|
602
|
+
caption: TrackPalette
|
|
603
|
+
/** TrackGutter's caption rail chip — see `CAPTION_RAIL_ACCENT`. */
|
|
604
|
+
captionRailAccent: string
|
|
605
|
+
waveform: WaveformColors
|
|
606
|
+
/** Drop shadow behind a clip label — see `LABEL_SHADOW_COLOR`. */
|
|
607
|
+
labelShadow: string
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The dark set, assembled from the module constants that have always held it.
|
|
611
|
+
* Every painter defaults to THIS when handed no palette, which is what keeps
|
|
612
|
+
* a caller (or a test) that predates modes drawing byte-identical pixels. */
|
|
613
|
+
export const DARK_TIMELINE_PALETTE: TimelinePalette = {
|
|
614
|
+
mode: 'dark',
|
|
615
|
+
colors: TIMELINE_COLORS,
|
|
616
|
+
tracks: TRACK_PALETTE,
|
|
617
|
+
caption: CAPTION_PALETTE,
|
|
618
|
+
captionRailAccent: CAPTION_RAIL_ACCENT,
|
|
619
|
+
waveform: WAVEFORM_COLORS,
|
|
620
|
+
labelShadow: LABEL_SHADOW_COLOR,
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export const LIGHT_TIMELINE_PALETTE: TimelinePalette = {
|
|
624
|
+
mode: 'light',
|
|
625
|
+
colors: LIGHT_TIMELINE_COLORS,
|
|
626
|
+
tracks: LIGHT_TRACK_PALETTE,
|
|
627
|
+
caption: LIGHT_CAPTION_PALETTE,
|
|
628
|
+
captionRailAccent: LIGHT_CAPTION_RAIL_ACCENT,
|
|
629
|
+
waveform: LIGHT_WAVEFORM_COLORS,
|
|
630
|
+
labelShadow: LIGHT_LABEL_SHADOW_COLOR,
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** The palette for a mode. Returns one of two module-level objects rather than
|
|
634
|
+
* building one per call: this is read once per draw pass and once per
|
|
635
|
+
* TrackGutter render, and a fresh object each time would churn identity for
|
|
636
|
+
* anything that memoizes on it. */
|
|
637
|
+
export function timelinePalette(mode: TimelineMode): TimelinePalette {
|
|
638
|
+
return mode === 'light' ? LIGHT_TIMELINE_PALETTE : DARK_TIMELINE_PALETTE
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ── Row layout ───────────────────────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
export interface VisualRowLayout {
|
|
644
|
+
trackIdx: number
|
|
645
|
+
items: VisualItem[]
|
|
646
|
+
y: number
|
|
647
|
+
height: number
|
|
648
|
+
/** The track's `enabled === false` — the row still lays out and still takes
|
|
649
|
+
* its full height (you have to be able to see and re-enable it); the painter
|
|
650
|
+
* just dims it. */
|
|
651
|
+
disabled?: boolean
|
|
652
|
+
/** The track's `muted === true`. Item-level mute is folded in per item at
|
|
653
|
+
* draw time (mirrors `effectiveItemAudio`'s OR) since this flag alone can't
|
|
654
|
+
* see an individual item's own `muted`. */
|
|
655
|
+
trackMuted: boolean
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export interface AudioLaneLayout {
|
|
659
|
+
laneIndex: number
|
|
660
|
+
tracks: AudioTrack[]
|
|
661
|
+
y: number
|
|
662
|
+
height: number
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export interface CaptionRowLayout {
|
|
666
|
+
/** Which caption lane this band renders (see `CaptionSegment.lane` /
|
|
667
|
+
* `captionLanes.ts`). Lane 0 is the band adjacent to the base video row;
|
|
668
|
+
* higher lanes stack upward toward the overlays. */
|
|
669
|
+
lane: number
|
|
670
|
+
y: number
|
|
671
|
+
height: number
|
|
672
|
+
segments: CaptionSegment[]
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export interface TimelineLayout {
|
|
676
|
+
/** The time ruler strip across the top. Always present and always at y=0;
|
|
677
|
+
* carried in the layout rather than assumed so hit-testing and painting
|
|
678
|
+
* read the same rectangle, the same contract the rows have. */
|
|
679
|
+
ruler: { y: number; height: number }
|
|
680
|
+
/** Visual rows in DRAW order (top of the surface first). */
|
|
681
|
+
rows: VisualRowLayout[]
|
|
682
|
+
lanes: AudioLaneLayout[]
|
|
683
|
+
/** The caption bands, one per lane (see `captionLanes.ts`'s
|
|
684
|
+
* `groupCaptionLanes`), carried the same way `ruler` is — a first-class set
|
|
685
|
+
* of rectangles the painter and hit-test both read, rather than each
|
|
686
|
+
* re-deriving them. In DRAW order (descending lane), so index 0 is the
|
|
687
|
+
* HIGHEST lane and the last entry is lane 0, immediately above the base
|
|
688
|
+
* video row (see `computeTimelineLayout`). Absent — not an empty array —
|
|
689
|
+
* when the project has no caption segments, so a caption-less project's
|
|
690
|
+
* layout is unchanged from before captions existed. */
|
|
691
|
+
captions?: CaptionRowLayout[]
|
|
692
|
+
/** Total surface height in CSS px, excluding trailing gap. */
|
|
693
|
+
height: number
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Row rectangles for a project, in the order the DOM path stacks them:
|
|
698
|
+
* visual tracks reversed (highest index on top, so the base video track sits at
|
|
699
|
+
* the bottom and overlays stack above it), then audio lanes below, ascending.
|
|
700
|
+
* The base track is drawn taller (120px vs. 40px for the rest).
|
|
701
|
+
*
|
|
702
|
+
* T5's hit-testing should derive its rows from here rather than re-deriving
|
|
703
|
+
* geometry — one layout, two readers.
|
|
704
|
+
*/
|
|
705
|
+
export function computeTimelineLayout(project: Project): TimelineLayout {
|
|
706
|
+
// Order-normalized (Part B): `trackItems` funnels through `normalizeTracks`,
|
|
707
|
+
// which now groups tracks into the canonical video-block/overlay-block
|
|
708
|
+
// stack (`normalizeTrackOrder`) before returning — so `allTracks` here is
|
|
709
|
+
// ALREADY in that order, and `trackSettings` below (also read off
|
|
710
|
+
// `normalizeTracks(project)`) agrees with it index-for-index by
|
|
711
|
+
// construction. Display, hit-test (which derives its rows from this same
|
|
712
|
+
// layout), and mutation (`moveItemAcrossTracks` re-groups its own result
|
|
713
|
+
// the same way) therefore can never disagree about what trackIdx N is.
|
|
714
|
+
const allTracks = trackItems(project)
|
|
715
|
+
// Settings live on the track object; `trackItems` deliberately returns only
|
|
716
|
+
// items, so read `enabled` off the normalized tracks alongside it. A
|
|
717
|
+
// legacy-shaped project has no settings, so every row is enabled.
|
|
718
|
+
const trackSettings = normalizeTracks(project).tracks ?? []
|
|
719
|
+
const rows: VisualRowLayout[] = []
|
|
720
|
+
// The ruler owns the top strip; everything else starts below it.
|
|
721
|
+
const ruler = { y: 0, height: RULER_HEIGHT_PX }
|
|
722
|
+
let y = RULER_HEIGHT_PX + ROW_GAP_PX
|
|
723
|
+
|
|
724
|
+
// Only stored when there is something to show — an empty `segments` (or no
|
|
725
|
+
// `project.captions` at all) means no bands at all, not an empty array, so a
|
|
726
|
+
// caption-less project's layout is byte-for-byte what it was before
|
|
727
|
+
// captions joined the canvas.
|
|
728
|
+
const captionSegments = project.captions?.segments ?? []
|
|
729
|
+
// One group per lane, `0..maxCaptionLane`, INCLUDING holes as empty groups
|
|
730
|
+
// (see `groupCaptionLanes`) — a hole still needs a band, because an empty
|
|
731
|
+
// band that keeps painting its row background is what stops the timeline
|
|
732
|
+
// jumping by a whole row height mid-drag when a caption that was alone in
|
|
733
|
+
// its lane moves out of it.
|
|
734
|
+
const captionGroups = captionSegments.length > 0 ? groupCaptionLanes(captionSegments) : []
|
|
735
|
+
const captions: CaptionRowLayout[] = []
|
|
736
|
+
const emitCaptionBands = () => {
|
|
737
|
+
// Descending lane order: the highest lane is emitted FIRST, so it lands
|
|
738
|
+
// closest to the overlay rows already pushed above (smaller y); lane 0 is
|
|
739
|
+
// emitted LAST, landing at the current `y` — exactly where the single
|
|
740
|
+
// band sat before multi-lane captions existed. Higher lanes therefore
|
|
741
|
+
// stack upward toward the overlays, per the caption-lane convention
|
|
742
|
+
// documented in `captionLanes.ts`.
|
|
743
|
+
for (let i = captionGroups.length - 1; i >= 0; i--) {
|
|
744
|
+
const group = captionGroups[i]
|
|
745
|
+
captions.push({ lane: group.lane, y, height: CAPTION_ROW_HEIGHT_PX, segments: group.segments })
|
|
746
|
+
y += CAPTION_ROW_HEIGHT_PX + ROW_GAP_PX
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Highest trackIdx that holds a video item. Because `allTracks` is already
|
|
751
|
+
// order-normalized (video block first, contiguous from index 0), this is
|
|
752
|
+
// the TOP of that block — where the caption band(s) belong: directly above
|
|
753
|
+
// the video block, below any overlay tracks. `-1` when there are no video
|
|
754
|
+
// tracks at all, in which case captions fall back to sitting above
|
|
755
|
+
// trackIdx 0 — today's behaviour, unchanged, whatever kind that track is.
|
|
756
|
+
// For a project with exactly one video track, `topVideoIdx` is always 0
|
|
757
|
+
// (the base track), so `captionEmitIdx` is 0 either way and this whole
|
|
758
|
+
// change is a no-op for the single-video-track case.
|
|
759
|
+
let topVideoIdx = -1
|
|
760
|
+
for (let i = 0; i < allTracks.length; i++) {
|
|
761
|
+
if (allTracks[i].some(item => item.type === 'video')) topVideoIdx = i
|
|
762
|
+
}
|
|
763
|
+
const captionEmitIdx = topVideoIdx >= 0 ? topVideoIdx : 0
|
|
764
|
+
|
|
765
|
+
for (let reversedIdx = 0; reversedIdx < allTracks.length; reversedIdx++) {
|
|
766
|
+
const trackIdx = allTracks.length - 1 - reversedIdx
|
|
767
|
+
// The caption bands sit directly above the TOP of the video block —
|
|
768
|
+
// captions narrate that footage, so they read as adjacent to it, above
|
|
769
|
+
// whatever overlay tracks sit above the video block and below the video
|
|
770
|
+
// block itself. `trackIdx === captionEmitIdx` fires exactly once per
|
|
771
|
+
// layout, immediately before that row is pushed (rows are pushed in
|
|
772
|
+
// descending trackIdx order, i.e. top of screen first).
|
|
773
|
+
if (trackIdx === captionEmitIdx && captionGroups.length > 0) emitCaptionBands()
|
|
774
|
+
// Tall not just for the base track (trackIdx 0, always tall even when
|
|
775
|
+
// empty, so a blank base row doesn't jump size once footage lands) but
|
|
776
|
+
// for ANY video-kind track — a second video track carries the same
|
|
777
|
+
// filmstrip+waveform content as the base and is cramped without the
|
|
778
|
+
// room. Overlay/image tracks keep the short row.
|
|
779
|
+
const height = (trackIdx === 0 || allTracks[trackIdx][0]?.type === 'video')
|
|
780
|
+
? BASE_VISUAL_ROW_RENDER_HEIGHT_PX
|
|
781
|
+
: VISUAL_ROW_RENDER_HEIGHT_PX
|
|
782
|
+
rows.push({
|
|
783
|
+
trackIdx,
|
|
784
|
+
items: allTracks[trackIdx],
|
|
785
|
+
y,
|
|
786
|
+
height,
|
|
787
|
+
disabled: trackSettings[trackIdx]?.enabled === false,
|
|
788
|
+
trackMuted: trackSettings[trackIdx]?.muted === true,
|
|
789
|
+
})
|
|
790
|
+
y += height + ROW_GAP_PX
|
|
791
|
+
}
|
|
792
|
+
// A project with captions but NO visual tracks at all never runs the loop
|
|
793
|
+
// above, so the hook that emits the bands right before `trackIdx ===
|
|
794
|
+
// captionEmitIdx` never fires. Emit them here instead — still above where
|
|
795
|
+
// the (absent) base row would sit, which is the same rule applied to zero
|
|
796
|
+
// rows.
|
|
797
|
+
if (allTracks.length === 0 && captionGroups.length > 0) emitCaptionBands()
|
|
798
|
+
|
|
799
|
+
// Resolved once per layout, not per track: an audio track may legally omit
|
|
800
|
+
// `start`/`end`, and `groupAudioLanes` needs a horizon to fall back to.
|
|
801
|
+
const { contentDuration } = computeDerivedTiming(project)
|
|
802
|
+
const lanes: AudioLaneLayout[] = groupAudioLanes(project.audio?.tracks ?? [], contentDuration).map(lane => {
|
|
803
|
+
const laneLayout: AudioLaneLayout = {
|
|
804
|
+
laneIndex: lane.laneIndex,
|
|
805
|
+
tracks: lane.tracks,
|
|
806
|
+
y,
|
|
807
|
+
height: AUDIO_LANE_HEIGHT_PX,
|
|
808
|
+
}
|
|
809
|
+
y += AUDIO_LANE_HEIGHT_PX + ROW_GAP_PX
|
|
810
|
+
return laneLayout
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
const layout: TimelineLayout = { ruler, rows, lanes, height: Math.max(0, y - ROW_GAP_PX) }
|
|
814
|
+
if (captions.length > 0) layout.captions = captions
|
|
815
|
+
return layout
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ── Primitives ───────────────────────────────────────────────────────────
|
|
819
|
+
|
|
820
|
+
/** Rounded-rect path. Hand-rolled rather than `ctx.roundRect` so the painter
|
|
821
|
+
* works on contexts (and stubs) that predate it. */
|
|
822
|
+
export function roundRectPath(ctx: DrawContext, x: number, y: number, w: number, h: number, r: number): void {
|
|
823
|
+
const radius = Math.max(0, Math.min(r, w / 2, h / 2))
|
|
824
|
+
ctx.beginPath()
|
|
825
|
+
ctx.moveTo(x + radius, y)
|
|
826
|
+
ctx.arcTo(x + w, y, x + w, y + h, radius)
|
|
827
|
+
ctx.arcTo(x + w, y + h, x, y + h, radius)
|
|
828
|
+
ctx.arcTo(x, y + h, x, y, radius)
|
|
829
|
+
ctx.arcTo(x, y, x + w, y, radius)
|
|
830
|
+
ctx.closePath()
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export interface Rect { x: number; y: number; width: number; height: number }
|
|
834
|
+
|
|
835
|
+
/** Clamp a rect to the surface so a clip stretching far off-screen doesn't ask
|
|
836
|
+
* the rasterizer to fill a million-pixel-wide box. */
|
|
837
|
+
export function clampRectToSurface(rect: Rect, surfaceWidth: number): Rect {
|
|
838
|
+
const left = Math.max(rect.x, -1)
|
|
839
|
+
const right = Math.min(rect.x + rect.width, surfaceWidth + 1)
|
|
840
|
+
return { ...rect, x: left, width: Math.max(0, right - left) }
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ── Element painters ─────────────────────────────────────────────────────
|
|
844
|
+
|
|
845
|
+
/** `color` stays the THIRD parameter (callers and tests pass the alternating
|
|
846
|
+
* row shade there positionally); the palette is appended after it, and an
|
|
847
|
+
* omitted `color` falls back to that palette's own `rowBackground` rather
|
|
848
|
+
* than to the dark literal. */
|
|
849
|
+
export function drawRowBackground(
|
|
850
|
+
ctx: DrawContext,
|
|
851
|
+
rect: Rect,
|
|
852
|
+
color?: string,
|
|
853
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
854
|
+
): void {
|
|
855
|
+
ctx.fillStyle = color ?? palette.colors.rowBackground
|
|
856
|
+
roundRectPath(ctx, rect.x, rect.y, rect.width, rect.height, ROW_RADIUS_PX)
|
|
857
|
+
ctx.fill()
|
|
858
|
+
// A faint outline so each lane reads as its own panel and the boundary
|
|
859
|
+
// between adjacent tracks is a visible divider, not just the gap. Uses
|
|
860
|
+
// strokeRect rather than a stroked roundRectPath so it adds no path
|
|
861
|
+
// (`moveTo`) calls — the row corners are only 4px, so a square outline reads
|
|
862
|
+
// as clean at this radius.
|
|
863
|
+
ctx.strokeStyle = palette.colors.rowDivider
|
|
864
|
+
ctx.lineWidth = 1
|
|
865
|
+
ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.width - 1), Math.max(0, rect.height - 1))
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export interface ClipDrawArgs {
|
|
869
|
+
rect: Rect
|
|
870
|
+
palette: TrackPalette
|
|
871
|
+
selected: boolean
|
|
872
|
+
label: string
|
|
873
|
+
/** A skipped track's clips are faded — the DOM path's `opacity-30`. */
|
|
874
|
+
dimmed?: boolean
|
|
875
|
+
/** Content layer drawn between the fill/border and the label — the T6
|
|
876
|
+
* per-clip waveform hooks in here (filmstrip, T7, will too). Receives the
|
|
877
|
+
* same rect as the clip itself. */
|
|
878
|
+
drawContent?: (ctx: DrawContext, rect: Rect) => void
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* One clip/overlay block. Mirrors the DOM item: flat coloured box, a right
|
|
883
|
+
* border separating touching clips, an inset ring when selected, and a small
|
|
884
|
+
* label. Content layers (waveform T6, filmstrip T7) draw between the fill and
|
|
885
|
+
* the label — pass through here once they exist.
|
|
886
|
+
*/
|
|
887
|
+
/**
|
|
888
|
+
* The drawn body of a clip: inset from its true time span, so two clips that
|
|
889
|
+
* touch on the timeline are separated by a real gutter of row background
|
|
890
|
+
* rather than a 1px line. That line was legible while a clip was a flat
|
|
891
|
+
* colour; against a continuous ribbon of filmstrip frames it vanished, and
|
|
892
|
+
* twelve clips read as one.
|
|
893
|
+
*
|
|
894
|
+
* Hit-testing still uses the full span — the gutter is paint, not a dead zone.
|
|
895
|
+
* Exported because the content lookups (`clipColumns`, `clipTiles`) must size
|
|
896
|
+
* themselves to the SAME rect the painter will draw them into, or a filmstrip
|
|
897
|
+
* lays out its cells against a width two pixels wider than the clip it lands
|
|
898
|
+
* in.
|
|
899
|
+
*/
|
|
900
|
+
export function clipBodyRect(rect: Rect): Rect {
|
|
901
|
+
const gutter = Math.min(CLIP_GUTTER_PX, Math.max(0, (rect.width - 1) / 2))
|
|
902
|
+
return {
|
|
903
|
+
x: rect.x + gutter,
|
|
904
|
+
y: rect.y,
|
|
905
|
+
width: Math.max(0, rect.width - gutter * 2),
|
|
906
|
+
height: rect.height,
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
export interface TrimHandleDrawArgs {
|
|
911
|
+
/** The BODY rect of the clip or bar — the rectangle actually painted, not
|
|
912
|
+
* the full time span. A handle drawn against the span would hang two pixels
|
|
913
|
+
* out over the gutter and touch its neighbour. */
|
|
914
|
+
rect: Rect
|
|
915
|
+
edge: 'in' | 'out'
|
|
916
|
+
/** Nominal width; narrowed automatically so two handles can never overlap on
|
|
917
|
+
* a short clip. */
|
|
918
|
+
width: number
|
|
919
|
+
hovered?: boolean
|
|
920
|
+
radius?: number
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* A trim handle: the grabbable pill on one end of a selected clip.
|
|
925
|
+
*
|
|
926
|
+
* This exists because canvas mode inherited the DOM path's *invisible* resize
|
|
927
|
+
* strips — a 10px zone with nothing drawn in it. That was survivable when a
|
|
928
|
+
* clip was a flat colour block with a visible 1px border; once clips filled
|
|
929
|
+
* with filmstrip frames there was no way to see where trimming started, and
|
|
930
|
+
* the only feedback was the cursor changing after you were already there.
|
|
931
|
+
*
|
|
932
|
+
* The drawn width is `VISUAL_EDGE_TOLERANCE_PX` (or the audio one), so the
|
|
933
|
+
* pill is a picture of the hit zone rather than a decoration near it.
|
|
934
|
+
*
|
|
935
|
+
* Handles are drawn for SELECTED items only. Trimming an unselected clip still
|
|
936
|
+
* works — hit-testing never consulted the selection and still doesn't — but
|
|
937
|
+
* showing a pair of pills on every clip on the timeline would bury the one
|
|
938
|
+
* thing selection is for.
|
|
939
|
+
*/
|
|
940
|
+
export function drawTrimHandle(
|
|
941
|
+
ctx: DrawContext,
|
|
942
|
+
args: TrimHandleDrawArgs,
|
|
943
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
944
|
+
): void {
|
|
945
|
+
const { rect, edge, hovered = false } = args
|
|
946
|
+
if (rect.width <= 0 || rect.height <= 0) return
|
|
947
|
+
|
|
948
|
+
// Two handles must never meet in the middle: on a clip narrower than twice
|
|
949
|
+
// the nominal width they each take half and stop.
|
|
950
|
+
const width = Math.min(args.width, rect.width / 2)
|
|
951
|
+
if (width < MIN_HANDLE_WIDTH_PX) return
|
|
952
|
+
|
|
953
|
+
const x = edge === 'in' ? rect.x : rect.x + rect.width - width
|
|
954
|
+
const radius = Math.min(args.radius ?? CLIP_RADIUS_PX, width / 2, rect.height / 2)
|
|
955
|
+
|
|
956
|
+
ctx.save()
|
|
957
|
+
ctx.fillStyle = hovered ? palette.colors.handleFillHovered : palette.colors.handleFill
|
|
958
|
+
roundRectPath(ctx, x, rect.y, width, rect.height, radius)
|
|
959
|
+
ctx.fill()
|
|
960
|
+
|
|
961
|
+
// Grip ticks, centred in the pill. Skipped when the pill has narrowed to the
|
|
962
|
+
// point where they'd fill it edge to edge and read as one solid block.
|
|
963
|
+
const gripSpan = HANDLE_GRIP_COUNT * HANDLE_GRIP_WIDTH_PX + (HANDLE_GRIP_COUNT - 1) * HANDLE_GRIP_GAP_PX
|
|
964
|
+
if (gripSpan <= width - 2) {
|
|
965
|
+
const gripHeight = Math.min(rect.height * HANDLE_GRIP_HEIGHT_RATIO, HANDLE_GRIP_MAX_HEIGHT_PX)
|
|
966
|
+
const gripY = rect.y + (rect.height - gripHeight) / 2
|
|
967
|
+
let gripX = x + (width - gripSpan) / 2
|
|
968
|
+
ctx.fillStyle = hovered ? palette.colors.handleGripHovered : palette.colors.handleGrip
|
|
969
|
+
for (let i = 0; i < HANDLE_GRIP_COUNT; i++) {
|
|
970
|
+
ctx.fillRect(gripX, gripY, HANDLE_GRIP_WIDTH_PX, gripHeight)
|
|
971
|
+
gripX += HANDLE_GRIP_WIDTH_PX + HANDLE_GRIP_GAP_PX
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
ctx.restore()
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Both trim handles for one selected item.
|
|
979
|
+
*
|
|
980
|
+
* Split out of the item painters so the row painter can run it as a LAST pass,
|
|
981
|
+
* after every clip in the row is down. Handles are an affordance, not content:
|
|
982
|
+
* where two clips overlap, the one on top owns the picture, but the selected
|
|
983
|
+
* clip still owns its own edges and has to be able to show them. Painting them
|
|
984
|
+
* in stacking order meant a clip whose end was overlapped had a handle you
|
|
985
|
+
* could neither see nor — before the hit-test learned about this — grab.
|
|
986
|
+
*/
|
|
987
|
+
export function drawItemHandles(
|
|
988
|
+
ctx: DrawContext,
|
|
989
|
+
rect: Rect,
|
|
990
|
+
width: number,
|
|
991
|
+
hoveredEdge?: 'in' | 'out' | null,
|
|
992
|
+
radius?: number,
|
|
993
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
994
|
+
): void {
|
|
995
|
+
for (const edge of ['in', 'out'] as const) {
|
|
996
|
+
drawTrimHandle(ctx, { rect, edge, width, hovered: hoveredEdge === edge, radius }, palette)
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** `args.palette` is the TRACK's own hue (already resolved by the caller from
|
|
1001
|
+
* the mode-appropriate cycle); the trailing `palette` is the surface-wide set
|
|
1002
|
+
* the mode-independent furniture — the selection outline, the label's halo —
|
|
1003
|
+
* comes from. */
|
|
1004
|
+
export function drawClipRect(
|
|
1005
|
+
ctx: DrawContext,
|
|
1006
|
+
args: ClipDrawArgs,
|
|
1007
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1008
|
+
): void {
|
|
1009
|
+
const { rect, palette: trackPalette, selected, label, dimmed, drawContent } = args
|
|
1010
|
+
if (rect.width <= 0) return
|
|
1011
|
+
|
|
1012
|
+
const body = clipBodyRect(rect)
|
|
1013
|
+
if (body.width <= 0) return
|
|
1014
|
+
|
|
1015
|
+
const radius = Math.min(CLIP_RADIUS_PX, body.width / 2, body.height / 2)
|
|
1016
|
+
|
|
1017
|
+
ctx.save()
|
|
1018
|
+
if (dimmed) ctx.globalAlpha = 0.3
|
|
1019
|
+
|
|
1020
|
+
// Fill and content clipped to the rounded body, so filmstrip tiles and
|
|
1021
|
+
// waveform bars stop at the corners instead of squaring them off.
|
|
1022
|
+
ctx.save()
|
|
1023
|
+
roundRectPath(ctx, body.x, body.y, body.width, body.height, radius)
|
|
1024
|
+
ctx.clip()
|
|
1025
|
+
ctx.fillStyle = selected ? trackPalette.fillSelected : trackPalette.fill
|
|
1026
|
+
ctx.fillRect(body.x, body.y, body.width, body.height)
|
|
1027
|
+
drawContent?.(ctx, body)
|
|
1028
|
+
ctx.restore()
|
|
1029
|
+
|
|
1030
|
+
// The outline is the selection. A 1px inset ring in the track's own hue was
|
|
1031
|
+
// invisible once frames filled the clip; selection now reads as a thick
|
|
1032
|
+
// white border round the whole block, the one treatment that survives any
|
|
1033
|
+
// frame underneath it.
|
|
1034
|
+
if (selected) {
|
|
1035
|
+
const inset = CLIP_SELECTED_BORDER_PX / 2
|
|
1036
|
+
ctx.strokeStyle = palette.colors.clipSelectedOutline
|
|
1037
|
+
ctx.lineWidth = CLIP_SELECTED_BORDER_PX
|
|
1038
|
+
roundRectPath(ctx, body.x + inset, body.y + inset, Math.max(0, body.width - CLIP_SELECTED_BORDER_PX), Math.max(0, body.height - CLIP_SELECTED_BORDER_PX), radius)
|
|
1039
|
+
ctx.stroke()
|
|
1040
|
+
} else {
|
|
1041
|
+
ctx.strokeStyle = trackPalette.border
|
|
1042
|
+
ctx.lineWidth = 1
|
|
1043
|
+
roundRectPath(ctx, body.x + 0.5, body.y + 0.5, Math.max(0, body.width - 1), Math.max(0, body.height - 1), radius)
|
|
1044
|
+
ctx.stroke()
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// The handles themselves are NOT drawn here — the row painter puts them on
|
|
1048
|
+
// in a pass of its own once every clip is down (see `drawTimelineContent`),
|
|
1049
|
+
// because a clip that overlaps this one would otherwise bury them. Their
|
|
1050
|
+
// width still matters here: the label has to indent past where they will
|
|
1051
|
+
// land, or a pill drops onto the first glyph of a short overlay name.
|
|
1052
|
+
const handleWidth = selected ? Math.min(CLIP_HANDLE_WIDTH_PX, body.width / 2) : 0
|
|
1053
|
+
|
|
1054
|
+
// An empty label draws nothing at all — video clips carry none now that the
|
|
1055
|
+
// track rail names the row.
|
|
1056
|
+
if (label !== '' && body.width >= MIN_LABEL_WIDTH_PX) {
|
|
1057
|
+
ctx.save()
|
|
1058
|
+
ctx.beginPath()
|
|
1059
|
+
ctx.rect(body.x, body.y, body.width, body.height)
|
|
1060
|
+
ctx.clip()
|
|
1061
|
+
ctx.fillStyle = trackPalette.text
|
|
1062
|
+
ctx.font = LABEL_FONT
|
|
1063
|
+
ctx.textBaseline = 'middle'
|
|
1064
|
+
// Pinned to the TOP, not the vertical centre it used to sit at: a video
|
|
1065
|
+
// clip is now split into a frames band and a waveform band, and the centre
|
|
1066
|
+
// is the seam between them — the worst line on the clip for legibility.
|
|
1067
|
+
// The shadow is what lets it read over an arbitrary frame underneath;
|
|
1068
|
+
// `restore()` below drops it before anything else paints.
|
|
1069
|
+
ctx.shadowColor = palette.labelShadow
|
|
1070
|
+
ctx.shadowBlur = LABEL_SHADOW_BLUR_PX
|
|
1071
|
+
ctx.fillText(label, body.x + LABEL_PAD_PX + handleWidth, body.y + LABEL_TOP_OFFSET_PX)
|
|
1072
|
+
ctx.restore()
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
ctx.restore()
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Segments the envelope polyline samples `fadeGain` at. 24 is smooth enough
|
|
1079
|
+
* that `exp`/`log`'s curvature reads clearly at any bar width the fade grip
|
|
1080
|
+
* can produce, without asking the rasterizer to stroke hundreds of tiny
|
|
1081
|
+
* segments on every repaint. Exported so tests can assert on the curve's
|
|
1082
|
+
* `lineTo` call count directly. */
|
|
1083
|
+
export const FADE_ENVELOPE_SEGMENTS = 24
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Vegas-style fade envelope: the WHOLE fade-width region — `x = spanX` to
|
|
1087
|
+
* `spanX + fadeInPx` for a fade-in, `spanX + spanWidth - fadeOutPx` to
|
|
1088
|
+
* `spanX + spanWidth` for a fade-out, measured off the clip's TRUE span (see
|
|
1089
|
+
* the note inside) so the fade tracks the clip under horizontal scroll — is
|
|
1090
|
+
* tinted FULL HEIGHT, top to bottom, as one band. The gain curve is then
|
|
1091
|
+
* stroked brightly on top of it, dividing the band into what's above and
|
|
1092
|
+
* below the curve — but BOTH halves stay tinted; this is not a wedge on one
|
|
1093
|
+
* side. The band is what says "this section is fading, full stop"; the curve
|
|
1094
|
+
* on top is what says exactly how much, at exactly which point. Replaces a
|
|
1095
|
+
* flat linear-gradient wash, which said "audio is quieter somewhere here"
|
|
1096
|
+
* but not how much quieter, where, or for how long — and replaced an earlier
|
|
1097
|
+
* one-sided wedge fill (shading only the region UNDER the curve) that read as
|
|
1098
|
+
* "this is the remaining signal" rather than "this is the fading section".
|
|
1099
|
+
*
|
|
1100
|
+
* The curve is drawn as a sampled polyline (`FADE_ENVELOPE_SEGMENTS` steps of
|
|
1101
|
+
* `fadeGain`), not a single curve primitive, because the shape is now a
|
|
1102
|
+
* per-fade CHOICE (`curve`) rather than one fixed ease — `fadeGain` is the
|
|
1103
|
+
* one source of truth for what each shape looks like, shared with the
|
|
1104
|
+
* waveform's own amplitude scaling (`waveforms.ts`) and the rendered mix
|
|
1105
|
+
* (`mix-audio.js`), and a polyline is the only primitive that can trace an
|
|
1106
|
+
* arbitrary one of them.
|
|
1107
|
+
*/
|
|
1108
|
+
function drawFadeEnvelope(
|
|
1109
|
+
ctx: DrawContext,
|
|
1110
|
+
rect: Rect,
|
|
1111
|
+
edge: 'in' | 'out',
|
|
1112
|
+
widthPx: number,
|
|
1113
|
+
spanX: number,
|
|
1114
|
+
spanWidth: number,
|
|
1115
|
+
curve: FadeCurve,
|
|
1116
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1117
|
+
): void {
|
|
1118
|
+
// `spanX`/`spanWidth` are the clip's TRUE horizontal span (its unclamped
|
|
1119
|
+
// body edges), NOT `rect` — the lane painter clamps `rect` to the visible
|
|
1120
|
+
// surface, and anchoring the curve there pinned the fade to a fixed screen
|
|
1121
|
+
// x so it stayed put while the clip scrolled under it (the same class of bug
|
|
1122
|
+
// the waveform once had). The envelope is drawn inside the bar's clip
|
|
1123
|
+
// region, so a span edge scrolled off-screen is harmless — it's clipped.
|
|
1124
|
+
const w = Math.min(Math.max(0, widthPx), spanWidth)
|
|
1125
|
+
if (w <= 0) return
|
|
1126
|
+
const top = rect.y
|
|
1127
|
+
const bottom = rect.y + rect.height
|
|
1128
|
+
const silentX = edge === 'in' ? spanX : spanX + spanWidth
|
|
1129
|
+
const fullX = edge === 'in' ? spanX + w : spanX + spanWidth - w
|
|
1130
|
+
|
|
1131
|
+
// Full-height tint across the whole fade-width band, both sides of the
|
|
1132
|
+
// curve — a plain rect, not a curve-bounded wedge, so `bandX` is just
|
|
1133
|
+
// whichever of the two x's is smaller regardless of edge direction.
|
|
1134
|
+
ctx.fillStyle = palette.colors.fadeEnvelopeDim
|
|
1135
|
+
ctx.fillRect(Math.min(silentX, fullX), top, w, bottom - top)
|
|
1136
|
+
|
|
1137
|
+
// `p` runs 0 (silentX, bottom — silent) → 1 (fullX, top — full volume), the
|
|
1138
|
+
// same convention `fadeGain` itself uses, so the first sample is always
|
|
1139
|
+
// exactly the silent corner and the last exactly the full-volume one.
|
|
1140
|
+
ctx.beginPath()
|
|
1141
|
+
for (let i = 0; i <= FADE_ENVELOPE_SEGMENTS; i++) {
|
|
1142
|
+
const p = i / FADE_ENVELOPE_SEGMENTS
|
|
1143
|
+
const x = silentX + (fullX - silentX) * p
|
|
1144
|
+
const y = bottom - fadeGain(p, curve) * (bottom - top)
|
|
1145
|
+
if (i === 0) ctx.moveTo(x, y)
|
|
1146
|
+
else ctx.lineTo(x, y)
|
|
1147
|
+
}
|
|
1148
|
+
ctx.strokeStyle = palette.colors.fadeEnvelopeLine
|
|
1149
|
+
ctx.lineWidth = 1.5
|
|
1150
|
+
ctx.stroke()
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* The small fade-grip triangle at one top corner of an audio bar — sitting
|
|
1155
|
+
* at the fade's inner edge when a fade is already set (drag it further in
|
|
1156
|
+
* or back out to the corner to adjust or remove the fade), or at the bar's
|
|
1157
|
+
* own corner when there is none yet (drag inward from there to create one).
|
|
1158
|
+
*
|
|
1159
|
+
* Drawn on EVERY bar, unlike the trim handles (`drawTrimHandle`), which are
|
|
1160
|
+
* selected-only: a fade grip has to be discoverable without selecting the
|
|
1161
|
+
* bar first, or it may as well not exist. `active` (hover, or the bar being
|
|
1162
|
+
* selected) brightens it — the same "clearer when it matters" language the
|
|
1163
|
+
* trim handles use, just without gating existence on selection too.
|
|
1164
|
+
*/
|
|
1165
|
+
function drawFadeGrip(
|
|
1166
|
+
ctx: DrawContext,
|
|
1167
|
+
x: number,
|
|
1168
|
+
top: number,
|
|
1169
|
+
active: boolean,
|
|
1170
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1171
|
+
): void {
|
|
1172
|
+
const half = FADE_GRIP_SIZE_PX / 2
|
|
1173
|
+
ctx.beginPath()
|
|
1174
|
+
ctx.moveTo(x - half, top)
|
|
1175
|
+
ctx.lineTo(x + half, top)
|
|
1176
|
+
ctx.lineTo(x, top + FADE_GRIP_SIZE_PX)
|
|
1177
|
+
ctx.closePath()
|
|
1178
|
+
ctx.fillStyle = active ? palette.colors.fadeGripActive : palette.colors.fadeGripSubtle
|
|
1179
|
+
ctx.fill()
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* One keyframe-strip diamond, centred at `(x, y)`.
|
|
1184
|
+
*
|
|
1185
|
+
* Unlike `drawFadeGrip` (drawn on every audio bar, selected or not — see its
|
|
1186
|
+
* own doc for why), a diamond is drawn ONLY for the selected, keyframed
|
|
1187
|
+
* overlay item: `drawTimelineContent`'s loop gates the whole strip on that,
|
|
1188
|
+
* not this function. A keyframe is per-property editing state, not a
|
|
1189
|
+
* persistent property of the clip the way a fade is, so showing it on every
|
|
1190
|
+
* overlay would be noise rather than an affordance (plan decision 1) — and
|
|
1191
|
+
* because the gate already limits diamonds to a selected item, there is no
|
|
1192
|
+
* separate "active/subtle" distinction to draw here the way the fade grip
|
|
1193
|
+
* needs for discoverability.
|
|
1194
|
+
*
|
|
1195
|
+
* `selected` is a DIFFERENT axis from that gate: which one diamond, among
|
|
1196
|
+
* however many the strip draws, is the host's `selectedKeyframe`. It swaps
|
|
1197
|
+
* the fill to `keyframeDiamondSelectedFill` and thickens the stroke, the
|
|
1198
|
+
* same "outline thickens" language `drawItemHandles` uses for a selected
|
|
1199
|
+
* clip's border.
|
|
1200
|
+
*/
|
|
1201
|
+
function drawKeyframeDiamond(
|
|
1202
|
+
ctx: DrawContext,
|
|
1203
|
+
x: number,
|
|
1204
|
+
y: number,
|
|
1205
|
+
selected = false,
|
|
1206
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1207
|
+
): void {
|
|
1208
|
+
const half = KEYFRAME_DIAMOND_SIZE_PX / 2
|
|
1209
|
+
ctx.beginPath()
|
|
1210
|
+
ctx.moveTo(x, y - half)
|
|
1211
|
+
ctx.lineTo(x + half, y)
|
|
1212
|
+
ctx.lineTo(x, y + half)
|
|
1213
|
+
ctx.lineTo(x - half, y)
|
|
1214
|
+
ctx.closePath()
|
|
1215
|
+
ctx.fillStyle = selected ? palette.colors.keyframeDiamondSelectedFill : palette.colors.keyframeDiamondFill
|
|
1216
|
+
ctx.fill()
|
|
1217
|
+
ctx.strokeStyle = palette.colors.keyframeDiamondStroke
|
|
1218
|
+
ctx.lineWidth = selected ? 2 : 1
|
|
1219
|
+
ctx.stroke()
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* The keyframe strip for one selected, keyframed overlay item: one diamond
|
|
1224
|
+
* per DISTINCT keyframe time across ALL of its tracks (`keyframeUnionTimes` —
|
|
1225
|
+
* plan decision 2's "union of times", not one row per property), positioned
|
|
1226
|
+
* along the BOTTOM of the clip's drawn body.
|
|
1227
|
+
*
|
|
1228
|
+
* Each diamond's x comes from `keyframeDiamondX`, which converts through the
|
|
1229
|
+
* SAME `timeToX` every other draw call on this surface uses — so unlike the
|
|
1230
|
+
* fade envelope (which has to be handed the clip's true, unclamped span to
|
|
1231
|
+
* survive horizontal scroll, see `drawFadeEnvelope`'s own note) a diamond
|
|
1232
|
+
* needs no such plumbing: its position is already absolute screen space.
|
|
1233
|
+
* What it DOES need is the clip to `body` (decision 3: never draw outside
|
|
1234
|
+
* the clip), which is why the whole strip is wrapped in one clip region
|
|
1235
|
+
* rather than each diamond clamping itself.
|
|
1236
|
+
*
|
|
1237
|
+
* The clip region is widened past `body`'s own edges, NOT set to `body`
|
|
1238
|
+
* verbatim. `body` is already inset from the clip's TRUE time span by
|
|
1239
|
+
* `CLIP_GUTTER_PX` (`clipBodyRect`), but a diamond's x is NOT computed from
|
|
1240
|
+
* `body` — `keyframeDiamondX` runs `item.start + t` through the same
|
|
1241
|
+
* `timeToX` the row loop used to get the clip's true, un-inset `rect` in the
|
|
1242
|
+
* first place. So an ENDPOINT diamond (t=0 or t=duration) is centred exactly
|
|
1243
|
+
* at `rect.x` / `rect.x + rect.width` — which is `CLIP_GUTTER_PX` further out
|
|
1244
|
+
* than `body`'s corresponding edge — and widening the clip by only half a
|
|
1245
|
+
* diamond from `body` (as it might look natural to do) still falls
|
|
1246
|
+
* `CLIP_GUTTER_PX` short of that centre, leaving a slimmer but still-real
|
|
1247
|
+
* sliver. The margin below is `half a diamond` **+ `CLIP_GUTTER_PX`**, which
|
|
1248
|
+
* puts the widened region's edge exactly back at `rect`'s true edge before
|
|
1249
|
+
* adding the half-diamond room a full diamond needs — so the endpoint diamond
|
|
1250
|
+
* survives WHOLE, not just mostly. That is not a corner case: `enableKeyframing`
|
|
1251
|
+
* seeds a keyframe at t=0 when the playhead sits at the item's own start, and
|
|
1252
|
+
* `applyKeyframeMove` lists 0 and duration as STRONG snap targets, so drags
|
|
1253
|
+
* are actively steered onto them. The extra margin never exposes anything
|
|
1254
|
+
* outside the clip's true span — a diamond's `t` is already clamped to
|
|
1255
|
+
* `[0, duration]` by every writer in `keyframeOps.ts` and, symmetrically, by
|
|
1256
|
+
* `hit-test.ts`'s `keyframeStripZone` filter — so "decision 3: never draw
|
|
1257
|
+
* outside the clip" still holds; only the definition of "the clip" grew back
|
|
1258
|
+
* out to `rect` (plus the diamond's own half-width) to match what decision 3
|
|
1259
|
+
* was actually protecting.
|
|
1260
|
+
*
|
|
1261
|
+
* `selectedT`, when given, is compared against each diamond's own `t` — not
|
|
1262
|
+
* carried as a separate "which index" — so the ONE diamond it names (if any
|
|
1263
|
+
* is currently on the strip at all) is the one `drawKeyframeDiamond` paints
|
|
1264
|
+
* `selected`. Absent or `null` means no diamond on this strip is selected,
|
|
1265
|
+
* the ordinary case for every item except the one holding the host's
|
|
1266
|
+
* `selectedKeyframe`.
|
|
1267
|
+
*/
|
|
1268
|
+
export function drawKeyframeStrip(
|
|
1269
|
+
ctx: DrawContext,
|
|
1270
|
+
item: VisualItem,
|
|
1271
|
+
body: Rect,
|
|
1272
|
+
viewport: Viewport,
|
|
1273
|
+
selectedT?: number | null,
|
|
1274
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1275
|
+
): void {
|
|
1276
|
+
const times = keyframeUnionTimes(item)
|
|
1277
|
+
if (times.length === 0 || body.width <= 0) return
|
|
1278
|
+
|
|
1279
|
+
const y = body.y + body.height - KEYFRAME_DIAMOND_SIZE_PX / 2 - KEYFRAME_STRIP_BOTTOM_PAD_PX
|
|
1280
|
+
const margin = KEYFRAME_DIAMOND_SIZE_PX / 2 + CLIP_GUTTER_PX
|
|
1281
|
+
ctx.save()
|
|
1282
|
+
ctx.beginPath()
|
|
1283
|
+
ctx.rect(body.x - margin, body.y, body.width + margin * 2, body.height)
|
|
1284
|
+
ctx.clip()
|
|
1285
|
+
for (const t of times) {
|
|
1286
|
+
drawKeyframeDiamond(ctx, keyframeDiamondX(item, t, viewport), y, t === selectedT, palette)
|
|
1287
|
+
}
|
|
1288
|
+
ctx.restore()
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
export interface AudioItemDrawArgs {
|
|
1292
|
+
rect: Rect
|
|
1293
|
+
selected: boolean
|
|
1294
|
+
muted: boolean
|
|
1295
|
+
label: string
|
|
1296
|
+
/** Fade widths in px, already converted from seconds. */
|
|
1297
|
+
fadeInPx?: number
|
|
1298
|
+
fadeOutPx?: number
|
|
1299
|
+
/** The clip's TRUE (unclamped) body span in surface x — left edge and
|
|
1300
|
+
* width. The fade envelope and grips anchor to THIS, not `rect`, which the
|
|
1301
|
+
* lane painter clamps to the visible surface; without it a fade stays
|
|
1302
|
+
* pinned to the viewport edge instead of scrolling with its clip. Default
|
|
1303
|
+
* to `rect.x`/`rect.width` when absent (an on-screen, unscrolled clip). */
|
|
1304
|
+
fadeSpanX?: number
|
|
1305
|
+
fadeSpanWidth?: number
|
|
1306
|
+
/** Envelope shape for each fade — see `fade-curve.ts`. Default
|
|
1307
|
+
* `DEFAULT_FADE_CURVE` ('exp'), the shape every fade had before curves
|
|
1308
|
+
* existed, so a bar whose track carries no explicit choice looks
|
|
1309
|
+
* unchanged. */
|
|
1310
|
+
fadeInCurve?: FadeCurve
|
|
1311
|
+
fadeOutCurve?: FadeCurve
|
|
1312
|
+
/** Content layer drawn inside the bar's clip region, beneath the fades and
|
|
1313
|
+
* label — the T6 audio-lane waveform hooks in here (mirrors the DOM
|
|
1314
|
+
* path's `AudioWaveformLayer`, which also paints behind the type label). */
|
|
1315
|
+
drawContent?: (ctx: DrawContext, rect: Rect) => void
|
|
1316
|
+
/** Which fade grip (if any) the pointer is resting on — brightens that one
|
|
1317
|
+
* grip the same way `hoveredEdge` brightens a trim handle. Absent/null
|
|
1318
|
+
* when nothing is hovered, which is also what a host that never wires up
|
|
1319
|
+
* hover detection gets: the grip still renders (just always at its
|
|
1320
|
+
* subtle-or-selected style), so the feature is fully usable without this
|
|
1321
|
+
* field ever being set. */
|
|
1322
|
+
hoveredFadeSide?: 'in' | 'out' | null
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
export function drawAudioItem(
|
|
1326
|
+
ctx: DrawContext,
|
|
1327
|
+
args: AudioItemDrawArgs,
|
|
1328
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1329
|
+
): void {
|
|
1330
|
+
const { rect, selected, muted, label, fadeInPx = 0, fadeOutPx = 0, drawContent, hoveredFadeSide } = args
|
|
1331
|
+
const fadeInCurve = args.fadeInCurve ?? DEFAULT_FADE_CURVE
|
|
1332
|
+
const fadeOutCurve = args.fadeOutCurve ?? DEFAULT_FADE_CURVE
|
|
1333
|
+
// Fades anchor to the clip's true span (scrolls with the clip); fall back to
|
|
1334
|
+
// the drawn rect for callers/tests that don't supply it (on-screen clip).
|
|
1335
|
+
const fadeSpanX = args.fadeSpanX ?? rect.x
|
|
1336
|
+
const fadeSpanWidth = args.fadeSpanWidth ?? rect.width
|
|
1337
|
+
const handleWidth = selected ? Math.min(AUDIO_HANDLE_WIDTH_PX, rect.width / 2) : 0
|
|
1338
|
+
if (rect.width <= 0) return
|
|
1339
|
+
ctx.save()
|
|
1340
|
+
|
|
1341
|
+
ctx.fillStyle = muted ? palette.colors.audioMutedFill : palette.colors.audioFill
|
|
1342
|
+
roundRectPath(ctx, rect.x, rect.y, rect.width, rect.height, AUDIO_ITEM_RADIUS_PX)
|
|
1343
|
+
ctx.fill()
|
|
1344
|
+
if (!muted) {
|
|
1345
|
+
ctx.strokeStyle = palette.colors.audioBorder
|
|
1346
|
+
ctx.lineWidth = 1
|
|
1347
|
+
ctx.stroke()
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// Clip the content/fades/label to the bar so none of them can bleed past
|
|
1351
|
+
// its edges.
|
|
1352
|
+
ctx.save()
|
|
1353
|
+
ctx.beginPath()
|
|
1354
|
+
ctx.rect(rect.x, rect.y, rect.width, rect.height)
|
|
1355
|
+
ctx.clip()
|
|
1356
|
+
|
|
1357
|
+
drawContent?.(ctx, rect)
|
|
1358
|
+
|
|
1359
|
+
if (fadeInPx > 0) drawFadeEnvelope(ctx, rect, 'in', fadeInPx, fadeSpanX, fadeSpanWidth, fadeInCurve, palette)
|
|
1360
|
+
if (fadeOutPx > 0) drawFadeEnvelope(ctx, rect, 'out', fadeOutPx, fadeSpanX, fadeSpanWidth, fadeOutCurve, palette)
|
|
1361
|
+
|
|
1362
|
+
if (rect.width >= MIN_LABEL_WIDTH_PX) {
|
|
1363
|
+
ctx.fillStyle = palette.colors.audioText
|
|
1364
|
+
ctx.font = LABEL_FONT
|
|
1365
|
+
ctx.textBaseline = 'middle'
|
|
1366
|
+
ctx.fillText(label, rect.x + LABEL_PAD_PX + handleWidth, rect.y + rect.height / 2)
|
|
1367
|
+
}
|
|
1368
|
+
ctx.restore()
|
|
1369
|
+
|
|
1370
|
+
if (selected) {
|
|
1371
|
+
ctx.strokeStyle = palette.colors.audioRing
|
|
1372
|
+
ctx.lineWidth = 1
|
|
1373
|
+
roundRectPath(ctx, rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.width - 1), Math.max(0, rect.height - 1), AUDIO_ITEM_RADIUS_PX)
|
|
1374
|
+
ctx.stroke()
|
|
1375
|
+
// Handles come later, in the lane painter's own pass — crossfaded bars
|
|
1376
|
+
// overlap by design, so burying them here would be the normal case rather
|
|
1377
|
+
// than the exception.
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Fade grips: on EVERY bar (not gated on `selected`, unlike the trim
|
|
1381
|
+
// handles above) — see `drawFadeGrip`'s own doc for why. Skipped only
|
|
1382
|
+
// when the bar is too narrow for the two grips to avoid sitting on top of
|
|
1383
|
+
// each other.
|
|
1384
|
+
if (fadeSpanWidth >= FADE_GRIP_SIZE_PX * 2) {
|
|
1385
|
+
const inX = fadeSpanX + Math.min(fadeInPx, fadeSpanWidth)
|
|
1386
|
+
const outX = fadeSpanX + fadeSpanWidth - Math.min(fadeOutPx, fadeSpanWidth)
|
|
1387
|
+
drawFadeGrip(ctx, inX, rect.y, selected || hoveredFadeSide === 'in', palette)
|
|
1388
|
+
drawFadeGrip(ctx, outX, rect.y, selected || hoveredFadeSide === 'out', palette)
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
ctx.restore()
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
export interface CaptionBlockDrawArgs {
|
|
1395
|
+
/** Already inset (see `AUDIO_ITEM_INSET_PX`) — same contract as
|
|
1396
|
+
* `drawAudioItem`'s `rect`, not the full row span. */
|
|
1397
|
+
rect: Rect
|
|
1398
|
+
selected: boolean
|
|
1399
|
+
label: string
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* One caption segment block. Structurally the closer sibling is
|
|
1404
|
+
* `drawAudioItem`, not `drawClipRect`: like an audio bar, a caption has no
|
|
1405
|
+
* content bands to split label position around, so its label rides centred;
|
|
1406
|
+
* like an audio bar, it takes its OWN hue on selection rather than the
|
|
1407
|
+
* global white clip-selection outline (`drawClipRect`'s override would be a
|
|
1408
|
+
* lie here — a caption is never confused with a video clip because nothing
|
|
1409
|
+
* else on this row looks like one).
|
|
1410
|
+
*
|
|
1411
|
+
* Unlike an audio bar, the FILL itself changes on selection too — mirrors
|
|
1412
|
+
* the retired DOM row's `bg-purple-700/40` → `bg-purple-600/70` swap — and
|
|
1413
|
+
* the border/ring pair is mutually exclusive (border unselected, ring
|
|
1414
|
+
* selected) rather than audio's "border always, ring layered on top",
|
|
1415
|
+
* because that is what the DOM classes did:
|
|
1416
|
+
* `border border-purple-500/40` XOR `ring-1 ring-inset ring-purple-300/80`.
|
|
1417
|
+
*/
|
|
1418
|
+
export function drawCaptionBlock(
|
|
1419
|
+
ctx: DrawContext,
|
|
1420
|
+
args: CaptionBlockDrawArgs,
|
|
1421
|
+
timeline: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1422
|
+
): void {
|
|
1423
|
+
const { rect, selected, label } = args
|
|
1424
|
+
if (rect.width <= 0) return
|
|
1425
|
+
|
|
1426
|
+
// The block's own hue; the surrounding `timeline` palette carries nothing
|
|
1427
|
+
// else this painter needs (a caption takes its OWN ring on selection rather
|
|
1428
|
+
// than the global white/near-black clip outline — see the doc above).
|
|
1429
|
+
const palette = timeline.caption
|
|
1430
|
+
const radius = Math.min(AUDIO_ITEM_RADIUS_PX, rect.width / 2, rect.height / 2)
|
|
1431
|
+
const handleWidth = selected ? Math.min(AUDIO_HANDLE_WIDTH_PX, rect.width / 2) : 0
|
|
1432
|
+
|
|
1433
|
+
ctx.save()
|
|
1434
|
+
ctx.fillStyle = selected ? palette.fillSelected : palette.fill
|
|
1435
|
+
roundRectPath(ctx, rect.x, rect.y, rect.width, rect.height, radius)
|
|
1436
|
+
ctx.fill()
|
|
1437
|
+
|
|
1438
|
+
ctx.strokeStyle = selected ? palette.ring : palette.border
|
|
1439
|
+
ctx.lineWidth = 1
|
|
1440
|
+
roundRectPath(ctx, rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.width - 1), Math.max(0, rect.height - 1), radius)
|
|
1441
|
+
ctx.stroke()
|
|
1442
|
+
|
|
1443
|
+
// Centred, clipped to the block — matching the DOM row's `flex items-center
|
|
1444
|
+
// overflow-hidden` rather than `drawClipRect`'s top pin, which exists only
|
|
1445
|
+
// to clear that clip's own frames/waveform split. A caption block has none.
|
|
1446
|
+
if (label !== '' && rect.width >= MIN_LABEL_WIDTH_PX) {
|
|
1447
|
+
ctx.save()
|
|
1448
|
+
ctx.beginPath()
|
|
1449
|
+
ctx.rect(rect.x, rect.y, rect.width, rect.height)
|
|
1450
|
+
ctx.clip()
|
|
1451
|
+
ctx.fillStyle = palette.text
|
|
1452
|
+
ctx.font = LABEL_FONT
|
|
1453
|
+
ctx.textBaseline = 'middle'
|
|
1454
|
+
ctx.fillText(label, rect.x + LABEL_PAD_PX + handleWidth, rect.y + rect.height / 2)
|
|
1455
|
+
ctx.restore()
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
ctx.restore()
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* The span where two items on one row sit on top of each other.
|
|
1463
|
+
*
|
|
1464
|
+
* On an audio lane that is a crossfade and usually deliberate. On a video
|
|
1465
|
+
* track it usually is not: within a track the later clip simply wins, so an
|
|
1466
|
+
* overlap means one clip is invisible for that stretch — worth shouting about
|
|
1467
|
+
* rather than whispering.
|
|
1468
|
+
*
|
|
1469
|
+
* Neutral wash, diagonal hatching, and a thin rule down each side. The hatch
|
|
1470
|
+
* is the load-bearing part: a translucent wash strong enough to see over a
|
|
1471
|
+
* bright filmstrip frame is strong enough to hide the frame, whereas diagonals
|
|
1472
|
+
* stay legible over anything without obscuring what is underneath them. It is
|
|
1473
|
+
* also the convention — every NLE marks a transition region with stripes.
|
|
1474
|
+
*/
|
|
1475
|
+
export function drawOverlapBand(
|
|
1476
|
+
ctx: DrawContext,
|
|
1477
|
+
rect: Rect,
|
|
1478
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1479
|
+
): void {
|
|
1480
|
+
if (rect.width <= 0 || rect.height <= 0) return
|
|
1481
|
+
|
|
1482
|
+
ctx.save()
|
|
1483
|
+
// Clip first: the diagonals run past the band's corners by design, and the
|
|
1484
|
+
// clip is what turns them into a contained patch of hatching.
|
|
1485
|
+
ctx.beginPath()
|
|
1486
|
+
ctx.rect(rect.x, rect.y, rect.width, rect.height)
|
|
1487
|
+
ctx.clip()
|
|
1488
|
+
|
|
1489
|
+
ctx.fillStyle = palette.colors.overlapFill
|
|
1490
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height)
|
|
1491
|
+
|
|
1492
|
+
// Leaning the same way as a fade: bottom-left to top-right. Start far enough
|
|
1493
|
+
// left that the first line still crosses the band's bottom-left corner.
|
|
1494
|
+
ctx.beginPath()
|
|
1495
|
+
for (let offset = 0; offset <= rect.width + rect.height; offset += OVERLAP_HATCH_SPACING_PX) {
|
|
1496
|
+
ctx.moveTo(rect.x + offset, rect.y + rect.height)
|
|
1497
|
+
ctx.lineTo(rect.x + offset - rect.height, rect.y)
|
|
1498
|
+
}
|
|
1499
|
+
// One path, stroked twice: a wide dark pass, then the amber inside it.
|
|
1500
|
+
ctx.strokeStyle = palette.colors.overlapHatchShadow
|
|
1501
|
+
ctx.lineWidth = OVERLAP_HATCH_SHADOW_WIDTH_PX
|
|
1502
|
+
ctx.stroke()
|
|
1503
|
+
ctx.strokeStyle = palette.colors.overlapHatch
|
|
1504
|
+
ctx.lineWidth = OVERLAP_HATCH_WIDTH_PX
|
|
1505
|
+
ctx.stroke()
|
|
1506
|
+
ctx.restore()
|
|
1507
|
+
|
|
1508
|
+
// Edges last and unclipped: they are what says exactly where the overlap
|
|
1509
|
+
// starts and stops, which is the number an editor is actually trying to read
|
|
1510
|
+
// off the screen. Kept thin and translucent so the band never reads as the
|
|
1511
|
+
// white outline that means "selected".
|
|
1512
|
+
const edge = Math.min(OVERLAP_EDGE_WIDTH_PX, rect.width / 2)
|
|
1513
|
+
ctx.fillStyle = palette.colors.overlapEdge
|
|
1514
|
+
ctx.fillRect(rect.x, rect.y, edge, rect.height)
|
|
1515
|
+
ctx.fillRect(rect.x + rect.width - edge, rect.y, edge, rect.height)
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Time spans where consecutive items on one row overlap.
|
|
1520
|
+
*
|
|
1521
|
+
* Consecutive in START order, which is what a crossfade is; a clip buried
|
|
1522
|
+
* under a much longer neighbour two positions along is a different problem and
|
|
1523
|
+
* not one a band can usefully describe. Pure so both row kinds can share it —
|
|
1524
|
+
* the visual rows had no overlap marking at all before this, and duplicating
|
|
1525
|
+
* the arithmetic is how they would drift apart again.
|
|
1526
|
+
*/
|
|
1527
|
+
export function overlapBands(items: readonly { start: number; end: number }[]): Array<{ start: number; end: number }> {
|
|
1528
|
+
const sorted = [...items].sort((a, b) => a.start - b.start)
|
|
1529
|
+
const out: Array<{ start: number; end: number }> = []
|
|
1530
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
1531
|
+
const a = sorted[i]
|
|
1532
|
+
const b = sorted[i + 1]
|
|
1533
|
+
if (a.end <= b.start) continue
|
|
1534
|
+
out.push({ start: b.start, end: Math.min(a.end, b.end) })
|
|
1535
|
+
}
|
|
1536
|
+
return out
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
export function drawPlayhead(
|
|
1540
|
+
ctx: DrawContext,
|
|
1541
|
+
x: number,
|
|
1542
|
+
top: number,
|
|
1543
|
+
bottom: number,
|
|
1544
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1545
|
+
): void {
|
|
1546
|
+
ctx.fillStyle = palette.colors.playhead
|
|
1547
|
+
ctx.fillRect(x - PLAYHEAD_WIDTH_PX / 2, top, PLAYHEAD_WIDTH_PX, bottom - top)
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* The snap guide: the boundary a running gesture is currently magnetized to.
|
|
1552
|
+
*
|
|
1553
|
+
* A line plus an arrowhead at each end, pointing inward. The caps are load-
|
|
1554
|
+
* bearing, not decoration — a bare vertical line on this surface is already
|
|
1555
|
+
* two other things (the red playhead, the yellow preview axis), and the whole
|
|
1556
|
+
* job of this mark is to say "you are held HERE, and here is a real edge".
|
|
1557
|
+
*
|
|
1558
|
+
* Drawn last of everything on the overlay so it survives whatever it lands on.
|
|
1559
|
+
* When a gesture snaps to the playhead the cyan sits directly over the red,
|
|
1560
|
+
* which is exactly the right picture: that IS what happened.
|
|
1561
|
+
*/
|
|
1562
|
+
export function drawSnapGuide(
|
|
1563
|
+
ctx: DrawContext,
|
|
1564
|
+
x: number,
|
|
1565
|
+
top: number,
|
|
1566
|
+
bottom: number,
|
|
1567
|
+
strength: SnapStrength = 'strong',
|
|
1568
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1569
|
+
): void {
|
|
1570
|
+
ctx.save()
|
|
1571
|
+
|
|
1572
|
+
if (strength === 'weak') {
|
|
1573
|
+
ctx.fillStyle = palette.colors.snapGuideWeak
|
|
1574
|
+
ctx.fillRect(x - SNAP_GUIDE_WEAK_WIDTH_PX / 2, top, SNAP_GUIDE_WEAK_WIDTH_PX, bottom - top)
|
|
1575
|
+
ctx.restore()
|
|
1576
|
+
return
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
ctx.fillStyle = palette.colors.snapGuide
|
|
1580
|
+
ctx.fillRect(x - SNAP_GUIDE_WIDTH_PX / 2, top, SNAP_GUIDE_WIDTH_PX, bottom - top)
|
|
1581
|
+
|
|
1582
|
+
const half = SNAP_GUIDE_CAP_HALF_WIDTH_PX
|
|
1583
|
+
const cap = SNAP_GUIDE_CAP_HEIGHT_PX
|
|
1584
|
+
|
|
1585
|
+
ctx.beginPath()
|
|
1586
|
+
ctx.moveTo(x - half, top)
|
|
1587
|
+
ctx.lineTo(x + half, top)
|
|
1588
|
+
ctx.lineTo(x, top + cap)
|
|
1589
|
+
ctx.closePath()
|
|
1590
|
+
ctx.fill()
|
|
1591
|
+
|
|
1592
|
+
ctx.beginPath()
|
|
1593
|
+
ctx.moveTo(x - half, bottom)
|
|
1594
|
+
ctx.lineTo(x + half, bottom)
|
|
1595
|
+
ctx.lineTo(x, bottom - cap)
|
|
1596
|
+
ctx.closePath()
|
|
1597
|
+
ctx.fill()
|
|
1598
|
+
ctx.restore()
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/** The preview-axis cursor. Drawn BEFORE the playhead by `drawTimelineOverlay`
|
|
1602
|
+
* so that where the two coincide the red playback line stays the one you see —
|
|
1603
|
+
* the playhead is the position that survives the pointer leaving. */
|
|
1604
|
+
export function drawCursorLine(
|
|
1605
|
+
ctx: DrawContext,
|
|
1606
|
+
x: number,
|
|
1607
|
+
top: number,
|
|
1608
|
+
bottom: number,
|
|
1609
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1610
|
+
): void {
|
|
1611
|
+
ctx.fillStyle = palette.colors.cursor
|
|
1612
|
+
ctx.fillRect(x - CURSOR_WIDTH_PX / 2, top, CURSOR_WIDTH_PX, bottom - top)
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// ── Scene composition ────────────────────────────────────────────────────
|
|
1616
|
+
|
|
1617
|
+
export interface TimelineScene {
|
|
1618
|
+
project: Project
|
|
1619
|
+
viewport: Viewport
|
|
1620
|
+
layout: TimelineLayout
|
|
1621
|
+
/** Unified selection (visual items + audio tracks), as Timeline holds it. */
|
|
1622
|
+
selectedIds: string[]
|
|
1623
|
+
/** The trim handle the pointer is resting on, if any. Only ever set for a
|
|
1624
|
+
* SELECTED item, since unselected ones draw no handles to highlight. */
|
|
1625
|
+
hoveredHandle?: { itemId: string; edge: 'in' | 'out' } | null
|
|
1626
|
+
/** The selected keyframe diamond, if any (`pointer-machine.ts`'s
|
|
1627
|
+
* `KeyframeSelection`, structurally — this module doesn't import that
|
|
1628
|
+
* type, since `pointer-machine.ts` imports THIS module already, and the
|
|
1629
|
+
* reverse would be circular). `itemId`'s strip, if it draws a diamond at
|
|
1630
|
+
* `t` at all, paints that one `selected`; every other diamond on every
|
|
1631
|
+
* other strip paints ordinary. Mirrors `hoveredHandle`'s own inline shape
|
|
1632
|
+
* for the same reason. */
|
|
1633
|
+
selectedKeyframe?: { itemId: string; t: number } | null
|
|
1634
|
+
surfaceWidth: number
|
|
1635
|
+
surfaceHeight: number
|
|
1636
|
+
/** T6 waveform content-layer provider. Absent → no waveforms drawn, which
|
|
1637
|
+
* is what a host without a `getWaveformPeaks` adapter method gets. */
|
|
1638
|
+
waveforms?: WaveformSceneLookup
|
|
1639
|
+
/** T7 filmstrip content-layer provider (the background tile strip inside a
|
|
1640
|
+
* clip rect). Absent → no filmstrips drawn, same graceful omission as
|
|
1641
|
+
* `waveforms`. */
|
|
1642
|
+
filmstrips?: FilmstripSceneLookup
|
|
1643
|
+
/** Which ground to paint on, resolved from the host theme by `VideoEditor`.
|
|
1644
|
+
* Absent → `'dark'`, so every caller that predates light mode — and every
|
|
1645
|
+
* existing test — paints byte-identical pixels. */
|
|
1646
|
+
mode?: TimelineMode
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
export interface DrawStats {
|
|
1650
|
+
visualItemsDrawn: number
|
|
1651
|
+
audioItemsDrawn: number
|
|
1652
|
+
itemsCulled: number
|
|
1653
|
+
/** Caption blocks drawn. Kept OUT of `visualItemsDrawn`/`audioItemsDrawn` —
|
|
1654
|
+
* every existing assertion built on those two ("drawn + culled === total
|
|
1655
|
+
* items") predates captions and counts only clips/overlays and audio bars;
|
|
1656
|
+
* folding captions in would silently change what those numbers mean for
|
|
1657
|
+
* every project that already has any. Culled captions still count toward
|
|
1658
|
+
* the shared `itemsCulled`, same as every other item kind. Required, not
|
|
1659
|
+
* optional: `drawTimelineContent` always initializes it to 0 and is the
|
|
1660
|
+
* only producer of a `DrawStats`, so every reader can rely on a number. */
|
|
1661
|
+
captionItemsDrawn: number
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** Does [start, end] intersect the viewport's visible time range? */
|
|
1665
|
+
function intersectsRange(start: number, end: number, range: { start: number; end: number }): boolean {
|
|
1666
|
+
return end >= range.start && start <= range.end
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Paint the content layer: rows, clips, audio bars and crossfade bands. The
|
|
1671
|
+
* playhead is NOT drawn here — it lives on its own layer so a 60fps playhead
|
|
1672
|
+
* never repaints the content (see `drawTimelineOverlay`).
|
|
1673
|
+
*
|
|
1674
|
+
* Culling is the whole point: items outside the visible time range are skipped
|
|
1675
|
+
* before any draw call, so the number of draw calls is bounded by what fits on
|
|
1676
|
+
* screen, not by how big the project is. Returned stats make that assertable.
|
|
1677
|
+
*/
|
|
1678
|
+
/**
|
|
1679
|
+
* The labelled interval for a ruler at this scale.
|
|
1680
|
+
*
|
|
1681
|
+
* Walks `RULER_STEPS_SECONDS` for the first step whose on-screen width clears
|
|
1682
|
+
* `RULER_MIN_LABEL_SPACING_PX`, so zooming changes the DENSITY of labels rather
|
|
1683
|
+
* than letting them overlap. Falls back to the coarsest step, which at extreme
|
|
1684
|
+
* zoom-out means sparse labels rather than none.
|
|
1685
|
+
*/
|
|
1686
|
+
export function rulerStepSeconds(pxPerSecond: number): number {
|
|
1687
|
+
if (!(pxPerSecond > 0)) return RULER_STEPS_SECONDS[RULER_STEPS_SECONDS.length - 1]
|
|
1688
|
+
for (const step of RULER_STEPS_SECONDS) {
|
|
1689
|
+
if (step * pxPerSecond >= RULER_MIN_LABEL_SPACING_PX) return step
|
|
1690
|
+
}
|
|
1691
|
+
return RULER_STEPS_SECONDS[RULER_STEPS_SECONDS.length - 1]
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/** `m:ss`, plus tenths only when the step is finer than a second — a ruler
|
|
1695
|
+
* labelled `0:03.0` every three seconds is noise, and one labelled `0:03`
|
|
1696
|
+
* every 250ms is four identical labels in a row. */
|
|
1697
|
+
export function formatRulerTime(seconds: number, step: number): string {
|
|
1698
|
+
const safe = Math.max(0, seconds)
|
|
1699
|
+
const mins = Math.floor(safe / 60)
|
|
1700
|
+
const secs = safe - mins * 60
|
|
1701
|
+
if (step < 1) return `${mins}:${secs.toFixed(1).padStart(4, '0')}`
|
|
1702
|
+
return `${mins}:${String(Math.round(secs)).padStart(2, '0')}`
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* The time ruler across the top of the surface — the strip you scrub on.
|
|
1707
|
+
*
|
|
1708
|
+
* Minor ticks subdivide each labelled interval into fifths, which is the
|
|
1709
|
+
* densest subdivision that still reads as ticks rather than as a grey band.
|
|
1710
|
+
*/
|
|
1711
|
+
export function drawRuler(
|
|
1712
|
+
ctx: DrawContext,
|
|
1713
|
+
viewport: Viewport,
|
|
1714
|
+
rect: { y: number; height: number },
|
|
1715
|
+
surfaceWidth: number,
|
|
1716
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1717
|
+
): void {
|
|
1718
|
+
ctx.save()
|
|
1719
|
+
ctx.fillStyle = palette.colors.rulerBackground
|
|
1720
|
+
ctx.fillRect(0, rect.y, surfaceWidth, rect.height)
|
|
1721
|
+
|
|
1722
|
+
const step = rulerStepSeconds(viewport.pxPerSecond)
|
|
1723
|
+
const minor = step / 5
|
|
1724
|
+
const bottom = rect.y + rect.height
|
|
1725
|
+
const range = visibleRange(viewport)
|
|
1726
|
+
const first = Math.floor(range.start / minor) * minor
|
|
1727
|
+
const last = range.end
|
|
1728
|
+
|
|
1729
|
+
ctx.font = LABEL_FONT
|
|
1730
|
+
ctx.textBaseline = 'alphabetic'
|
|
1731
|
+
|
|
1732
|
+
// Iterated in integer multiples of `minor` rather than by accumulating a
|
|
1733
|
+
// float, so a long timeline cannot drift the ticks off the labels.
|
|
1734
|
+
for (let i = 0; ; i++) {
|
|
1735
|
+
const t = first + i * minor
|
|
1736
|
+
if (t > last) break
|
|
1737
|
+
if (t < 0) continue
|
|
1738
|
+
const x = Math.round(timeToX(t, viewport)) + 0.5
|
|
1739
|
+
if (x < 0 || x > surfaceWidth) continue
|
|
1740
|
+
// Float multiples never land exactly on the step, so test the remainder
|
|
1741
|
+
// against a tolerance scaled to the step itself.
|
|
1742
|
+
const isMajor = Math.abs(t / step - Math.round(t / step)) < 1e-6
|
|
1743
|
+
const tickHeight = isMajor ? RULER_MAJOR_TICK_PX : RULER_MINOR_TICK_PX
|
|
1744
|
+
ctx.fillStyle = isMajor ? palette.colors.rulerTick : palette.colors.rulerTickMinor
|
|
1745
|
+
ctx.fillRect(x, bottom - tickHeight, 1, tickHeight)
|
|
1746
|
+
|
|
1747
|
+
if (isMajor) {
|
|
1748
|
+
ctx.fillStyle = palette.colors.rulerText
|
|
1749
|
+
ctx.fillText(formatRulerTime(t, step), x + 3, rect.y + RULER_LABEL_BASELINE_PX)
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
ctx.restore()
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// ── Pending-drop ghost ───────────────────────────────────────────────────
|
|
1756
|
+
|
|
1757
|
+
/** Corner radius of a ghost band. The same 4px a clip uses — the band stands
|
|
1758
|
+
* where a clip is about to be, so it should be the same shape. */
|
|
1759
|
+
export const PENDING_DROP_RADIUS_PX = CLIP_RADIUS_PX
|
|
1760
|
+
/** Inset from the row's own rectangle, top and bottom, so the ghost reads as
|
|
1761
|
+
* sitting IN the row rather than replacing it. */
|
|
1762
|
+
export const PENDING_DROP_INSET_PX = 2
|
|
1763
|
+
/** Stroke width of the dashed outline. 1.5px, not 1: a dash is half gaps, so
|
|
1764
|
+
* at 1px the outline reads as a faint dotted smudge rather than a border. */
|
|
1765
|
+
export const PENDING_DROP_BORDER_PX = 1.5
|
|
1766
|
+
/** Dash pattern — 6 on, 4 off. Long enough that each dash is unmistakably a
|
|
1767
|
+
* dash (not a dot), short enough that a narrow band still shows several. */
|
|
1768
|
+
export const PENDING_DROP_DASH: readonly number[] = [6, 4]
|
|
1769
|
+
|
|
1770
|
+
/** One in-flight import's ghost band. `start`/`end` are TIMELINE SECONDS (the
|
|
1771
|
+
* painter converts them through the viewport, so a ghost pans and zooms with
|
|
1772
|
+
* everything else without the host resending it); `y`/`height` are surface
|
|
1773
|
+
* CSS pixels, resolved by the caller from the row rectangle in
|
|
1774
|
+
* `TimelineLayout.rows` — the same "read the layout, never re-derive geometry"
|
|
1775
|
+
* rule the hit-test states. `label` is the filename, drawn inside. */
|
|
1776
|
+
export interface PendingDropBand {
|
|
1777
|
+
start: number
|
|
1778
|
+
end: number
|
|
1779
|
+
y: number
|
|
1780
|
+
height: number
|
|
1781
|
+
label?: string
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* The ghost band for a file whose import is still in flight: a translucent
|
|
1786
|
+
* rounded rect with a DASHED outline, at the place the file was dropped.
|
|
1787
|
+
*
|
|
1788
|
+
* Dashed is the whole point. A solid band at clip opacity would read as a clip
|
|
1789
|
+
* that is already there, and the one thing this mark has to say is "this is
|
|
1790
|
+
* not real yet" — the outline is what says it, which is why it is dashed
|
|
1791
|
+
* rather than merely faint (a faint solid band just reads as a dimmed clip,
|
|
1792
|
+
* which on this surface already means a disabled track).
|
|
1793
|
+
*
|
|
1794
|
+
* `setLineDash` is scoped to this painter's own save/restore AND explicitly
|
|
1795
|
+
* reset before it returns. A real 2D context restores the dash with the rest
|
|
1796
|
+
* of its drawing state, but the stub contexts the tests drive do not, and a
|
|
1797
|
+
* leaked dash on the overlay layer means a dashed playhead — the single
|
|
1798
|
+
* easiest bug to ship here, so it is closed twice.
|
|
1799
|
+
*/
|
|
1800
|
+
export function drawPendingDropBand(
|
|
1801
|
+
ctx: DrawContext,
|
|
1802
|
+
band: PendingDropBand,
|
|
1803
|
+
viewport: Viewport,
|
|
1804
|
+
surfaceWidth: number,
|
|
1805
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1806
|
+
): void {
|
|
1807
|
+
const x = timeToX(band.start, viewport)
|
|
1808
|
+
const width = Math.max(0, (band.end - band.start) * viewport.pxPerSecond)
|
|
1809
|
+
const rect = clampRectToSurface({ x, y: band.y + PENDING_DROP_INSET_PX, width, height: Math.max(0, band.height - PENDING_DROP_INSET_PX * 2) }, surfaceWidth)
|
|
1810
|
+
if (rect.width <= 0 || rect.height <= 0) return
|
|
1811
|
+
|
|
1812
|
+
const radius = Math.min(PENDING_DROP_RADIUS_PX, rect.width / 2, rect.height / 2)
|
|
1813
|
+
|
|
1814
|
+
ctx.save()
|
|
1815
|
+
ctx.fillStyle = palette.colors.pendingDropFill
|
|
1816
|
+
roundRectPath(ctx, rect.x, rect.y, rect.width, rect.height, radius)
|
|
1817
|
+
ctx.fill()
|
|
1818
|
+
|
|
1819
|
+
// Inset by half the stroke so the dashes sit fully inside the band instead
|
|
1820
|
+
// of straddling its edge — same reason the marquee offsets by half a pixel.
|
|
1821
|
+
const inset = PENDING_DROP_BORDER_PX / 2
|
|
1822
|
+
ctx.strokeStyle = palette.colors.pendingDropStroke
|
|
1823
|
+
ctx.lineWidth = PENDING_DROP_BORDER_PX
|
|
1824
|
+
ctx.setLineDash(PENDING_DROP_DASH as number[])
|
|
1825
|
+
roundRectPath(
|
|
1826
|
+
ctx,
|
|
1827
|
+
rect.x + inset,
|
|
1828
|
+
rect.y + inset,
|
|
1829
|
+
Math.max(0, rect.width - PENDING_DROP_BORDER_PX),
|
|
1830
|
+
Math.max(0, rect.height - PENDING_DROP_BORDER_PX),
|
|
1831
|
+
radius,
|
|
1832
|
+
)
|
|
1833
|
+
ctx.stroke()
|
|
1834
|
+
ctx.setLineDash([])
|
|
1835
|
+
|
|
1836
|
+
// The filename, clipped to the band: an import is usually named after a
|
|
1837
|
+
// camera file, and those are long — without the clip a name would bleed out
|
|
1838
|
+
// over the clips either side of the ghost. Same MIN_LABEL_WIDTH_PX floor the
|
|
1839
|
+
// clip labels use, below which a label is more smear than information.
|
|
1840
|
+
if (band.label && rect.width >= MIN_LABEL_WIDTH_PX) {
|
|
1841
|
+
ctx.save()
|
|
1842
|
+
ctx.beginPath()
|
|
1843
|
+
ctx.rect(rect.x, rect.y, rect.width, rect.height)
|
|
1844
|
+
ctx.clip()
|
|
1845
|
+
ctx.fillStyle = palette.colors.pendingDropText
|
|
1846
|
+
ctx.font = LABEL_FONT
|
|
1847
|
+
ctx.textBaseline = 'middle'
|
|
1848
|
+
ctx.fillText(band.label, rect.x + LABEL_PAD_PX, rect.y + LABEL_TOP_OFFSET_PX)
|
|
1849
|
+
ctx.restore()
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
ctx.restore()
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/** The rubber-band selection box. Drawn on the overlay layer because it changes
|
|
1856
|
+
* on every pointer move, exactly like the playhead. */
|
|
1857
|
+
export function drawMarquee(
|
|
1858
|
+
ctx: DrawContext,
|
|
1859
|
+
rect: Rect,
|
|
1860
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1861
|
+
): void {
|
|
1862
|
+
ctx.save()
|
|
1863
|
+
ctx.fillStyle = palette.colors.marqueeFill
|
|
1864
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height)
|
|
1865
|
+
ctx.strokeStyle = palette.colors.marqueeBorder
|
|
1866
|
+
ctx.lineWidth = 1
|
|
1867
|
+
// Half-pixel offsets so a 1px stroke lands on a pixel instead of straddling
|
|
1868
|
+
// two and rendering as a 2px blur.
|
|
1869
|
+
ctx.strokeRect(
|
|
1870
|
+
Math.round(rect.x) + 0.5,
|
|
1871
|
+
Math.round(rect.y) + 0.5,
|
|
1872
|
+
Math.round(rect.width),
|
|
1873
|
+
Math.round(rect.height),
|
|
1874
|
+
)
|
|
1875
|
+
ctx.restore()
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
export function drawTimelineContent(ctx: DrawContext, scene: TimelineScene): DrawStats {
|
|
1879
|
+
const { viewport, layout, selectedIds, selectedKeyframe, surfaceWidth, surfaceHeight, hoveredHandle } = scene
|
|
1880
|
+
// Resolved ONCE for the whole pass, not per row or per clip: this is read by
|
|
1881
|
+
// every painter below and a per-item lookup would repeat it hundreds of
|
|
1882
|
+
// times a frame for an answer that cannot change mid-pass.
|
|
1883
|
+
const themePalette = timelinePalette(scene.mode ?? 'dark')
|
|
1884
|
+
const range = visibleRange(viewport)
|
|
1885
|
+
const stats: DrawStats = { visualItemsDrawn: 0, audioItemsDrawn: 0, itemsCulled: 0, captionItemsDrawn: 0 }
|
|
1886
|
+
|
|
1887
|
+
ctx.clearRect(0, 0, surfaceWidth, surfaceHeight)
|
|
1888
|
+
|
|
1889
|
+
drawRuler(ctx, viewport, layout.ruler, surfaceWidth, themePalette)
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
let rowShadeIdx = 0
|
|
1893
|
+
for (const row of layout.rows) {
|
|
1894
|
+
drawRowBackground(ctx, { x: 0, y: row.y, width: surfaceWidth, height: row.height }, rowShadeIdx++ % 2 === 0 ? themePalette.colors.rowBackground : themePalette.colors.rowBackgroundAlt, themePalette)
|
|
1895
|
+
|
|
1896
|
+
// A SKIPPED track is drawn faded: it still draws, and stays selectable and
|
|
1897
|
+
// editable; only playback and export leave it out.
|
|
1898
|
+
const dimmed = row.disabled === true
|
|
1899
|
+
const palette = themePalette.tracks[row.trackIdx % themePalette.tracks.length]
|
|
1900
|
+
// Bodies of the selected clips in this row, queued for the handle pass at
|
|
1901
|
+
// the bottom of the loop.
|
|
1902
|
+
const handleRects: Array<{ body: Rect; hoveredEdge: 'in' | 'out' | null }> = []
|
|
1903
|
+
|
|
1904
|
+
for (const item of row.items) {
|
|
1905
|
+
if (!intersectsRange(item.start, item.end, range)) { stats.itemsCulled++; continue }
|
|
1906
|
+
const x = timeToX(item.start, viewport)
|
|
1907
|
+
const rect = clampRectToSurface(
|
|
1908
|
+
{ x, y: row.y, width: (item.end - item.start) * viewport.pxPerSecond, height: row.height },
|
|
1909
|
+
surfaceWidth,
|
|
1910
|
+
)
|
|
1911
|
+
if (rect.width <= 0) { stats.itemsCulled++; continue }
|
|
1912
|
+
// Both lookups size to the BODY, the rect `drawClipRect` actually paints
|
|
1913
|
+
// into — not the full span, which is two gutters wider.
|
|
1914
|
+
const body = clipBodyRect(rect)
|
|
1915
|
+
const clipWaveform = scene.waveforms?.clipColumns(item, body) ?? null
|
|
1916
|
+
const filmstripTiles = scene.filmstrips?.clipTiles(item, body) ?? null
|
|
1917
|
+
// Mirrors `effectiveItemAudio`'s track-or-item OR without needing a full
|
|
1918
|
+
// VisualTrack in scope here — `row.trackMuted` already folded in the
|
|
1919
|
+
// track's own `muted`, so only the item's is left to OR in.
|
|
1920
|
+
const itemMuted = row.trackMuted || item.muted === true
|
|
1921
|
+
// Composed in one hook, but painting into DISJOINT halves of the clip
|
|
1922
|
+
// (`clip-bands.ts`): tiles in the upper frames band, waveform in the
|
|
1923
|
+
// lower one. Order no longer matters for overlap — it is kept
|
|
1924
|
+
// frames-then-waveform so that if a band ever gains a translucent edge
|
|
1925
|
+
// the picture sits under the audio, not over it. Either half may be
|
|
1926
|
+
// absent (no proxy, no adapter, nothing fetched yet) and the other still
|
|
1927
|
+
// draws.
|
|
1928
|
+
const drawContent = (filmstripTiles || clipWaveform)
|
|
1929
|
+
? (c: DrawContext, r: Rect) => {
|
|
1930
|
+
if (filmstripTiles) drawFilmstripTiles(c, filmstripTiles)
|
|
1931
|
+
if (clipWaveform) drawClipWaveform(c, r, clipWaveform, itemMuted, themePalette.waveform)
|
|
1932
|
+
}
|
|
1933
|
+
: undefined
|
|
1934
|
+
const itemSelected = selectedIds.includes(item.id)
|
|
1935
|
+
drawClipRect(ctx, {
|
|
1936
|
+
rect,
|
|
1937
|
+
palette,
|
|
1938
|
+
selected: itemSelected,
|
|
1939
|
+
label: visualItemLabel(item),
|
|
1940
|
+
dimmed,
|
|
1941
|
+
drawContent,
|
|
1942
|
+
}, themePalette)
|
|
1943
|
+
// The keyframe strip (SP9b T3.3): selected, keyframed overlays only —
|
|
1944
|
+
// see `drawKeyframeStrip`'s own doc for why this is gated here rather
|
|
1945
|
+
// than inside it. Drawn AFTER the clip's own content/label so a
|
|
1946
|
+
// diamond never sits under a filmstrip frame.
|
|
1947
|
+
if (itemSelected && canKeyframe(item) && isKeyframed(item)) {
|
|
1948
|
+
drawKeyframeStrip(ctx, item, body, viewport, selectedKeyframe?.itemId === item.id ? selectedKeyframe.t : null, themePalette)
|
|
1949
|
+
}
|
|
1950
|
+
if (itemSelected) {
|
|
1951
|
+
handleRects.push({ body, hoveredEdge: hoveredHandle?.itemId === item.id ? hoveredHandle.edge : null })
|
|
1952
|
+
}
|
|
1953
|
+
stats.visualItemsDrawn++
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// Overlaps on a visual row, drawn over the clips they span. New here: the
|
|
1957
|
+
// DOM timeline never marked these, and on canvas the only sign of one was
|
|
1958
|
+
// the clip band's dark wash landing twice — a faintly lighter box that
|
|
1959
|
+
// read as a rendering quirk rather than as two clips on top of each other.
|
|
1960
|
+
// A skipped row's band fades with the rest of it.
|
|
1961
|
+
ctx.save()
|
|
1962
|
+
if (dimmed) ctx.globalAlpha = 0.3
|
|
1963
|
+
for (const band of overlapBands(row.items)) {
|
|
1964
|
+
if (!intersectsRange(band.start, band.end, range)) continue
|
|
1965
|
+
drawOverlapBand(ctx, clampRectToSurface(
|
|
1966
|
+
{ x: timeToX(band.start, viewport), y: row.y, width: (band.end - band.start) * viewport.pxPerSecond, height: row.height },
|
|
1967
|
+
surfaceWidth,
|
|
1968
|
+
), themePalette)
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// Handles last of all, over the clips AND over any overlap band. They are
|
|
1972
|
+
// the control, so nothing gets to sit on top of them: a selected clip
|
|
1973
|
+
// whose end is buried under an overlapping neighbour still shows the edge
|
|
1974
|
+
// you can grab, which is what the hit-test now hands you there.
|
|
1975
|
+
for (const { body, hoveredEdge } of handleRects) {
|
|
1976
|
+
drawItemHandles(ctx, body, CLIP_HANDLE_WIDTH_PX, hoveredEdge, Math.min(CLIP_RADIUS_PX, body.width / 2, body.height / 2), themePalette)
|
|
1977
|
+
}
|
|
1978
|
+
ctx.restore()
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// The caption bands — absent entirely on a project with no caption segments
|
|
1982
|
+
// (see `computeTimelineLayout`), so this whole pass is a no-op for every
|
|
1983
|
+
// project that predates captions moving into the canvas. A hole lane still
|
|
1984
|
+
// gets a background-only band: nothing in its (empty) `segments` loop below
|
|
1985
|
+
// draws a block, but the row painted at its `y` is what keeps every OTHER
|
|
1986
|
+
// band from jumping position mid-drag.
|
|
1987
|
+
for (const caption of layout.captions ?? []) {
|
|
1988
|
+
drawRowBackground(ctx, { x: 0, y: caption.y, width: surfaceWidth, height: caption.height }, undefined, themePalette)
|
|
1989
|
+
const handleRects: Array<{ rect: Rect; hoveredEdge: 'in' | 'out' | null }> = []
|
|
1990
|
+
|
|
1991
|
+
for (const seg of caption.segments) {
|
|
1992
|
+
if (!intersectsRange(seg.start, seg.end, range)) { stats.itemsCulled++; continue }
|
|
1993
|
+
const x = timeToX(seg.start, viewport)
|
|
1994
|
+
const rect = clampRectToSurface(
|
|
1995
|
+
{
|
|
1996
|
+
x,
|
|
1997
|
+
y: caption.y + AUDIO_ITEM_INSET_PX,
|
|
1998
|
+
width: (seg.end - seg.start) * viewport.pxPerSecond,
|
|
1999
|
+
height: caption.height - AUDIO_ITEM_INSET_PX * 2,
|
|
2000
|
+
},
|
|
2001
|
+
surfaceWidth,
|
|
2002
|
+
)
|
|
2003
|
+
if (rect.width <= 0) { stats.itemsCulled++; continue }
|
|
2004
|
+
// A segment briefly has no `id` — before `backfillCaptionIds` mints one
|
|
2005
|
+
// (see VideoEditor.tsx) — so it draws, but can never be selected or
|
|
2006
|
+
// handled. `segId` (not `seg.id`) is what the rest of this block reads,
|
|
2007
|
+
// since `seg.id`'s `string | undefined` would otherwise let a bare
|
|
2008
|
+
// `hoveredHandle?.itemId === seg.id` go true on "both sides undefined"
|
|
2009
|
+
// without `hoveredHandle` itself being set.
|
|
2010
|
+
const segId = typeof seg.id === 'string' ? seg.id : null
|
|
2011
|
+
const selected = segId !== null && selectedIds.includes(segId)
|
|
2012
|
+
drawCaptionBlock(ctx, { rect, selected, label: seg.text }, themePalette)
|
|
2013
|
+
if (selected && segId !== null) {
|
|
2014
|
+
const hoveredEdge = hoveredHandle?.itemId === segId ? hoveredHandle.edge : null
|
|
2015
|
+
handleRects.push({ rect, hoveredEdge })
|
|
2016
|
+
}
|
|
2017
|
+
stats.captionItemsDrawn++
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// Handles last, over every block in the band — same "the control always
|
|
2021
|
+
// wins the stacking order" rule the visual rows and audio lanes follow.
|
|
2022
|
+
for (const { rect, hoveredEdge } of handleRects) {
|
|
2023
|
+
drawItemHandles(ctx, rect, AUDIO_HANDLE_WIDTH_PX, hoveredEdge, AUDIO_ITEM_RADIUS_PX, themePalette)
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
for (const lane of layout.lanes) {
|
|
2028
|
+
drawRowBackground(ctx, { x: 0, y: lane.y, width: surfaceWidth, height: lane.height }, undefined, themePalette)
|
|
2029
|
+
const laneHandleRects: Array<{ rect: Rect; hoveredEdge: 'in' | 'out' | null }> = []
|
|
2030
|
+
|
|
2031
|
+
for (const track of lane.tracks) {
|
|
2032
|
+
if (!intersectsRange(track.start, track.end, range)) { stats.itemsCulled++; continue }
|
|
2033
|
+
const x = timeToX(track.start, viewport)
|
|
2034
|
+
const rect = clampRectToSurface(
|
|
2035
|
+
{
|
|
2036
|
+
x,
|
|
2037
|
+
y: lane.y + AUDIO_ITEM_INSET_PX,
|
|
2038
|
+
width: (track.end - track.start) * viewport.pxPerSecond,
|
|
2039
|
+
height: lane.height - AUDIO_ITEM_INSET_PX * 2,
|
|
2040
|
+
},
|
|
2041
|
+
surfaceWidth,
|
|
2042
|
+
)
|
|
2043
|
+
if (rect.width <= 0) { stats.itemsCulled++; continue }
|
|
2044
|
+
// Horizontal gutter, same as a video clip's `clipBodyRect` — two
|
|
2045
|
+
// touching audio bars therefore show the same uniform CLIP_GUTTER_PX
|
|
2046
|
+
// gap video clips do, rather than a seam that depends on where exactly
|
|
2047
|
+
// the bars' edges land.
|
|
2048
|
+
const body = clipBodyRect(rect)
|
|
2049
|
+
// The clip's TRUE body span, before surface-clamping, so the fade
|
|
2050
|
+
// envelope/grips anchor to the clip and scroll with it (see
|
|
2051
|
+
// `fadeSpanX`/`fadeSpanWidth`). `rect`/`body` above are already clamped
|
|
2052
|
+
// to the visible surface for the bar fill and waveform.
|
|
2053
|
+
const fullBody = clipBodyRect({ x, y: body.y, width: (track.end - track.start) * viewport.pxPerSecond, height: body.height })
|
|
2054
|
+
const audioWaveform = scene.waveforms?.audioColumns(track, body) ?? null
|
|
2055
|
+
const fadeInPx = (track.fadeIn ?? 0) * viewport.pxPerSecond
|
|
2056
|
+
const fadeOutPx = (track.fadeOut ?? 0) * viewport.pxPerSecond
|
|
2057
|
+
const fadeInCurve = track.fadeInCurve ?? DEFAULT_FADE_CURVE
|
|
2058
|
+
const fadeOutCurve = track.fadeOutCurve ?? DEFAULT_FADE_CURVE
|
|
2059
|
+
// One gain-at-x lookup per bar, built against its TRUE span (mirrors
|
|
2060
|
+
// fadeSpanX/fadeSpanWidth below) so the waveform's amplitude scaling
|
|
2061
|
+
// agrees with the envelope drawn over it — same anchor, same curves.
|
|
2062
|
+
const gainAt = makeFadeGainAt(fullBody.x, fullBody.width, fadeInPx, fadeOutPx, fadeInCurve, fadeOutCurve)
|
|
2063
|
+
drawAudioItem(ctx, {
|
|
2064
|
+
rect: body,
|
|
2065
|
+
selected: selectedIds.includes(track.id),
|
|
2066
|
+
muted: !!track.muted,
|
|
2067
|
+
label: audioLabel(track),
|
|
2068
|
+
fadeInPx,
|
|
2069
|
+
fadeOutPx,
|
|
2070
|
+
fadeInCurve,
|
|
2071
|
+
fadeOutCurve,
|
|
2072
|
+
fadeSpanX: fullBody.x,
|
|
2073
|
+
fadeSpanWidth: fullBody.width,
|
|
2074
|
+
drawContent: audioWaveform ? (c) => drawAudioLaneWaveform(c, audioWaveform.rect, audioWaveform.columns, gainAt, themePalette.waveform) : undefined,
|
|
2075
|
+
}, themePalette)
|
|
2076
|
+
if (selectedIds.includes(track.id)) {
|
|
2077
|
+
laneHandleRects.push({ rect: body, hoveredEdge: hoveredHandle?.itemId === track.id ? hoveredHandle.edge : null })
|
|
2078
|
+
}
|
|
2079
|
+
stats.audioItemsDrawn++
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// Overlap bands between consecutive unmuted bars in the lane. A muted bar
|
|
2083
|
+
// is not crossfading with anything, so it is left out of the pairing.
|
|
2084
|
+
for (const band of overlapBands(lane.tracks.filter(t => !t.muted))) {
|
|
2085
|
+
if (!intersectsRange(band.start, band.end, range)) continue
|
|
2086
|
+
drawOverlapBand(ctx, clampRectToSurface(
|
|
2087
|
+
{ x: timeToX(band.start, viewport), y: lane.y, width: (band.end - band.start) * viewport.pxPerSecond, height: lane.height },
|
|
2088
|
+
surfaceWidth,
|
|
2089
|
+
), themePalette)
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// As the visual rows: last, over everything. Crossfaded bars overlap by
|
|
2093
|
+
// design, so on a lane this is the normal case rather than the exception.
|
|
2094
|
+
for (const { rect, hoveredEdge } of laneHandleRects) {
|
|
2095
|
+
drawItemHandles(ctx, rect, AUDIO_HANDLE_WIDTH_PX, hoveredEdge, AUDIO_ITEM_RADIUS_PX, themePalette)
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
return stats
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
export interface OverlayScene {
|
|
2103
|
+
viewport: Viewport
|
|
2104
|
+
currentTime: number
|
|
2105
|
+
surfaceWidth: number
|
|
2106
|
+
surfaceHeight: number
|
|
2107
|
+
/** Where the preview-axis cursor sits, or null/absent when the axis is off
|
|
2108
|
+
* or the pointer is elsewhere. Independent of `currentTime`: while the axis
|
|
2109
|
+
* is on the preview follows THIS while the playhead stays put, which is the
|
|
2110
|
+
* entire point of the toggle. */
|
|
2111
|
+
cursorTime?: number | null
|
|
2112
|
+
/** The boundary a running gesture is snapped to, or null when it is running
|
|
2113
|
+
* free (or no gesture is running). Emitted by the pointer machine, which is
|
|
2114
|
+
* the only thing that knows which edge of a dragged span actually caught. */
|
|
2115
|
+
snapTime?: number | null
|
|
2116
|
+
/** Which tier is holding it — a same-track magnet draws boldly, a
|
|
2117
|
+
* cross-track one as a hairline. Defaults to strong. */
|
|
2118
|
+
snapStrength?: SnapStrength | null
|
|
2119
|
+
/** The rubber-band selection box while one is being dragged, else null. */
|
|
2120
|
+
marquee?: Rect | null
|
|
2121
|
+
/** Ghost bands for files the host is still importing (`PendingDrop` in
|
|
2122
|
+
* types.ts, resolved to rectangles by the canvas). On the OVERLAY layer,
|
|
2123
|
+
* not the content one, for the same reason the marquee is: they come and go
|
|
2124
|
+
* on host events unrelated to any project edit, and a ghost must be able to
|
|
2125
|
+
* appear and be retracted without repainting every clip and filmstrip
|
|
2126
|
+
* underneath it. Absent or empty → nothing is drawn, and this layer's paint
|
|
2127
|
+
* is byte-identical to what it was before ghosts existed. */
|
|
2128
|
+
pendingDrops?: readonly PendingDropBand[]
|
|
2129
|
+
/** Which ground to paint on — see `TimelineScene.mode`. Carried on BOTH
|
|
2130
|
+
* scenes rather than only the content one: the playhead, the axis cursor
|
|
2131
|
+
* and the marquee all live on this layer, and a mode change has to be able
|
|
2132
|
+
* to repaint them without waiting for a content edit. */
|
|
2133
|
+
mode?: TimelineMode
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/** Paint the playhead layer. Kept separate from the content so playback — which
|
|
2137
|
+
* moves the playhead ~60 times a second — repaints two `fillRect`s, not the
|
|
2138
|
+
* whole timeline. The preview-axis cursor rides here for the same reason:
|
|
2139
|
+
* tracking the pointer must not force a content repaint. */
|
|
2140
|
+
export function drawTimelineOverlay(ctx: DrawContext, scene: OverlayScene): void {
|
|
2141
|
+
const { viewport, currentTime, surfaceWidth, surfaceHeight, cursorTime, snapTime, snapStrength } = scene
|
|
2142
|
+
// Once per pass, same rule as `drawTimelineContent` — this layer repaints at
|
|
2143
|
+
// ~60Hz during playback, so it is the one that least wants a lookup per mark.
|
|
2144
|
+
const themePalette = timelinePalette(scene.mode ?? 'dark')
|
|
2145
|
+
ctx.clearRect(0, 0, surfaceWidth, surfaceHeight)
|
|
2146
|
+
// First, so the playhead and any guide stay legible over it — the marquee is
|
|
2147
|
+
// a translucent wash and would otherwise dull both.
|
|
2148
|
+
if (scene.marquee) drawMarquee(ctx, scene.marquee, themePalette)
|
|
2149
|
+
// After the marquee (a ghost is a solid-ish band; under the wash it would be
|
|
2150
|
+
// dulled the same way the lines are), and before every LINE below it. The
|
|
2151
|
+
// playhead in particular has to stay legible over a ghost: an import that
|
|
2152
|
+
// happens to land under the playhead must not hide where playback is, and a
|
|
2153
|
+
// band is far wider than any of these marks, so it can only ever go beneath
|
|
2154
|
+
// them. Each band culls itself the way the playhead and cursor do — the
|
|
2155
|
+
// painter converts to x and returns on a rect that clamps away to nothing.
|
|
2156
|
+
if (scene.pendingDrops) {
|
|
2157
|
+
for (const band of scene.pendingDrops) {
|
|
2158
|
+
drawPendingDropBand(ctx, band, viewport, surfaceWidth, themePalette)
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
if (cursorTime !== undefined && cursorTime !== null) {
|
|
2162
|
+
const cx = timeToX(cursorTime, viewport)
|
|
2163
|
+
if (!(cx < -CURSOR_WIDTH_PX || cx > surfaceWidth + CURSOR_WIDTH_PX)) {
|
|
2164
|
+
drawCursorLine(ctx, cx, 0, surfaceHeight, themePalette)
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
const x = timeToX(currentTime, viewport)
|
|
2168
|
+
if (!(x < -PLAYHEAD_WIDTH_PX || x > surfaceWidth + PLAYHEAD_WIDTH_PX)) {
|
|
2169
|
+
drawPlayhead(ctx, x, 0, surfaceHeight, themePalette)
|
|
2170
|
+
}
|
|
2171
|
+
// Last, so it wins over both lines. A gesture snapped to the playhead should
|
|
2172
|
+
// look snapped, not hidden behind the thing it snapped to.
|
|
2173
|
+
if (snapTime !== undefined && snapTime !== null) {
|
|
2174
|
+
const sx = timeToX(snapTime, viewport)
|
|
2175
|
+
if (!(sx < -SNAP_GUIDE_CAP_HALF_WIDTH_PX || sx > surfaceWidth + SNAP_GUIDE_CAP_HALF_WIDTH_PX)) {
|
|
2176
|
+
drawSnapGuide(ctx, sx, 0, surfaceHeight, snapStrength ?? 'strong', themePalette)
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
/** AudioTrackRow's label rule: the type name, else the track label, else the
|
|
2182
|
+
* source filename. */
|
|
2183
|
+
export function audioLabel(track: AudioTrack): string {
|
|
2184
|
+
if (track.type === 'voiceover') return 'Voiceover'
|
|
2185
|
+
if (track.type === 'music') return 'Music'
|
|
2186
|
+
return track.label ?? track.src.split('/').pop() ?? 'audio'
|
|
2187
|
+
}
|