@bycrux/editor 0.11.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas timeline hit-testing (SP5 T5) — point → target, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* The DOM timeline never needed this: each clip was an element, so the browser
|
|
5
|
+
* did the hit-testing and every gesture arrived pre-addressed. On canvas there
|
|
6
|
+
* is one surface and one pointer stream, so the address has to be computed.
|
|
7
|
+
*
|
|
8
|
+
* Two rules keep this honest:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Layout is read, never re-derived.** Rows and lanes come from
|
|
11
|
+
* `computeTimelineLayout` (draw.ts) — the same rectangles the painter fills.
|
|
12
|
+
* A hit-test that computed its own geometry would drift from the picture the
|
|
13
|
+
* moment a row height changed, and the drift would show up as "I clicked the
|
|
14
|
+
* clip and nothing happened".
|
|
15
|
+
* 2. **No DOM.** The point is already surface-relative CSS pixels. Everything
|
|
16
|
+
* here is arithmetic over the layout and the viewport, so every case is a
|
|
17
|
+
* unit test rather than a browser session.
|
|
18
|
+
*
|
|
19
|
+
* Edge tolerances are the DOM resize-handle widths, so a trim grab that worked
|
|
20
|
+
* on the old timeline works here: `w-2.5` (10px) on VisualTrackRow's handles,
|
|
21
|
+
* `w-1.5` (6px) on AudioTrackRow's — and on the retired CaptionTrackRow's,
|
|
22
|
+
* which is why captions hit-test at the audio tolerance.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { AudioTrack, CaptionSegment, VisualItem } from '../../../schema'
|
|
26
|
+
import { canKeyframe, isKeyframed } from '../../keyframeOps'
|
|
27
|
+
import { AUDIO_ITEM_INSET_PX, type TimelineLayout } from './draw'
|
|
28
|
+
import { KEYFRAME_HIT_HALF_WIDTH_PX, KEYFRAME_STRIP_ZONE_HEIGHT_PX, keyframeDiamondX, keyframeUnionTimes } from './keyframe-strip'
|
|
29
|
+
import { timeToX, xToTime, type Viewport } from './viewport'
|
|
30
|
+
|
|
31
|
+
export interface Point {
|
|
32
|
+
x: number
|
|
33
|
+
y: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** VisualTrackRow's `w-2.5` resize handles. */
|
|
37
|
+
export const VISUAL_EDGE_TOLERANCE_PX = 10
|
|
38
|
+
|
|
39
|
+
/** AudioTrackRow's `w-1.5` resize handles. */
|
|
40
|
+
export const AUDIO_EDGE_TOLERANCE_PX = 6
|
|
41
|
+
|
|
42
|
+
/** Grab band (half-width, px) around the 2px playhead line. Matches the audio
|
|
43
|
+
* trim-handle tolerance so the playhead is no harder to grab than an audio
|
|
44
|
+
* edge. A press within this many px of where the playhead is drawn starts a
|
|
45
|
+
* scrub, exactly as pressing the ruler does. */
|
|
46
|
+
export const PLAYHEAD_GRAB_PX = 6
|
|
47
|
+
|
|
48
|
+
/** Half-width (px) of an audio bar's fade-GRIP zone — small on purpose,
|
|
49
|
+
* matching `draw.ts`'s `FADE_GRIP_SIZE_PX` triangle it's the hit zone for.
|
|
50
|
+
* See `audioFadeGripZone`'s doc for the precedence this creates against the
|
|
51
|
+
* full-height trim edge. */
|
|
52
|
+
export const FADE_GRIP_HALF_WIDTH_PX = 5
|
|
53
|
+
|
|
54
|
+
/** How far down from the TOP of an audio bar the fade-grip zone reaches.
|
|
55
|
+
* Confines the grip to a small top-corner target rather than the bar's
|
|
56
|
+
* full height — which is what keeps it from swallowing the trim edge
|
|
57
|
+
* (`audio-edge`), grabbable across the bar's full height everywhere below
|
|
58
|
+
* this. */
|
|
59
|
+
export const FADE_GRIP_ZONE_HEIGHT_PX = 10
|
|
60
|
+
|
|
61
|
+
export interface HitTestOptions {
|
|
62
|
+
visualEdgeTolerancePx?: number
|
|
63
|
+
audioEdgeTolerancePx?: number
|
|
64
|
+
/** The current selection, ids only — needed for exactly one thing: a
|
|
65
|
+
* keyframe-strip diamond (`'keyframe'`) only exists to hit-test on a
|
|
66
|
+
* SELECTED overlay (plan decision 1; `drawKeyframeStrip` only PAINTS one
|
|
67
|
+
* there too). Absent or empty means no keyframe hits are possible, which
|
|
68
|
+
* is also what every caller that predates T3.3 gets — this option is
|
|
69
|
+
* purely additive. */
|
|
70
|
+
selectedIds?: readonly string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type HitKind =
|
|
74
|
+
/** The time ruler strip along the top. Scrubbing lives here, and only here:
|
|
75
|
+
* the track area's empty space belongs to the marquee now. */
|
|
76
|
+
| 'ruler'
|
|
77
|
+
/** Inside a visual clip, away from its trim handles. */
|
|
78
|
+
| 'item-body'
|
|
79
|
+
/** Inside a visual clip's in/out trim handle. */
|
|
80
|
+
| 'item-edge'
|
|
81
|
+
/** On a keyframe-strip diamond (`drawKeyframeStrip` in draw.ts) — the small
|
|
82
|
+
* zone along the bottom of a SELECTED, keyframed overlay clip. Only ever
|
|
83
|
+
* produced for an item that satisfies all three (see `HitTestOptions.
|
|
84
|
+
* selectedIds`); takes precedence over `item-edge`/`item-body` within its
|
|
85
|
+
* own small zone, the same way `audio-fade` takes precedence over
|
|
86
|
+
* `audio-edge`/`audio-body`. */
|
|
87
|
+
| 'keyframe'
|
|
88
|
+
/** Inside an audio bar, away from its trim handles. */
|
|
89
|
+
| 'audio-body'
|
|
90
|
+
| 'audio-edge'
|
|
91
|
+
/** On an audio bar's fade-in or fade-out GRIP — a small zone at the TOP of
|
|
92
|
+
* the bar near the fade's inner edge (or the bar's own corner, when no
|
|
93
|
+
* fade is set). Takes precedence over `audio-edge`/`audio-body` within
|
|
94
|
+
* its own small zone; see `audioFadeGripZone`'s doc for the precedence
|
|
95
|
+
* this creates and why it doesn't swallow the trim edge. */
|
|
96
|
+
| 'audio-fade'
|
|
97
|
+
/** Inside a caption block, away from its trim handles. */
|
|
98
|
+
| 'caption-body'
|
|
99
|
+
/** Inside a caption block's in/out trim handle. */
|
|
100
|
+
| 'caption-edge'
|
|
101
|
+
/** A visual row, but no clip under the point. */
|
|
102
|
+
| 'empty-row'
|
|
103
|
+
/** An audio lane, but no bar under the point (includes the lane's inset). */
|
|
104
|
+
| 'empty-lane'
|
|
105
|
+
/** Outside every row: the gaps between rows, and anything past the last one. */
|
|
106
|
+
| 'background'
|
|
107
|
+
|
|
108
|
+
export interface HitResult {
|
|
109
|
+
kind: HitKind
|
|
110
|
+
/** Time at the point's x. Always present — every gesture needs it, and a
|
|
111
|
+
* point outside the rows is still a point in time. */
|
|
112
|
+
t: number
|
|
113
|
+
itemId?: string
|
|
114
|
+
/** Which trim handle, on the two `*-edge` kinds. `in` is the left/start
|
|
115
|
+
* handle, `out` the right/end one — the vocabulary `cuts.ts` uses for source
|
|
116
|
+
* windows, rather than the DOM hook's `start`/`end`. */
|
|
117
|
+
edge?: 'in' | 'out'
|
|
118
|
+
/** Which fade grip, on `audio-fade` hits. `in` is the fade-in grip
|
|
119
|
+
* (top-left corner or its inner edge), `out` the fade-out grip
|
|
120
|
+
* (top-right). A field of its own, deliberately NOT a reuse of `edge` —
|
|
121
|
+
* a fade grip is not a trim handle, and `isEdgeHit` (which several
|
|
122
|
+
* callers use to mean "this is a trim grab") must not go true for it. */
|
|
123
|
+
side?: 'in' | 'out'
|
|
124
|
+
/** The keyframe's item-relative time, on a `'keyframe'` hit — the SAME `t`
|
|
125
|
+
* every `keyframeOps` export addresses a point by. A field of its own,
|
|
126
|
+
* deliberately not a reuse of `t` (which is the point's ABSOLUTE timeline
|
|
127
|
+
* time, the same `t` every other hit kind reports). */
|
|
128
|
+
kfT?: number
|
|
129
|
+
/** Visual track index (project.tracks[trackIdx]) for row and item hits. */
|
|
130
|
+
trackIdx?: number
|
|
131
|
+
/** Audio lane index (the `lane` field / grouping index) for lane hits. */
|
|
132
|
+
laneIdx?: number
|
|
133
|
+
/** Which caption lane a caption hit resolved in — the `lane` of the band the
|
|
134
|
+
* point fell inside (see `CaptionRowLayout` in draw.ts).
|
|
135
|
+
*
|
|
136
|
+
* A field of its own, deliberately NOT a reuse of `laneIdx`. That one is an
|
|
137
|
+
* AUDIO lane index and is handed straight to `tieredBoundaries`, which ranks
|
|
138
|
+
* the boundaries of THAT audio lane STRONG for the whole gesture. A caption
|
|
139
|
+
* lane and an audio lane that happen to share a number have nothing to do
|
|
140
|
+
* with each other, so passing one through the other would give a caption
|
|
141
|
+
* drag the magnets of an unrelated row. */
|
|
142
|
+
captionLane?: number
|
|
143
|
+
/** The hit item itself, so callers don't re-scan the project. Captured at
|
|
144
|
+
* press time by the pointer machine and used as the gesture's origin. */
|
|
145
|
+
item?: VisualItem
|
|
146
|
+
track?: AudioTrack
|
|
147
|
+
/** The hit caption segment, for the same reason `item`/`track` are here: the
|
|
148
|
+
* pointer machine captures it at press time and reads its `start`/`end` for
|
|
149
|
+
* the whole gesture, rather than re-scanning `project.captions` per move. */
|
|
150
|
+
segment?: CaptionSegment
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Which part of a horizontal span [x0, x1] a point falls in, given the handle
|
|
154
|
+
* width. Returns null when the point is outside the span entirely.
|
|
155
|
+
*
|
|
156
|
+
* Handles sit INSIDE the span, exactly as the DOM's absolutely-positioned
|
|
157
|
+
* `left-0`/`right-0` handle divs do. When a clip is narrower than two handles
|
|
158
|
+
* the two zones overlap, and the out handle wins — matching the DOM, where the
|
|
159
|
+
* right handle is painted last and therefore hit-tests first. */
|
|
160
|
+
function spanZone(x: number, x0: number, x1: number, tolerance: number): 'body' | 'in' | 'out' | null {
|
|
161
|
+
if (x < x0 || x > x1) return null
|
|
162
|
+
if (x >= x1 - tolerance) return 'out'
|
|
163
|
+
if (x <= x0 + tolerance) return 'in'
|
|
164
|
+
return 'body'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** One row's worth of hit resolution, shared by the visual and audio scans.
|
|
168
|
+
*
|
|
169
|
+
* Edges beat bodies ACROSS the whole row, and the nearest edge wins.
|
|
170
|
+
*
|
|
171
|
+
* The obvious rule — topmost item wins, and within it an edge beats its own
|
|
172
|
+
* body — is what this replaces, and it made the trailing edge of an
|
|
173
|
+
* overlapped clip completely ungrabbable. Where clip B overlaps the end of
|
|
174
|
+
* clip A, every point near A's out edge is also inside B, and B is on top, so
|
|
175
|
+
* the scan returned B's body and stopped. You could see A's edge; you could
|
|
176
|
+
* not trim it. Crossfaded audio bars had the same hole, permanently.
|
|
177
|
+
*
|
|
178
|
+
* Nearest-edge-wins fixes it without disturbing the ordinary case: a point is
|
|
179
|
+
* only ever a candidate for items whose span actually contains it, and for
|
|
180
|
+
* clips that merely touch, the shared boundary is equidistant, so the tie
|
|
181
|
+
* falls to the topmost item exactly as before. Ties resolve to the topmost
|
|
182
|
+
* because the scan runs back-to-front and only a STRICTLY nearer edge
|
|
183
|
+
* displaces the incumbent.
|
|
184
|
+
*/
|
|
185
|
+
function resolveRow<T extends { id: string; start: number; end: number }>(
|
|
186
|
+
x: number,
|
|
187
|
+
items: readonly T[],
|
|
188
|
+
viewport: Viewport,
|
|
189
|
+
tolerance: number,
|
|
190
|
+
): { item: T; edge: 'in' | 'out' } | { item: T; edge: null } | null {
|
|
191
|
+
let bestEdge: { item: T; edge: 'in' | 'out'; dist: number } | null = null
|
|
192
|
+
let topBody: T | null = null
|
|
193
|
+
|
|
194
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
195
|
+
const item = items[i]
|
|
196
|
+
const x0 = timeToX(item.start, viewport)
|
|
197
|
+
const x1 = timeToX(item.end, viewport)
|
|
198
|
+
const zone = spanZone(x, x0, x1, tolerance)
|
|
199
|
+
if (zone === null) continue
|
|
200
|
+
if (zone === 'body') {
|
|
201
|
+
if (topBody === null) topBody = item
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
const dist = zone === 'in' ? x - x0 : x1 - x
|
|
205
|
+
if (bestEdge === null || dist < bestEdge.dist) bestEdge = { item, edge: zone, dist }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (bestEdge !== null) return { item: bestEdge.item, edge: bestEdge.edge }
|
|
209
|
+
if (topBody !== null) return { item: topBody, edge: null }
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The screen x of an audio bar's fade GRIP for one side — at the fade's
|
|
214
|
+
* inner edge when a fade is set, or at the bar's own corner when it isn't
|
|
215
|
+
* (drag inward from there to create one). Expressed in TIME-then-`timeToX`,
|
|
216
|
+
* the same convention `resolveRow` uses for every other audio-lane zone:
|
|
217
|
+
* the gutter `clipBodyRect` insets the DRAWN body by is paint, not a dead
|
|
218
|
+
* zone, so hit-testing always works over the raw span. Shares this math
|
|
219
|
+
* with `draw.ts`'s own grip placement (there expressed in already-converted
|
|
220
|
+
* px, since the painter works from a `rect` rather than a `track`) only in
|
|
221
|
+
* spirit — the two are kept independently computed because they start from
|
|
222
|
+
* different inputs, not duplicated by accident. */
|
|
223
|
+
function audioFadeGripX(track: AudioTrack, side: 'in' | 'out', viewport: Viewport): number {
|
|
224
|
+
return side === 'in'
|
|
225
|
+
? timeToX(track.start + Math.max(0, track.fadeIn ?? 0), viewport)
|
|
226
|
+
: timeToX(track.end - Math.max(0, track.fadeOut ?? 0), viewport)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Does `point` fall in one of `track`'s two fade-grip zones? Returns the
|
|
231
|
+
* matching side, or null.
|
|
232
|
+
*
|
|
233
|
+
* Confined to the TOP `FADE_GRIP_ZONE_HEIGHT_PX` of the bar (`barTop` is the
|
|
234
|
+
* same y the caller already computed for the bar's vertical inset) — this,
|
|
235
|
+
* not a separate x-tolerance trick, is what keeps the grip from swallowing
|
|
236
|
+
* the trim edge. `audio-edge` is grabbable across the bar's FULL height; a
|
|
237
|
+
* fade grip only claims a small target at the very top, near the same
|
|
238
|
+
* corner, so the two zones barely overlap and where they do (no fade set,
|
|
239
|
+
* grip sitting exactly at the corner) the grip wins — it is the smaller,
|
|
240
|
+
* more deliberate target, the same reasoning `grabsPlayhead` already gives
|
|
241
|
+
* trim edges over the playhead grab.
|
|
242
|
+
*/
|
|
243
|
+
function audioFadeGripZone(
|
|
244
|
+
point: Point,
|
|
245
|
+
track: AudioTrack,
|
|
246
|
+
barTop: number,
|
|
247
|
+
viewport: Viewport,
|
|
248
|
+
): 'in' | 'out' | null {
|
|
249
|
+
if (point.y < barTop || point.y > barTop + FADE_GRIP_ZONE_HEIGHT_PX) return null
|
|
250
|
+
const x0 = timeToX(track.start, viewport)
|
|
251
|
+
const x1 = timeToX(track.end, viewport)
|
|
252
|
+
if (point.x < x0 - FADE_GRIP_HALF_WIDTH_PX || point.x > x1 + FADE_GRIP_HALF_WIDTH_PX) return null
|
|
253
|
+
const inX = audioFadeGripX(track, 'in', viewport)
|
|
254
|
+
if (Math.abs(point.x - inX) <= FADE_GRIP_HALF_WIDTH_PX) return 'in'
|
|
255
|
+
const outX = audioFadeGripX(track, 'out', viewport)
|
|
256
|
+
if (Math.abs(point.x - outX) <= FADE_GRIP_HALF_WIDTH_PX) return 'out'
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Does `point` fall on one of `item`'s keyframe-strip diamonds? Returns the
|
|
262
|
+
* matching keyframe's item-relative `t`, or null.
|
|
263
|
+
*
|
|
264
|
+
* Confined to the bottom `KEYFRAME_STRIP_ZONE_HEIGHT_PX` of the row — the same
|
|
265
|
+
* "small dedicated target near where it's drawn" reasoning `audioFadeGripZone`
|
|
266
|
+
* uses to keep a grip from swallowing the trim edge below it; here what sits
|
|
267
|
+
* below is `item-body`/`item-edge`, not `audio-body`/`audio-edge`, but the
|
|
268
|
+
* precedence story is identical. Callers gate this on selection themselves
|
|
269
|
+
* (`hitTest` only calls it for ids in `opts.selectedIds`) — it does not check
|
|
270
|
+
* again, mirroring how `drawKeyframeStrip` is gated by ITS caller rather than
|
|
271
|
+
* internally.
|
|
272
|
+
*
|
|
273
|
+
* Out-of-span times (`t < 0` or `t > duration`) are skipped. `draw.ts`'s
|
|
274
|
+
* `drawKeyframeStrip` clips such a diamond away — it is never actually drawn
|
|
275
|
+
* anywhere — but `keyframeDiamondX` is happy to compute a screen x for it
|
|
276
|
+
* regardless, and that x can land ANYWHERE in the row, including on top of a
|
|
277
|
+
* different clip's body that IS drawn there. Without this filter that
|
|
278
|
+
* invisible zone would win over the real `item-body`/`item-edge` hit
|
|
279
|
+
* underneath it, purely because it happened to be checked first.
|
|
280
|
+
*/
|
|
281
|
+
function keyframeStripZone(point: Point, item: VisualItem, rowY: number, rowHeight: number, viewport: Viewport): number | null {
|
|
282
|
+
const zoneTop = rowY + rowHeight - KEYFRAME_STRIP_ZONE_HEIGHT_PX
|
|
283
|
+
if (point.y < zoneTop || point.y >= rowY + rowHeight) return null
|
|
284
|
+
const duration = Math.max(0, item.end - item.start)
|
|
285
|
+
for (const t of keyframeUnionTimes(item)) {
|
|
286
|
+
if (t < 0 || t > duration) continue
|
|
287
|
+
if (Math.abs(point.x - keyframeDiamondX(item, t, viewport)) <= KEYFRAME_HIT_HALF_WIDTH_PX) return t
|
|
288
|
+
}
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Resolve a surface-space point to what sits under it.
|
|
294
|
+
*
|
|
295
|
+
* Items within a row are scanned back-to-front (last in the array first), which
|
|
296
|
+
* is the order the painter draws them and therefore the order the DOM stacked
|
|
297
|
+
* them: the clip drawn on top is the clip you grab. Row bounds are half-open on
|
|
298
|
+
* the bottom edge so the 4px inter-row gap belongs to neither neighbour.
|
|
299
|
+
*/
|
|
300
|
+
export function hitTest(
|
|
301
|
+
point: Point,
|
|
302
|
+
layout: TimelineLayout,
|
|
303
|
+
viewport: Viewport,
|
|
304
|
+
opts: HitTestOptions = {},
|
|
305
|
+
): HitResult {
|
|
306
|
+
const t = xToTime(point.x, viewport)
|
|
307
|
+
const visualTolerance = opts.visualEdgeTolerancePx ?? VISUAL_EDGE_TOLERANCE_PX
|
|
308
|
+
const audioTolerance = opts.audioEdgeTolerancePx ?? AUDIO_EDGE_TOLERANCE_PX
|
|
309
|
+
|
|
310
|
+
// Before the rows, and including everything above the strip: a pointer that
|
|
311
|
+
// has run off the top of the surface is still aiming at the ruler, and
|
|
312
|
+
// clamping it there keeps a scrub alive when the hand drifts upward
|
|
313
|
+
// mid-drag rather than dropping the gesture on the floor.
|
|
314
|
+
if (point.y < layout.ruler.y + layout.ruler.height) return { kind: 'ruler', t }
|
|
315
|
+
|
|
316
|
+
// The caption bands. Their y-ranges are disjoint from every row and lane
|
|
317
|
+
// (and from each other), so this could sit anywhere among the scans below;
|
|
318
|
+
// it goes here because it was a single optional rectangle like the ruler
|
|
319
|
+
// before multi-lane captions existed, and this keeps that spot.
|
|
320
|
+
//
|
|
321
|
+
// The loop finds WHICH band the point falls in and then runs the same
|
|
322
|
+
// single-band logic against that band, reporting the band's `lane` back as
|
|
323
|
+
// `captionLane` so a drag knows which row it started on. With exactly one
|
|
324
|
+
// band this is byte-for-byte the pre-lanes behaviour, plus a `captionLane`
|
|
325
|
+
// of 0 that nothing which doesn't care ever reads.
|
|
326
|
+
for (const caption of layout.captions ?? []) {
|
|
327
|
+
if (point.y < caption.y || point.y >= caption.y + caption.height) continue
|
|
328
|
+
// Id-less segments are never hittable. `backfillCaptionIds` mints an id
|
|
329
|
+
// shortly after captions arrive, but until it has, a segment has nothing
|
|
330
|
+
// to select BY — the same guard the retired CaptionTrackRow spelled `canInteract`.
|
|
331
|
+
const hittable = caption.segments.filter(
|
|
332
|
+
(seg): seg is CaptionSegment & { id: string } => typeof seg.id === 'string',
|
|
333
|
+
)
|
|
334
|
+
// Deliberately the FULL band height, unlike the audio-lane branch below,
|
|
335
|
+
// which treats its inset strip as lane background. The painter insets
|
|
336
|
+
// caption blocks by the same `AUDIO_ITEM_INSET_PX`, so this is a 4px
|
|
337
|
+
// cosmetic mismatch at the top and bottom — and that is much the lesser
|
|
338
|
+
// evil: a 40px row whose outer 8px silently miss reads as a broken row,
|
|
339
|
+
// while an audio LANE is tall enough that its inset is visibly gutter.
|
|
340
|
+
//
|
|
341
|
+
// The AUDIO tolerance, not a third one of its own: the retired DOM row's
|
|
342
|
+
// handles were `w-1.5` like AudioTrackRow's, and the painter draws
|
|
343
|
+
// caption handles at `AUDIO_HANDLE_WIDTH_PX`. Captions borrow the audio
|
|
344
|
+
// handle vocabulary wholesale, so they borrow its grab width too.
|
|
345
|
+
const hit = resolveRow(point.x, hittable, viewport, audioTolerance)
|
|
346
|
+
if (hit === null) {
|
|
347
|
+
// Not `empty-caption-row`. `background` already means exactly what the
|
|
348
|
+
// gaps between caption blocks should do — marquee from here, seek here,
|
|
349
|
+
// clear the selection — and a new kind would only force `isEmptyHit`
|
|
350
|
+
// and `resolveGesture` to learn about it for no behavioural gain.
|
|
351
|
+
return { kind: 'background', t }
|
|
352
|
+
}
|
|
353
|
+
if (hit.edge === null) return { kind: 'caption-body', t, itemId: hit.item.id, segment: hit.item, captionLane: caption.lane }
|
|
354
|
+
return { kind: 'caption-edge', t, itemId: hit.item.id, edge: hit.edge, segment: hit.item, captionLane: caption.lane }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const selectedIds = opts.selectedIds
|
|
358
|
+
for (const row of layout.rows) {
|
|
359
|
+
if (point.y < row.y || point.y >= row.y + row.height) continue
|
|
360
|
+
// Keyframe diamonds take precedence within their own small bottom-strip
|
|
361
|
+
// zone — selected, keyframed overlay items only (nothing else EVER draws
|
|
362
|
+
// a diamond; see `drawKeyframeStrip`). Scanned back-to-front like
|
|
363
|
+
// `resolveRow`, so a diamond on a higher-stacked item wins ties the same
|
|
364
|
+
// way a body/edge hit would.
|
|
365
|
+
if (selectedIds !== undefined && selectedIds.length > 0) {
|
|
366
|
+
for (let i = row.items.length - 1; i >= 0; i--) {
|
|
367
|
+
const item = row.items[i]
|
|
368
|
+
if (!canKeyframe(item) || !selectedIds.includes(item.id) || !isKeyframed(item)) continue
|
|
369
|
+
const kfT = keyframeStripZone(point, item, row.y, row.height, viewport)
|
|
370
|
+
if (kfT !== null) return { kind: 'keyframe', t, itemId: item.id, kfT, trackIdx: row.trackIdx, item }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const hit = resolveRow(point.x, row.items, viewport, visualTolerance)
|
|
374
|
+
if (hit === null) return { kind: 'empty-row', t, trackIdx: row.trackIdx }
|
|
375
|
+
if (hit.edge === null) return { kind: 'item-body', t, itemId: hit.item.id, trackIdx: row.trackIdx, item: hit.item }
|
|
376
|
+
return { kind: 'item-edge', t, itemId: hit.item.id, edge: hit.edge, trackIdx: row.trackIdx, item: hit.item }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const lane of layout.lanes) {
|
|
380
|
+
if (point.y < lane.y || point.y >= lane.y + lane.height) continue
|
|
381
|
+
// Audio bars are inset vertically (`top-1 bottom-1`); the inset strip is
|
|
382
|
+
// lane background in the DOM too, not part of the bar.
|
|
383
|
+
const barTop = lane.y + AUDIO_ITEM_INSET_PX
|
|
384
|
+
const barBottom = lane.y + lane.height - AUDIO_ITEM_INSET_PX
|
|
385
|
+
if (point.y >= barTop && point.y < barBottom) {
|
|
386
|
+
// Fade grips take precedence within their own small top-corner zone —
|
|
387
|
+
// see `audioFadeGripZone`'s doc for why. Scanned back-to-front like
|
|
388
|
+
// `resolveRow`, so a grip on a higher-stacked (later-drawn, crossfaded)
|
|
389
|
+
// bar wins ties the same way a body/edge hit would.
|
|
390
|
+
for (let i = lane.tracks.length - 1; i >= 0; i--) {
|
|
391
|
+
const track = lane.tracks[i]
|
|
392
|
+
const side = audioFadeGripZone(point, track, barTop, viewport)
|
|
393
|
+
if (side !== null) return { kind: 'audio-fade', t, itemId: track.id, side, laneIdx: lane.laneIndex, track }
|
|
394
|
+
}
|
|
395
|
+
const hit = resolveRow(point.x, lane.tracks, viewport, audioTolerance)
|
|
396
|
+
if (hit !== null) {
|
|
397
|
+
if (hit.edge === null) return { kind: 'audio-body', t, itemId: hit.item.id, laneIdx: lane.laneIndex, track: hit.item }
|
|
398
|
+
return { kind: 'audio-edge', t, itemId: hit.item.id, edge: hit.edge, laneIdx: lane.laneIndex, track: hit.item }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return { kind: 'empty-lane', t, laneIdx: lane.laneIndex }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return { kind: 'background', t }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export interface SurfaceRect { x: number; y: number; width: number; height: number }
|
|
408
|
+
|
|
409
|
+
/** A rect from two corners, in any drag direction. A marquee dragged up-left
|
|
410
|
+
* is the same box as one dragged down-right; normalizing here means every
|
|
411
|
+
* consumer can assume non-negative width and height. */
|
|
412
|
+
export function normalizeRect(a: Point, b: Point): SurfaceRect {
|
|
413
|
+
const x = Math.min(a.x, b.x)
|
|
414
|
+
const y = Math.min(a.y, b.y)
|
|
415
|
+
return { x, y, width: Math.abs(a.x - b.x), height: Math.abs(a.y - b.y) }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function overlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
|
419
|
+
return aStart < bEnd && bStart < aEnd
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Every item id the marquee box touches — visual clips and audio bars alike.
|
|
424
|
+
*
|
|
425
|
+
* Touch, not containment: a box only counts items it fully encloses forces you
|
|
426
|
+
* to drag past the ends of long clips to catch them, which on a zoomed-in
|
|
427
|
+
* timeline can mean dragging off-screen. Every NLE uses intersection, and so
|
|
428
|
+
* does the reference this was built from.
|
|
429
|
+
*
|
|
430
|
+
* Rows are tested against the box in surface space and spans in time space, so
|
|
431
|
+
* a tall thin box down one moment in time selects across every track at once —
|
|
432
|
+
* which is the gesture's most useful form.
|
|
433
|
+
*/
|
|
434
|
+
export function itemsInRect(
|
|
435
|
+
rect: SurfaceRect,
|
|
436
|
+
layout: TimelineLayout,
|
|
437
|
+
viewport: Viewport,
|
|
438
|
+
): string[] {
|
|
439
|
+
const ids: string[] = []
|
|
440
|
+
const left = xToTime(rect.x, viewport)
|
|
441
|
+
const right = xToTime(rect.x + rect.width, viewport)
|
|
442
|
+
const top = rect.y
|
|
443
|
+
const bottom = rect.y + rect.height
|
|
444
|
+
|
|
445
|
+
// Captions first, mirroring `hitTest`'s order. Id-less segments are skipped
|
|
446
|
+
// for the same reason they are unhittable: an id is what a selection is.
|
|
447
|
+
// One pass per band, same as the row/lane scans below — a marquee spanning
|
|
448
|
+
// several caption lanes picks up segments from every band it touches.
|
|
449
|
+
for (const caption of layout.captions ?? []) {
|
|
450
|
+
if (!overlaps(caption.y, caption.y + caption.height, top, bottom)) continue
|
|
451
|
+
for (const seg of caption.segments) {
|
|
452
|
+
if (typeof seg.id === 'string' && overlaps(seg.start, seg.end, left, right)) ids.push(seg.id)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const row of layout.rows) {
|
|
456
|
+
if (!overlaps(row.y, row.y + row.height, top, bottom)) continue
|
|
457
|
+
for (const item of row.items) {
|
|
458
|
+
if (overlaps(item.start, item.end, left, right)) ids.push(item.id)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (const lane of layout.lanes) {
|
|
462
|
+
if (!overlaps(lane.y, lane.y + lane.height, top, bottom)) continue
|
|
463
|
+
for (const track of lane.tracks) {
|
|
464
|
+
if (overlaps(track.start, track.end, left, right)) ids.push(track.id)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return ids
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Do the three `*-edge` kinds and their `edge` field describe a trim grab?
|
|
471
|
+
*
|
|
472
|
+
* Captions are in here deliberately, and it is load-bearing in both callers:
|
|
473
|
+
* `grabsPlayhead` lets a caption EDGE beat a playhead grab while a caption
|
|
474
|
+
* BODY yields to it, and TimelineCanvas's hover pass uses this to decide
|
|
475
|
+
* which trim handle to light up — handles the painter already draws on a
|
|
476
|
+
* selected caption. */
|
|
477
|
+
export function isEdgeHit(hit: HitResult): boolean {
|
|
478
|
+
return hit.kind === 'item-edge' || hit.kind === 'audio-edge' || hit.kind === 'caption-edge'
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Is this a hit on a clip or an audio bar (either body or edge)?
|
|
482
|
+
*
|
|
483
|
+
* Captions are deliberately NOT folded in, unlike `isEdgeHit` above. This
|
|
484
|
+
* predicate has no non-test callers, so widening it would change a documented
|
|
485
|
+
* meaning that nothing currently reads, on a guess about what a future caller
|
|
486
|
+
* wants. Ask the caption question with `isCaptionHit`; a caller that wants
|
|
487
|
+
* "anything grabbable" can OR the two, and does so knowingly. */
|
|
488
|
+
export function isItemHit(hit: HitResult): boolean {
|
|
489
|
+
return hit.kind === 'item-body' || hit.kind === 'item-edge'
|
|
490
|
+
|| hit.kind === 'audio-body' || hit.kind === 'audio-edge'
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Is this a hit on a caption block (either body or edge)? */
|
|
494
|
+
export function isCaptionHit(hit: HitResult): boolean {
|
|
495
|
+
return hit.kind === 'caption-body' || hit.kind === 'caption-edge'
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Nothing under the point but timeline: every kind that means "seek here". */
|
|
499
|
+
export function isEmptyHit(hit: HitResult): boolean {
|
|
500
|
+
return hit.kind === 'empty-row' || hit.kind === 'empty-lane' || hit.kind === 'background'
|
|
501
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyframe-strip geometry (SP9b T3.3) — the handful of pure helpers shared by
|
|
3
|
+
* the painter (`draw.ts`'s `drawKeyframeStrip`) and the hit-tester
|
|
4
|
+
* (`hit-test.ts`'s keyframe zone), so a diamond's clickable zone is always
|
|
5
|
+
* exactly where it is drawn. Mirrors why `fade-curve.ts` is its own module
|
|
6
|
+
* rather than living inside either caller: one function, two readers, no risk
|
|
7
|
+
* of the two positions drifting apart.
|
|
8
|
+
*
|
|
9
|
+
* The strip draws ONE diamond per DISTINCT keyframe TIME across ALL of an
|
|
10
|
+
* item's keyframe tracks — a merged strip, not one row per property (plan
|
|
11
|
+
* decision 2). `keyframeUnionTimes` is that union; `keyframeDiamondX` is
|
|
12
|
+
* where the diamond for one of those times lands on screen.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { VisualItem } from '../../../schema'
|
|
16
|
+
import { timeToX, type Viewport } from './viewport'
|
|
17
|
+
|
|
18
|
+
/** Edge-to-edge size (px) of a drawn diamond. Mirrors `FADE_GRIP_SIZE_PX`'s
|
|
19
|
+
* role for the fade grip: the one number both the painter and the
|
|
20
|
+
* hit-tester measure the shape by. */
|
|
21
|
+
export const KEYFRAME_DIAMOND_SIZE_PX = 8
|
|
22
|
+
|
|
23
|
+
/** Half-width (px) of a diamond's CLICKABLE zone — a little roomier than the
|
|
24
|
+
* diamond itself is drawn, the same margin `FADE_GRIP_HALF_WIDTH_PX` gives
|
|
25
|
+
* the fade grip over `FADE_GRIP_SIZE_PX`. */
|
|
26
|
+
export const KEYFRAME_HIT_HALF_WIDTH_PX = 6
|
|
27
|
+
|
|
28
|
+
/** Gap (px) between the diamond's own centre and the clip body's bottom
|
|
29
|
+
* edge, so it doesn't sit flush on the border. */
|
|
30
|
+
export const KEYFRAME_STRIP_BOTTOM_PAD_PX = 4
|
|
31
|
+
|
|
32
|
+
/** How tall the strip's clickable zone is, measured up from the BOTTOM of the
|
|
33
|
+
* row — mirrors `FADE_GRIP_ZONE_HEIGHT_PX` confining the fade grip to the
|
|
34
|
+
* bar's top. Confines diamonds to a thin band along the bottom so they never
|
|
35
|
+
* compete with the trim handles (grabbable across the row's full height) or
|
|
36
|
+
* the clip label (pinned to the top) anywhere but right at the diamond's own
|
|
37
|
+
* small target — the same "small dedicated zone wins locally, the rest of
|
|
38
|
+
* the edge is untouched" precedent `audioFadeGripZone` sets.
|
|
39
|
+
*
|
|
40
|
+
* DERIVED, not a hand-picked number, so it can never drift out of sync with
|
|
41
|
+
* where a diamond is actually drawn (`drawKeyframeStrip`'s `y = body.y +
|
|
42
|
+
* body.height - KEYFRAME_DIAMOND_SIZE_PX / 2 - KEYFRAME_STRIP_BOTTOM_PAD_PX`,
|
|
43
|
+
* with the diamond's own half-height extending `KEYFRAME_DIAMOND_SIZE_PX / 2`
|
|
44
|
+
* above and below that centre). A previous hand-picked `10` put the zone at
|
|
45
|
+
* `[bottom-10, bottom)` against a diamond actually drawn at `[bottom-12,
|
|
46
|
+
* bottom-4]` — the diamond's own top 2px fell OUTSIDE the clickable zone,
|
|
47
|
+
* while 4px of empty space below the diamond fell INSIDE it. This value is
|
|
48
|
+
* exactly `KEYFRAME_DIAMOND_SIZE_PX + KEYFRAME_STRIP_BOTTOM_PAD_PX`, which
|
|
49
|
+
* puts the zone's own top edge exactly at the diamond's top edge (both
|
|
50
|
+
* measured up from the same `bottom` reference), so the diamond's full height
|
|
51
|
+
* is inside the zone with no gap. */
|
|
52
|
+
export const KEYFRAME_STRIP_ZONE_HEIGHT_PX = KEYFRAME_DIAMOND_SIZE_PX + KEYFRAME_STRIP_BOTTOM_PAD_PX
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every DISTINCT keyframe time (item-relative seconds) across all of `item`'s
|
|
56
|
+
* keyframe tracks, ascending and de-duplicated. This IS the "union of times"
|
|
57
|
+
* plan decision 2 describes: several props sharing a `t` collapse to one
|
|
58
|
+
* diamond, so the strip stays a strip rather than becoming a curve editor.
|
|
59
|
+
*/
|
|
60
|
+
export function keyframeUnionTimes(item: VisualItem): number[] {
|
|
61
|
+
const seen = new Set<number>()
|
|
62
|
+
for (const track of item.keyframes ?? []) {
|
|
63
|
+
for (const point of track.points) seen.add(point.t)
|
|
64
|
+
}
|
|
65
|
+
return [...seen].sort((a, b) => a - b)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Screen x for the diamond at item-relative time `t` on `item` — `item.start
|
|
69
|
+
* + t` run through the SAME seconds→px conversion every other draw/hit-test
|
|
70
|
+
* call on this surface uses (plan decision 3: never invent a parallel one). */
|
|
71
|
+
export function keyframeDiamondX(item: VisualItem, t: number, viewport: Viewport): number {
|
|
72
|
+
return timeToX(item.start + t, viewport)
|
|
73
|
+
}
|