@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,818 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* SP5 T5 — hit-testing. Pure arithmetic over the painter's own layout, so the
|
|
4
|
+
* whole surface is covered here without a canvas or a browser.
|
|
5
|
+
*
|
|
6
|
+
* The fixture runs at 100px per second with no scroll, so x is exactly
|
|
7
|
+
* `t * 100`. The Y of each row is taken from the layout rather than written
|
|
8
|
+
* out — the rows are tall enough now to hold a filmstrip over a waveform, and
|
|
9
|
+
* hardcoded Y's would aim these probes at the gaps between them.
|
|
10
|
+
*
|
|
11
|
+
* row track 1 (overlays) — o0 spans x 200–400
|
|
12
|
+
* row track 0 (base, taller) — c0 x 0–500, c1 x 500–1000
|
|
13
|
+
* lane audio a0 x 100–600, the bar inset inside the lane
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from 'vitest'
|
|
16
|
+
import type { Project } from '../../../../types'
|
|
17
|
+
import { AUDIO_ITEM_INSET_PX, RULER_HEIGHT_PX, computeTimelineLayout } from '../draw'
|
|
18
|
+
import {
|
|
19
|
+
AUDIO_EDGE_TOLERANCE_PX,
|
|
20
|
+
FADE_GRIP_ZONE_HEIGHT_PX,
|
|
21
|
+
VISUAL_EDGE_TOLERANCE_PX,
|
|
22
|
+
hitTest,
|
|
23
|
+
isCaptionHit,
|
|
24
|
+
isEdgeHit,
|
|
25
|
+
isEmptyHit,
|
|
26
|
+
isItemHit,
|
|
27
|
+
itemsInRect,
|
|
28
|
+
normalizeRect,
|
|
29
|
+
} from '../hit-test'
|
|
30
|
+
import { KEYFRAME_DIAMOND_SIZE_PX, KEYFRAME_STRIP_BOTTOM_PAD_PX, KEYFRAME_STRIP_ZONE_HEIGHT_PX } from '../keyframe-strip'
|
|
31
|
+
import type { Viewport } from '../viewport'
|
|
32
|
+
import {
|
|
33
|
+
AUDIO_LANE_HEIGHT_PX,
|
|
34
|
+
BASE_VISUAL_ROW_RENDER_HEIGHT_PX,
|
|
35
|
+
ROW_GAP_PX,
|
|
36
|
+
VISUAL_ROW_RENDER_HEIGHT_PX,
|
|
37
|
+
} from '../../timeline-model'
|
|
38
|
+
|
|
39
|
+
const VIEWPORT: Viewport = { pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }
|
|
40
|
+
|
|
41
|
+
const project = {
|
|
42
|
+
id: 'p',
|
|
43
|
+
tracks: [
|
|
44
|
+
[
|
|
45
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5 },
|
|
46
|
+
{ id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10 },
|
|
47
|
+
],
|
|
48
|
+
[{ id: 'o0', type: 'overlay', start: 2, end: 4 }],
|
|
49
|
+
],
|
|
50
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
|
|
51
|
+
} as unknown as Project
|
|
52
|
+
|
|
53
|
+
const layout = computeTimelineLayout(project)
|
|
54
|
+
|
|
55
|
+
const overlayRow = layout.rows.find(r => r.trackIdx === 1)!
|
|
56
|
+
const baseRow = layout.rows.find(r => r.trackIdx === 0)!
|
|
57
|
+
const lane0 = layout.lanes[0]
|
|
58
|
+
|
|
59
|
+
const OVERLAY_Y = Math.round(overlayRow.y + overlayRow.height / 2)
|
|
60
|
+
const BASE_Y = Math.round(baseRow.y + baseRow.height / 2)
|
|
61
|
+
const LANE_Y = Math.round(lane0.y + lane0.height / 2)
|
|
62
|
+
/** Inside the gap between two rows. */
|
|
63
|
+
const ROW_GAP_Y = overlayRow.y + overlayRow.height + 1
|
|
64
|
+
/** Inside the lane but above/below the inset audio bar. */
|
|
65
|
+
const LANE_ABOVE_BAR_Y = lane0.y + 1
|
|
66
|
+
const LANE_BELOW_BAR_Y = lane0.y + lane0.height - 2
|
|
67
|
+
|
|
68
|
+
function at(x: number, y: number) {
|
|
69
|
+
return hitTest({ x, y }, layout, VIEWPORT)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('hitTest — geometry sanity', () => {
|
|
73
|
+
it('the fixture lays out where the assertions below assume', () => {
|
|
74
|
+
// Rows begin under the ruler strip, which owns the top of the surface.
|
|
75
|
+
const contentTop = RULER_HEIGHT_PX + ROW_GAP_PX
|
|
76
|
+
expect(layout.ruler).toEqual({ y: 0, height: RULER_HEIGHT_PX })
|
|
77
|
+
expect(layout.rows.map(r => [r.trackIdx, r.y, r.height])).toEqual([
|
|
78
|
+
[1, contentTop, VISUAL_ROW_RENDER_HEIGHT_PX],
|
|
79
|
+
[0, contentTop + VISUAL_ROW_RENDER_HEIGHT_PX + ROW_GAP_PX, BASE_VISUAL_ROW_RENDER_HEIGHT_PX],
|
|
80
|
+
])
|
|
81
|
+
expect(layout.lanes.map(l => [l.laneIndex, l.y, l.height])).toEqual([[baseRow.y + baseRow.height + ROW_GAP_PX, AUDIO_LANE_HEIGHT_PX]].map(([y, h]) => [0, y, h]))
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('hitTest — visual items', () => {
|
|
86
|
+
it('finds a clip body and reports its track', () => {
|
|
87
|
+
const hit = at(250, BASE_Y)
|
|
88
|
+
expect(hit.kind).toBe('item-body')
|
|
89
|
+
expect(hit.itemId).toBe('c0')
|
|
90
|
+
expect(hit.trackIdx).toBe(0)
|
|
91
|
+
expect(hit.item?.id).toBe('c0')
|
|
92
|
+
expect(hit.edge).toBeUndefined()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('finds the in and out trim handles', () => {
|
|
96
|
+
expect(at(5, BASE_Y)).toMatchObject({ kind: 'item-edge', itemId: 'c0', edge: 'in' })
|
|
97
|
+
expect(at(495, BASE_Y)).toMatchObject({ kind: 'item-edge', itemId: 'c0', edge: 'out' })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('honours the handle width exactly at its boundaries', () => {
|
|
101
|
+
// The handles are 10px wide and sit inside the clip: 0–10 and 490–500.
|
|
102
|
+
expect(at(10, BASE_Y).edge).toBe('in')
|
|
103
|
+
expect(at(10.5, BASE_Y).kind).toBe('item-body')
|
|
104
|
+
expect(at(490, BASE_Y).edge).toBe('out')
|
|
105
|
+
expect(at(489.5, BASE_Y).kind).toBe('item-body')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('takes the tolerance from options when given', () => {
|
|
109
|
+
const hit = hitTest({ x: 30, y: BASE_Y }, layout, VIEWPORT, { visualEdgeTolerancePx: 40 })
|
|
110
|
+
expect(hit).toMatchObject({ kind: 'item-edge', edge: 'in' })
|
|
111
|
+
expect(VISUAL_EDGE_TOLERANCE_PX).toBe(10)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('gives the out handle precedence when a clip is too narrow for two', () => {
|
|
115
|
+
// A 12px clip: the two 10px zones overlap, and the out handle wins — the
|
|
116
|
+
// DOM's right handle paints last and so hit-tests first.
|
|
117
|
+
const narrow = {
|
|
118
|
+
id: 'p', tracks: [[{ id: 'n0', type: 'video', src: 'n.mp4', start: 0, end: 0.12 }]],
|
|
119
|
+
} as unknown as Project
|
|
120
|
+
const narrowLayout = computeTimelineLayout(narrow)
|
|
121
|
+
const narrowY = narrowLayout.rows[0].y + 2
|
|
122
|
+
expect(hitTest({ x: 6, y: narrowY }, narrowLayout, VIEWPORT)).toMatchObject({ edge: 'out' })
|
|
123
|
+
expect(hitTest({ x: 1, y: narrowY }, narrowLayout, VIEWPORT)).toMatchObject({ edge: 'in' })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('resolves a shared boundary to the clip painted on top', () => {
|
|
127
|
+
// c0 ends and c1 starts at t=5 → x=500. The later item in the array wins,
|
|
128
|
+
// and the point lands in its in-handle.
|
|
129
|
+
expect(at(500, BASE_Y)).toMatchObject({ kind: 'item-edge', itemId: 'c1', edge: 'in' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('finds an overlay on the row above the base track', () => {
|
|
133
|
+
expect(at(300, OVERLAY_Y)).toMatchObject({ kind: 'item-body', itemId: 'o0', trackIdx: 1 })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('reports empty track area with the row it belongs to', () => {
|
|
137
|
+
expect(at(700, OVERLAY_Y)).toMatchObject({ kind: 'empty-row', trackIdx: 1 })
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('hitTest — overlapping items', () => {
|
|
142
|
+
// `a` runs 0–5 and `b` 3–9 on one track, so b covers a's whole trailing
|
|
143
|
+
// second. b is later in the array, i.e. drawn on top.
|
|
144
|
+
const overlapped = {
|
|
145
|
+
id: 'p',
|
|
146
|
+
tracks: [[
|
|
147
|
+
{ id: 'a', type: 'video', src: 'a.mp4', start: 0, end: 5 },
|
|
148
|
+
{ id: 'b', type: 'video', src: 'b.mp4', start: 3, end: 9 },
|
|
149
|
+
]],
|
|
150
|
+
audio: { tracks: [
|
|
151
|
+
{ id: 'x', src: 'x.mp3', start: 0, end: 5, lane: 0 },
|
|
152
|
+
{ id: 'y', src: 'y.mp3', start: 3, end: 9, lane: 0 },
|
|
153
|
+
] },
|
|
154
|
+
} as unknown as Project
|
|
155
|
+
const olay = computeTimelineLayout(overlapped)
|
|
156
|
+
const row = olay.rows.find(r => r.trackIdx === 0)!
|
|
157
|
+
const rowY = Math.round(row.y + row.height / 2)
|
|
158
|
+
const lane = olay.lanes[0]
|
|
159
|
+
const laneY = Math.round(lane.y + lane.height / 2)
|
|
160
|
+
const hit = (x: number, y: number) => hitTest({ x, y }, olay, VIEWPORT)
|
|
161
|
+
|
|
162
|
+
it('reaches the buried out edge of the clip underneath', () => {
|
|
163
|
+
// THE BUG: x=498 is 2px from a's end and 198px into b. Topmost-wins
|
|
164
|
+
// returned b's body and stopped, so a's trailing edge was unreachable —
|
|
165
|
+
// you could see it and not trim it.
|
|
166
|
+
const r = hit(498, rowY)
|
|
167
|
+
expect(r.kind).toBe('item-edge')
|
|
168
|
+
expect(r.itemId).toBe('a')
|
|
169
|
+
expect(r.edge).toBe('out')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('still gives the overlapping clip its own body away from any edge', () => {
|
|
173
|
+
// 6.5s: past a entirely, and 150px from either of b's edges.
|
|
174
|
+
expect(hit(650, rowY)).toMatchObject({ kind: 'item-body', itemId: 'b' })
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('gives the overlapping clip its in edge where that is the nearer one', () => {
|
|
178
|
+
// x=302 is 2px from b's start and 198px from a's end.
|
|
179
|
+
expect(hit(302, rowY)).toMatchObject({ kind: 'item-edge', itemId: 'b', edge: 'in' })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('takes the nearest edge, not the topmost, when BOTH are in range', () => {
|
|
183
|
+
// A 5px overlap puts a's end (500px) and b's start (495px) inside one
|
|
184
|
+
// another's tolerance, so every point between them is a candidate for
|
|
185
|
+
// both and the tie-break is the whole rule.
|
|
186
|
+
const tiny = {
|
|
187
|
+
id: 'p',
|
|
188
|
+
tracks: [[
|
|
189
|
+
{ id: 'a', type: 'video', src: 'a.mp4', start: 0, end: 5 },
|
|
190
|
+
{ id: 'b', type: 'video', src: 'b.mp4', start: 4.95, end: 9 },
|
|
191
|
+
]],
|
|
192
|
+
} as unknown as Project
|
|
193
|
+
const tinyLayout = computeTimelineLayout(tiny)
|
|
194
|
+
const y = Math.round(tinyLayout.rows[0].y + tinyLayout.rows[0].height / 2)
|
|
195
|
+
const probe = (x: number) => hitTest({ x, y }, tinyLayout, VIEWPORT)
|
|
196
|
+
|
|
197
|
+
// 2px from b's start, 3px from a's end.
|
|
198
|
+
expect(probe(497)).toMatchObject({ itemId: 'b', edge: 'in' })
|
|
199
|
+
// 1px from a's end, 4px from b's start — topmost would still say b.
|
|
200
|
+
expect(probe(499)).toMatchObject({ itemId: 'a', edge: 'out' })
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('falls back to the body when no edge is within tolerance', () => {
|
|
204
|
+
// 4.0s: 100px from a's end, 100px from b's start. Neither edge is close,
|
|
205
|
+
// so the topmost body wins, exactly as it always did.
|
|
206
|
+
expect(hit(400, rowY)).toMatchObject({ kind: 'item-body', itemId: 'b' })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('reaches a crossfaded audio bar\'s buried out edge too', () => {
|
|
210
|
+
// The permanent version of the same hole: bars in a lane overlap BY
|
|
211
|
+
// DESIGN, so the earlier bar's out edge was never reachable at all.
|
|
212
|
+
const r = hit(498, laneY)
|
|
213
|
+
expect(r.kind).toBe('audio-edge')
|
|
214
|
+
expect(r.itemId).toBe('x')
|
|
215
|
+
expect(r.edge).toBe('out')
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe('hitTest — audio lanes', () => {
|
|
220
|
+
it('finds a bar body and its lane', () => {
|
|
221
|
+
expect(at(300, LANE_Y)).toMatchObject({ kind: 'audio-body', itemId: 'a0', laneIdx: 0 })
|
|
222
|
+
expect(at(300, LANE_Y).track?.id).toBe('a0')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('finds the bar trim handles at the narrower audio tolerance', () => {
|
|
226
|
+
expect(AUDIO_EDGE_TOLERANCE_PX).toBe(6)
|
|
227
|
+
expect(at(103, LANE_Y)).toMatchObject({ kind: 'audio-edge', edge: 'in' })
|
|
228
|
+
expect(at(597, LANE_Y)).toMatchObject({ kind: 'audio-edge', edge: 'out' })
|
|
229
|
+
// 8px in from the start is past the 6px handle — that's bar body.
|
|
230
|
+
expect(at(108, LANE_Y).kind).toBe('audio-body')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('treats the lane inset above and below the bar as lane background', () => {
|
|
234
|
+
expect(at(300, LANE_ABOVE_BAR_Y).kind).toBe('empty-lane')
|
|
235
|
+
expect(at(300, LANE_BELOW_BAR_Y).kind).toBe('empty-lane')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('reports empty lane area', () => {
|
|
239
|
+
expect(at(800, LANE_Y)).toMatchObject({ kind: 'empty-lane', laneIdx: 0 })
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('hitTest — audio fade grips', () => {
|
|
244
|
+
// a0 spans x 100–600 (1s–6s @ 100px/s) with no fadeIn/fadeOut set, so both
|
|
245
|
+
// grips sit at the bar's own corners — see `audioFadeGripZone`'s doc for
|
|
246
|
+
// the precedence this creates against the trim edge, which claims the SAME
|
|
247
|
+
// x but the bar's FULL height.
|
|
248
|
+
const barTop = lane0.y + AUDIO_ITEM_INSET_PX
|
|
249
|
+
|
|
250
|
+
it('finds the fade-IN grip at the bar′s own left corner when no fade is set', () => {
|
|
251
|
+
expect(at(100, barTop)).toMatchObject({ kind: 'audio-fade', itemId: 'a0', side: 'in', laneIdx: 0 })
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('finds the fade-OUT grip at the bar′s own right corner when no fade is set', () => {
|
|
255
|
+
expect(at(600, barTop)).toMatchObject({ kind: 'audio-fade', itemId: 'a0', side: 'out', laneIdx: 0 })
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('takes precedence over the trim edge at the exact corner, where both zones overlap', () => {
|
|
259
|
+
// (100, barTop) is also inside the audio-edge zone (±AUDIO_EDGE_TOLERANCE_PX
|
|
260
|
+
// of the corner) — confirm the GRIP wins there, not the edge.
|
|
261
|
+
expect(at(100, barTop).kind).toBe('audio-fade')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('does NOT steal the trim edge below the grip′s small top zone — the full-height edge is still reachable further down', () => {
|
|
265
|
+
// Same x as the corner (well within AUDIO_EDGE_TOLERANCE_PX), but below
|
|
266
|
+
// FADE_GRIP_ZONE_HEIGHT_PX — resolves to the trim edge, not the grip.
|
|
267
|
+
const belowGripZone = barTop + FADE_GRIP_ZONE_HEIGHT_PX + 2
|
|
268
|
+
expect(at(100, belowGripZone)).toMatchObject({ kind: 'audio-edge', edge: 'in' })
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('leaves the bar BODY, away from either grip, alone', () => {
|
|
272
|
+
expect(at(300, barTop)).toMatchObject({ kind: 'audio-body', itemId: 'a0' })
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('moves the grip zone to the fade′s INNER edge once a fade is set, not the corner', () => {
|
|
276
|
+
const withFade = {
|
|
277
|
+
id: 'p',
|
|
278
|
+
audio: { tracks: [{ id: 'f0', src: 'v.mp3', start: 1, end: 6, lane: 0, fadeIn: 1, fadeOut: 0.5 }] },
|
|
279
|
+
} as unknown as Project
|
|
280
|
+
const fadeLayout = computeTimelineLayout(withFade)
|
|
281
|
+
const fadeLane = fadeLayout.lanes[0]
|
|
282
|
+
const fadeBarTop = fadeLane.y + AUDIO_ITEM_INSET_PX
|
|
283
|
+
const fadeAt = (x: number, y: number) => hitTest({ x, y }, fadeLayout, VIEWPORT)
|
|
284
|
+
|
|
285
|
+
// Track spans x 100–600 (1s–6s). fadeIn=1s -> inner edge at 100+100=200.
|
|
286
|
+
// fadeOut=0.5s -> inner edge at 600-50=550.
|
|
287
|
+
expect(fadeAt(200, fadeBarTop)).toMatchObject({ kind: 'audio-fade', side: 'in' })
|
|
288
|
+
expect(fadeAt(550, fadeBarTop)).toMatchObject({ kind: 'audio-fade', side: 'out' })
|
|
289
|
+
// The track's own corner is no longer a grip — it's ordinary trim-edge
|
|
290
|
+
// territory now that the grip has moved off it.
|
|
291
|
+
expect(fadeAt(100, fadeBarTop)).toMatchObject({ kind: 'audio-edge', edge: 'in' })
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
describe('hitTest — keyframe strip (SP9b T3.3)', () => {
|
|
296
|
+
// o0 (2s–4s, overlay) keyframed on offsetX at t=0, 0.5 and 1.5, and on
|
|
297
|
+
// opacity at t=0.5 ONLY — so the union of times (the merged strip, plan
|
|
298
|
+
// decision 2) is {0, 0.5, 1.5}, landing at x=200 (o0's own left edge, to
|
|
299
|
+
// probe precedence over item-edge), x=250 (a diamond TWO props share, to
|
|
300
|
+
// probe the "one diamond per shared instant" rule) and x=350 (offsetX
|
|
301
|
+
// alone) — all at 100px/s off o0's start (2s).
|
|
302
|
+
const keyframedProject = {
|
|
303
|
+
id: 'p',
|
|
304
|
+
tracks: [
|
|
305
|
+
[
|
|
306
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5 },
|
|
307
|
+
{ id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10 },
|
|
308
|
+
],
|
|
309
|
+
[{
|
|
310
|
+
id: 'o0', type: 'overlay', start: 2, end: 4,
|
|
311
|
+
keyframes: [
|
|
312
|
+
{ prop: 'offsetX', points: [{ t: 0, value: -5 }, { t: 0.5, value: 0 }, { t: 1.5, value: 10 }] },
|
|
313
|
+
{ prop: 'opacity', points: [{ t: 0.5, value: 1 }] },
|
|
314
|
+
],
|
|
315
|
+
}],
|
|
316
|
+
],
|
|
317
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
|
|
318
|
+
} as unknown as Project
|
|
319
|
+
|
|
320
|
+
const kfLayout = computeTimelineLayout(keyframedProject)
|
|
321
|
+
const kfRow = kfLayout.rows.find(r => r.trackIdx === 1)!
|
|
322
|
+
// Well inside the bottom strip zone, not at its very edge.
|
|
323
|
+
const KF_ZONE_Y = kfRow.y + kfRow.height - 2
|
|
324
|
+
|
|
325
|
+
function atKf(x: number, y: number, selectedIds: string[]) {
|
|
326
|
+
return hitTest({ x, y }, kfLayout, VIEWPORT, { selectedIds })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
it('finds a diamond at a time only ONE prop has', () => {
|
|
330
|
+
expect(atKf(350, KF_ZONE_Y, ['o0'])).toMatchObject({ kind: 'keyframe', itemId: 'o0', kfT: 1.5 })
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('finds ONE diamond at a time TWO props share', () => {
|
|
334
|
+
expect(atKf(250, KF_ZONE_Y, ['o0'])).toMatchObject({ kind: 'keyframe', itemId: 'o0', kfT: 0.5 })
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('takes precedence over the item-edge trim zone at the exact corner, where both overlap', () => {
|
|
338
|
+
// x=200 is o0's own left edge — also inside VISUAL_EDGE_TOLERANCE_PX of
|
|
339
|
+
// it — but a keyframe sits exactly there too. The diamond must win.
|
|
340
|
+
expect(atKf(200, KF_ZONE_Y, ['o0'])).toMatchObject({ kind: 'keyframe', itemId: 'o0', kfT: 0 })
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it("the clickable zone reaches all the way up to the diamond's own drawn top edge (FIX 7)", () => {
|
|
344
|
+
// The diamond is drawn KEYFRAME_DIAMOND_SIZE_PX + KEYFRAME_STRIP_BOTTOM_PAD_PX
|
|
345
|
+
// (8 + 4 = 12px) up from the row's bottom (see drawKeyframeStrip / keyframe-
|
|
346
|
+
// strip.ts's own doc). Computed independently of KEYFRAME_STRIP_ZONE_HEIGHT_PX
|
|
347
|
+
// itself, so this would have failed against the old hand-picked 10px zone
|
|
348
|
+
// height, which fell 2px short of the diamond's actual top edge.
|
|
349
|
+
const diamondTopFromBottom = KEYFRAME_DIAMOND_SIZE_PX + KEYFRAME_STRIP_BOTTOM_PAD_PX
|
|
350
|
+
const topOfDiamond = kfRow.y + kfRow.height - diamondTopFromBottom
|
|
351
|
+
expect(atKf(350, topOfDiamond, ['o0'])).toMatchObject({ kind: 'keyframe', itemId: 'o0', kfT: 1.5 })
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('an in-span keyframe still hits normally (non-regression)', () => {
|
|
355
|
+
expect(atKf(350, KF_ZONE_Y, ['o0'])).toMatchObject({ kind: 'keyframe', itemId: 'o0', kfT: 1.5 })
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('a point far from any diamond in x, but still within the strip zone in y, does not produce a keyframe hit', () => {
|
|
359
|
+
// x=380 is well inside o0's body (200-400) but 30px from the nearest
|
|
360
|
+
// diamond (x=350, t=1.5) — outside KEYFRAME_HIT_HALF_WIDTH_PX (6px). Must
|
|
361
|
+
// fall through to the ordinary item-body hit, not stay stuck on 'keyframe'.
|
|
362
|
+
expect(atKf(380, KF_ZONE_Y, ['o0'])).toMatchObject({ kind: 'item-body', itemId: 'o0' })
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('an out-of-span keyframe produces NO keyframe hit — the item-body of the clip actually drawn there wins instead', () => {
|
|
366
|
+
// o0 (2s-4s, duration 2s) carries an OUT-OF-SPAN keyframe at t=3
|
|
367
|
+
// (t > duration). Unclamped, its screen x is (o0.start + 3) * 100 = 500 —
|
|
368
|
+
// which is nowhere near o0's own drawn rect (x 200-400): it lands exactly
|
|
369
|
+
// where the NEXT item on the same track, o1 (4s-6s), is actually drawn.
|
|
370
|
+
// Before the fix, keyframeStripZone had no span filter, so o0's phantom
|
|
371
|
+
// diamond at x=500 won the hit ahead of o1's real, visible body.
|
|
372
|
+
const overlapProject = {
|
|
373
|
+
id: 'p',
|
|
374
|
+
tracks: [
|
|
375
|
+
[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10 }],
|
|
376
|
+
[
|
|
377
|
+
{
|
|
378
|
+
id: 'o0', type: 'overlay', start: 2, end: 4,
|
|
379
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }, { t: 3, value: 10 }] }],
|
|
380
|
+
},
|
|
381
|
+
{ id: 'o1', type: 'overlay', start: 4, end: 6 },
|
|
382
|
+
],
|
|
383
|
+
],
|
|
384
|
+
} as unknown as Project
|
|
385
|
+
const overlapLayout = computeTimelineLayout(overlapProject)
|
|
386
|
+
const row = overlapLayout.rows.find(r => r.trackIdx === 1)!
|
|
387
|
+
const y = row.y + row.height - 2
|
|
388
|
+
expect(hitTest({ x: 500, y }, overlapLayout, VIEWPORT, { selectedIds: ['o0'] }))
|
|
389
|
+
.toMatchObject({ kind: 'item-body', itemId: 'o1' })
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('does not steal the row away from the diamond zone — item-body still resolves above/below it', () => {
|
|
393
|
+
// Same x as a diamond, but ABOVE KEYFRAME_STRIP_ZONE_HEIGHT_PX from the
|
|
394
|
+
// row's bottom: falls through to the ordinary clip body.
|
|
395
|
+
expect(KEYFRAME_STRIP_ZONE_HEIGHT_PX).toBeGreaterThan(0)
|
|
396
|
+
const aboveStrip = kfRow.y + 2
|
|
397
|
+
expect(atKf(250, aboveStrip, ['o0'])).toMatchObject({ kind: 'item-body', itemId: 'o0' })
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('yields no keyframe hit when the overlay is NOT selected', () => {
|
|
401
|
+
expect(atKf(250, KF_ZONE_Y, [])).toMatchObject({ kind: 'item-body', itemId: 'o0' })
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('yields no keyframe hit when a DIFFERENT item is selected', () => {
|
|
405
|
+
expect(atKf(250, KF_ZONE_Y, ['c0'])).toMatchObject({ kind: 'item-body', itemId: 'o0' })
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('yields no keyframe hit on a selected but UN-keyframed overlay', () => {
|
|
409
|
+
const plain = { id: 'p', tracks: [[{ id: 'o1', type: 'overlay', start: 2, end: 4 }]] } as unknown as Project
|
|
410
|
+
const plainLayout = computeTimelineLayout(plain)
|
|
411
|
+
const row = plainLayout.rows[0]
|
|
412
|
+
const y = row.y + row.height - 2
|
|
413
|
+
expect(hitTest({ x: 250, y }, plainLayout, VIEWPORT, { selectedIds: ['o1'] })).toMatchObject({ kind: 'item-body', itemId: 'o1' })
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('YIELDS a keyframe hit on a selected, keyframed video clip (SP9d)', () => {
|
|
417
|
+
// Clips became keyframeable once the renderer could compile curves into
|
|
418
|
+
// ffmpeg expressions, so their diamonds must be grabbable like an
|
|
419
|
+
// overlay's. The gate is still `canKeyframe(item)`, not `isKeyframed`
|
|
420
|
+
// alone — the case below proves it is still a gate.
|
|
421
|
+
const videoKf = {
|
|
422
|
+
id: 'p',
|
|
423
|
+
tracks: [[{
|
|
424
|
+
id: 'c0', type: 'video', src: 'a.mp4', start: 2, end: 4,
|
|
425
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }] }],
|
|
426
|
+
}]],
|
|
427
|
+
} as unknown as Project
|
|
428
|
+
const videoLayout = computeTimelineLayout(videoKf)
|
|
429
|
+
const row = videoLayout.rows[0]
|
|
430
|
+
const y = row.y + row.height - 2
|
|
431
|
+
expect(hitTest({ x: 250, y }, videoLayout, VIEWPORT, { selectedIds: ['c0'] }).kind).toBe('keyframe')
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('still yields no keyframe hit on a kind that cannot be keyframed at all', () => {
|
|
435
|
+
const audioKf = {
|
|
436
|
+
id: 'p',
|
|
437
|
+
tracks: [[{
|
|
438
|
+
id: 'a0', type: 'audio', src: 'a.wav', start: 2, end: 4,
|
|
439
|
+
keyframes: [{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }] }],
|
|
440
|
+
}]],
|
|
441
|
+
} as unknown as Project
|
|
442
|
+
const audioLayout = computeTimelineLayout(audioKf)
|
|
443
|
+
const row = audioLayout.rows[0]
|
|
444
|
+
const y = row.y + row.height - 2
|
|
445
|
+
expect(hitTest({ x: 250, y }, audioLayout, VIEWPORT, { selectedIds: ['a0'] }).kind).not.toBe('keyframe')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('is backward compatible: omitting `selectedIds` entirely never produces a keyframe hit', () => {
|
|
449
|
+
expect(hitTest({ x: 250, y: KF_ZONE_Y }, kfLayout, VIEWPORT).kind).not.toBe('keyframe')
|
|
450
|
+
})
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
describe('hitTest — outside the rows', () => {
|
|
454
|
+
it('treats the gap between rows as background', () => {
|
|
455
|
+
expect(at(300, ROW_GAP_Y).kind).toBe('background')
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it('treats anything below the last lane as background', () => {
|
|
459
|
+
expect(at(300, 500).kind).toBe('background')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('treats negative y as the ruler, so a scrub survives drifting off the top', () => {
|
|
463
|
+
// Everything at or above the ruler's bottom edge is the ruler, including
|
|
464
|
+
// points off the surface entirely. A pointer that wanders above the strip
|
|
465
|
+
// mid-scrub is still scrubbing; reporting `background` there would drop the
|
|
466
|
+
// gesture the moment the hand rose.
|
|
467
|
+
expect(at(300, -5).kind).toBe('ruler')
|
|
468
|
+
expect(at(300, -5).t).toBeCloseTo(3)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('still reports a time for points off either end of the surface', () => {
|
|
472
|
+
expect(at(-50, BASE_Y)).toMatchObject({ kind: 'empty-row', t: -0.5 })
|
|
473
|
+
expect(at(5000, BASE_Y)).toMatchObject({ kind: 'empty-row', t: 50 })
|
|
474
|
+
})
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
describe('hitTest — time', () => {
|
|
478
|
+
it('always reports the time under x, whatever was hit', () => {
|
|
479
|
+
expect(at(250, BASE_Y).t).toBeCloseTo(2.5)
|
|
480
|
+
expect(at(250, ROW_GAP_Y).t).toBeCloseTo(2.5)
|
|
481
|
+
expect(at(250, LANE_Y).t).toBeCloseTo(2.5)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('accounts for scroll', () => {
|
|
485
|
+
const scrolled: Viewport = { ...VIEWPORT, scrollSeconds: 3 }
|
|
486
|
+
expect(hitTest({ x: 250, y: 60 }, layout, scrolled).t).toBeCloseTo(5.5)
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
describe('hitTest — predicates', () => {
|
|
491
|
+
it('classifies each kind', () => {
|
|
492
|
+
expect(isEdgeHit(at(5, BASE_Y))).toBe(true)
|
|
493
|
+
expect(isEdgeHit(at(250, BASE_Y))).toBe(false)
|
|
494
|
+
expect(isItemHit(at(250, BASE_Y))).toBe(true)
|
|
495
|
+
expect(isItemHit(at(300, LANE_Y))).toBe(true)
|
|
496
|
+
expect(isItemHit(at(700, OVERLAY_Y))).toBe(false)
|
|
497
|
+
expect(isEmptyHit(at(700, OVERLAY_Y))).toBe(true)
|
|
498
|
+
expect(isEmptyHit(at(300, ROW_GAP_Y))).toBe(true)
|
|
499
|
+
expect(isEmptyHit(at(800, LANE_Y))).toBe(true)
|
|
500
|
+
expect(isEmptyHit(at(250, BASE_Y))).toBe(false)
|
|
501
|
+
})
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
describe('hitTest — degenerate layouts', () => {
|
|
505
|
+
it('reports background for an empty project', () => {
|
|
506
|
+
const empty = computeTimelineLayout({ id: 'p' } as unknown as Project)
|
|
507
|
+
// Below the ruler: with no rows or lanes there is nothing but background.
|
|
508
|
+
expect(hitTest({ x: 100, y: RULER_HEIGHT_PX + 10 }, empty, VIEWPORT).kind).toBe('background')
|
|
509
|
+
// The ruler itself is still there, even with nothing to rule over.
|
|
510
|
+
expect(hitTest({ x: 100, y: 2 }, empty, VIEWPORT).kind).toBe('ruler')
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('survives a viewport with no scale yet', () => {
|
|
514
|
+
const unscaled: Viewport = { pxPerSecond: 0, scrollSeconds: 0, widthPx: 0 }
|
|
515
|
+
const hit = hitTest({ x: 100, y: 60 }, layout, unscaled)
|
|
516
|
+
// Every clip collapses to x=0, so a point at x=100 is past all of them.
|
|
517
|
+
expect(hit.kind).toBe('empty-row')
|
|
518
|
+
expect(hit.t).toBe(0)
|
|
519
|
+
})
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
describe('hitTest — an audio bar with no declared window', () => {
|
|
523
|
+
// `groupAudioLanes` resolves the window, so the painter, the hit-test and the
|
|
524
|
+
// pointer machine all address the same bar. Without that, this lane's
|
|
525
|
+
// x-coordinates are NaN, and NaN loses every comparison in `spanZone` — so
|
|
526
|
+
// the bar reported a body hit at EVERY x in the lane, including well past
|
|
527
|
+
// its end, and the gesture that followed did NaN arithmetic. The contract is
|
|
528
|
+
// that the bar is grabbable where it is drawn and only there. Note which
|
|
529
|
+
// assertions actually guard it: a plain body hit does NOT, because NaN bounds
|
|
530
|
+
// fall through to 'body' everywhere. The in-handle and past-the-end cases do.
|
|
531
|
+
const p = {
|
|
532
|
+
id: 'p',
|
|
533
|
+
tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10 }]],
|
|
534
|
+
audio: { tracks: [{ id: 'bed', src: 'song.mp3' }] },
|
|
535
|
+
} as unknown as Project
|
|
536
|
+
const l = computeTimelineLayout(p)
|
|
537
|
+
const lane = l.lanes[0]
|
|
538
|
+
const LANE_MID_Y = lane.y + lane.height / 2
|
|
539
|
+
|
|
540
|
+
it('a bar with no declared start/end is still grabbable', () => {
|
|
541
|
+
// Vertically centred in the lane. The fixture viewport runs at 100px/s
|
|
542
|
+
// from t=0, so x=40 is t=0.4 — past the in handle, nowhere near the out.
|
|
543
|
+
const hit = hitTest({ x: 40, y: LANE_MID_Y }, l, VIEWPORT)
|
|
544
|
+
expect(hit.kind).toBe('audio-body')
|
|
545
|
+
expect(hit.itemId).toBe('bed')
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
it('and its in-handle is where the bar actually starts', () => {
|
|
549
|
+
// This is the half that FAILS on revert. The body assertion above does not:
|
|
550
|
+
// with NaN bounds every comparison in `spanZone` is false, so it falls
|
|
551
|
+
// through to 'body' at every x and reports a body hit anyway. Only a zone
|
|
552
|
+
// that NaN cannot produce proves the window was resolved — the in handle
|
|
553
|
+
// needs `x <= x0 + tolerance` to be TRUE, which NaN never satisfies.
|
|
554
|
+
const hit = hitTest({ x: 2, y: LANE_MID_Y }, l, VIEWPORT)
|
|
555
|
+
expect(hit.kind).toBe('audio-edge')
|
|
556
|
+
expect(hit.edge).toBe('in')
|
|
557
|
+
expect(hit.itemId).toBe('bed')
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('and is not grabbable past the end of the resolved window', () => {
|
|
561
|
+
// The window resolves to the project's content duration (10s → x=1000).
|
|
562
|
+
const hit = hitTest({ x: 1200, y: LANE_MID_Y }, l, VIEWPORT)
|
|
563
|
+
expect(hit.kind).toBe('empty-lane')
|
|
564
|
+
expect(hit.itemId).toBeUndefined()
|
|
565
|
+
})
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
// ── The caption band ──────────────────────────────────────────────────────
|
|
569
|
+
// Captions hit-test exactly like clips and audio bars now: the band is a
|
|
570
|
+
// rectangle in the same layout, and its blocks resolve through the same
|
|
571
|
+
// `resolveRow`. A separate fixture so the assertions above keep the geometry
|
|
572
|
+
// they were written against.
|
|
573
|
+
describe('hitTest — the caption band', () => {
|
|
574
|
+
const captioned = {
|
|
575
|
+
...project,
|
|
576
|
+
captions: {
|
|
577
|
+
style: 'pop',
|
|
578
|
+
segments: [
|
|
579
|
+
// Deliberately NOT butted (a 1s gap): the gap is where "empty band"
|
|
580
|
+
// is probed, and it keeps each edge test aimed at one segment only.
|
|
581
|
+
{ id: 's0', text: 'one', start: 0, end: 2 },
|
|
582
|
+
{ id: 's1', text: 'two', start: 3, end: 5 },
|
|
583
|
+
// No id — before `backfillCaptionIds` has run. Draws, never hittable.
|
|
584
|
+
{ text: 'three', start: 6, end: 8 },
|
|
585
|
+
],
|
|
586
|
+
},
|
|
587
|
+
} as unknown as Project
|
|
588
|
+
|
|
589
|
+
const l = computeTimelineLayout(captioned)
|
|
590
|
+
const band = l.captions![0]
|
|
591
|
+
const BAND_Y = Math.round(band.y + band.height / 2)
|
|
592
|
+
const capBaseRow = l.rows.find(r => r.trackIdx === 0)!
|
|
593
|
+
const inBand = (x: number, y: number = BAND_Y) => hitTest({ x, y }, l, VIEWPORT)
|
|
594
|
+
|
|
595
|
+
it('sits immediately above the base video row', () => {
|
|
596
|
+
expect(band.y + band.height + ROW_GAP_PX).toBe(capBaseRow.y)
|
|
597
|
+
// …and below the overlay row, so it is genuinely between the two.
|
|
598
|
+
expect(band.y).toBeGreaterThan(l.rows.find(r => r.trackIdx === 1)!.y)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it('resolves a segment body', () => {
|
|
602
|
+
const hit = inBand(100)
|
|
603
|
+
expect(hit.kind).toBe('caption-body')
|
|
604
|
+
expect(hit.itemId).toBe('s0')
|
|
605
|
+
expect(hit.segment?.text).toBe('one')
|
|
606
|
+
expect(hit.t).toBeCloseTo(1)
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
it('resolves both trim handles', () => {
|
|
610
|
+
expect(inBand(2)).toMatchObject({ kind: 'caption-edge', itemId: 's0', edge: 'in' })
|
|
611
|
+
expect(inBand(198)).toMatchObject({ kind: 'caption-edge', itemId: 's0', edge: 'out' })
|
|
612
|
+
expect(inBand(302)).toMatchObject({ kind: 'caption-edge', itemId: 's1', edge: 'in' })
|
|
613
|
+
expect(inBand(498)).toMatchObject({ kind: 'caption-edge', itemId: 's1', edge: 'out' })
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
it('grabs handles at the AUDIO tolerance, not the visual one', () => {
|
|
617
|
+
// 7px in is body at 6px tolerance and would be the in-handle at 10px.
|
|
618
|
+
expect(AUDIO_EDGE_TOLERANCE_PX).toBeLessThan(VISUAL_EDGE_TOLERANCE_PX)
|
|
619
|
+
expect(inBand(AUDIO_EDGE_TOLERANCE_PX - 1).kind).toBe('caption-edge')
|
|
620
|
+
expect(inBand(AUDIO_EDGE_TOLERANCE_PX + 1).kind).toBe('caption-body')
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('reports background for the gaps between blocks, and past the last one', () => {
|
|
624
|
+
// Not a kind of its own: `background` already means marquee here, seek
|
|
625
|
+
// here, clear the selection — which is right for a gap in the band.
|
|
626
|
+
expect(inBand(250).kind).toBe('background')
|
|
627
|
+
expect(inBand(900).kind).toBe('background')
|
|
628
|
+
expect(isEmptyHit(inBand(250))).toBe(true)
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
it('never hits a segment that has no id yet', () => {
|
|
632
|
+
// x 600–800 is where the id-less segment is drawn.
|
|
633
|
+
expect(inBand(700).kind).toBe('background')
|
|
634
|
+
expect(inBand(602).kind).toBe('background')
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
it('hit-tests the FULL band height, inset and all', () => {
|
|
638
|
+
// The painter insets blocks by 4px top and bottom; the hit-test does not.
|
|
639
|
+
// A 40px row whose outer 8px silently missed would read as broken.
|
|
640
|
+
expect(inBand(100, band.y).kind).toBe('caption-body')
|
|
641
|
+
expect(inBand(100, band.y + band.height - 1).kind).toBe('caption-body')
|
|
642
|
+
// …and the pixel below the band belongs to the gap, not to the band.
|
|
643
|
+
expect(inBand(100, band.y + band.height).kind).not.toBe('caption-body')
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it('reports the band lane on every caption hit — a single-band project is all lane 0', () => {
|
|
647
|
+
expect(inBand(100).captionLane).toBe(0)
|
|
648
|
+
expect(inBand(2).captionLane).toBe(0)
|
|
649
|
+
// Not a caption hit, so no lane to report.
|
|
650
|
+
expect(inBand(250).captionLane).toBeUndefined()
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
it('classifies caption hits through the predicates', () => {
|
|
654
|
+
expect(isCaptionHit(inBand(100))).toBe(true)
|
|
655
|
+
expect(isCaptionHit(inBand(2))).toBe(true)
|
|
656
|
+
expect(isCaptionHit(inBand(250))).toBe(false)
|
|
657
|
+
// Load-bearing: `grabsPlayhead` and the hover-handle pass both key off this.
|
|
658
|
+
expect(isEdgeHit(inBand(2))).toBe(true)
|
|
659
|
+
expect(isEdgeHit(inBand(100))).toBe(false)
|
|
660
|
+
// Deliberately excluded — see the predicate's own comment.
|
|
661
|
+
expect(isItemHit(inBand(100))).toBe(false)
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it('leaves the rows and lanes below it exactly where they were', () => {
|
|
665
|
+
// The band pushes the base row down, so every other probe here has to keep
|
|
666
|
+
// reading its Y from the layout — this asserts the layout still resolves.
|
|
667
|
+
expect(hitTest({ x: 250, y: Math.round(capBaseRow.y + capBaseRow.height / 2) }, l, VIEWPORT))
|
|
668
|
+
.toMatchObject({ kind: 'item-body', itemId: 'c0' })
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
describe('itemsInRect', () => {
|
|
672
|
+
const caught = (r: { x: number; y: number; width: number; height: number }) =>
|
|
673
|
+
itemsInRect(r, l, VIEWPORT).sort()
|
|
674
|
+
|
|
675
|
+
it('catches caption ids when the box crosses the band', () => {
|
|
676
|
+
const rect = normalizeRect({ x: 100, y: band.y }, { x: 350, y: band.y + band.height })
|
|
677
|
+
expect(caught(rect)).toEqual(['s0', 's1'])
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it('skips id-less segments the box crosses', () => {
|
|
681
|
+
const rect = normalizeRect({ x: 550, y: band.y }, { x: 900, y: band.y + band.height })
|
|
682
|
+
expect(caught(rect)).toEqual([])
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it('catches captions alongside clips and bars in one tall box', () => {
|
|
686
|
+
const rect = normalizeRect(
|
|
687
|
+
{ x: 98, y: l.rows.find(r => r.trackIdx === 1)!.y },
|
|
688
|
+
{ x: 104, y: l.lanes[0].y + l.lanes[0].height },
|
|
689
|
+
)
|
|
690
|
+
// t=1: s0 (0–2), c0 (0–5) and a0 (1–6) are live; o0 (2–4) is not.
|
|
691
|
+
expect(caught(rect)).toEqual(['a0', 'c0', 's0'])
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it('catches nothing from the band when the box stays clear of it', () => {
|
|
695
|
+
const rect = normalizeRect({ x: 0, y: capBaseRow.y }, { x: 1000, y: capBaseRow.y + 4 })
|
|
696
|
+
expect(caught(rect)).toEqual(['c0', 'c1'])
|
|
697
|
+
})
|
|
698
|
+
})
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
// ── Caption bands on more than one lane ────────────────────────────────────
|
|
702
|
+
// Bands are emitted in DESCENDING lane order, so lane 0 sits lowest (adjacent
|
|
703
|
+
// to the base video row) and higher lanes stack upward. A hit has to name the
|
|
704
|
+
// band it landed in, because that is where a vertical drag measures its lane
|
|
705
|
+
// delta from.
|
|
706
|
+
describe('hitTest — captions across several lanes', () => {
|
|
707
|
+
const laned = {
|
|
708
|
+
...project,
|
|
709
|
+
captions: {
|
|
710
|
+
style: 'pop',
|
|
711
|
+
segments: [
|
|
712
|
+
{ id: 'l0', text: 'ground', start: 0, end: 2 }, // lane 0 (absent ⇒ 0)
|
|
713
|
+
{ id: 'l1', text: 'middle', start: 0, end: 2, lane: 1 }, // same span, one row up
|
|
714
|
+
{ id: 'l2', text: 'top', start: 3, end: 5, lane: 2 },
|
|
715
|
+
],
|
|
716
|
+
},
|
|
717
|
+
} as unknown as Project
|
|
718
|
+
|
|
719
|
+
const l = computeTimelineLayout(laned)
|
|
720
|
+
const bandFor = (lane: number) => l.captions!.find(b => b.lane === lane)!
|
|
721
|
+
const midY = (lane: number) => Math.round(bandFor(lane).y + bandFor(lane).height / 2)
|
|
722
|
+
const hit = (x: number, lane: number) => hitTest({ x, y: midY(lane) }, l, VIEWPORT)
|
|
723
|
+
|
|
724
|
+
it('stacks lane 0 lowest and higher lanes above it', () => {
|
|
725
|
+
expect(l.captions!.map(b => b.lane)).toEqual([2, 1, 0])
|
|
726
|
+
expect(bandFor(2).y).toBeLessThan(bandFor(1).y)
|
|
727
|
+
expect(bandFor(1).y).toBeLessThan(bandFor(0).y)
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
it('resolves each band to its own segment, and reports its lane', () => {
|
|
731
|
+
expect(hit(100, 0)).toMatchObject({ kind: 'caption-body', itemId: 'l0', captionLane: 0 })
|
|
732
|
+
expect(hit(100, 1)).toMatchObject({ kind: 'caption-body', itemId: 'l1', captionLane: 1 })
|
|
733
|
+
expect(hit(400, 2)).toMatchObject({ kind: 'caption-body', itemId: 'l2', captionLane: 2 })
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
it('does not let one lane catch another lane\'s segment at the same x', () => {
|
|
737
|
+
// l0 and l1 share the span 0–2s exactly. Only the band under the point
|
|
738
|
+
// answers; the identically-placed segment one row up is not reachable
|
|
739
|
+
// from lane 0's band.
|
|
740
|
+
expect(hit(100, 0).itemId).toBe('l0')
|
|
741
|
+
expect(hit(400, 0).kind).toBe('background') // l2's span, but l2 is on lane 2
|
|
742
|
+
expect(hit(100, 2).kind).toBe('background') // l0/l1's span, but they are lower
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
it('reports the lane on an EDGE hit too', () => {
|
|
746
|
+
expect(hit(2, 1)).toMatchObject({ kind: 'caption-edge', itemId: 'l1', edge: 'in', captionLane: 1 })
|
|
747
|
+
expect(hit(198, 1)).toMatchObject({ kind: 'caption-edge', itemId: 'l1', edge: 'out', captionLane: 1 })
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
it('never reports a caption lane through `laneIdx`, which is the AUDIO index', () => {
|
|
751
|
+
// Load-bearing: `laneIdx` is fed to `tieredBoundaries`, which ranks THAT
|
|
752
|
+
// audio lane's boundaries strong. A caption on lane 0 must not hand a drag
|
|
753
|
+
// audio lane 0's magnets.
|
|
754
|
+
expect(hit(100, 1).laneIdx).toBeUndefined()
|
|
755
|
+
expect(hit(100, 0).laneIdx).toBeUndefined()
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it('still finds the rows and lanes pushed down by the extra bands', () => {
|
|
759
|
+
const pushedBase = l.rows.find(r => r.trackIdx === 0)!
|
|
760
|
+
expect(pushedBase.y).toBeGreaterThan(baseRow.y)
|
|
761
|
+
expect(hitTest({ x: 250, y: Math.round(pushedBase.y + pushedBase.height / 2) }, l, VIEWPORT))
|
|
762
|
+
.toMatchObject({ kind: 'item-body', itemId: 'c0' })
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
it('a marquee crossing several bands catches segments from every one of them', () => {
|
|
766
|
+
const rect = normalizeRect({ x: 50, y: bandFor(2).y }, { x: 450, y: bandFor(0).y + bandFor(0).height })
|
|
767
|
+
expect(itemsInRect(rect, l, VIEWPORT).sort()).toEqual(['l0', 'l1', 'l2'])
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it('a marquee inside ONE band catches only that band', () => {
|
|
771
|
+
const rect = normalizeRect({ x: 50, y: bandFor(1).y }, { x: 450, y: bandFor(1).y + bandFor(1).height })
|
|
772
|
+
expect(itemsInRect(rect, l, VIEWPORT).sort()).toEqual(['l1'])
|
|
773
|
+
})
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
// ── itemsInRect: what a marquee box catches ────────────────────────────────
|
|
777
|
+
// The one piece the marquee has always leaned on but never tested directly.
|
|
778
|
+
// Intersection (touch), not containment; rows in surface space, spans in time.
|
|
779
|
+
describe('itemsInRect', () => {
|
|
780
|
+
const sorted = (r: { x: number; y: number; width: number; height: number }) =>
|
|
781
|
+
itemsInRect(r, layout, VIEWPORT).sort()
|
|
782
|
+
|
|
783
|
+
it('catches every clip a horizontal box crosses within one row', () => {
|
|
784
|
+
// A box dragged LEFT from the empty tail across the base row, kept in that
|
|
785
|
+
// row's own y-band: the reachable way to marquee a fully-tiled row.
|
|
786
|
+
const rect = normalizeRect({ x: 800, y: BASE_Y }, { x: 300, y: BASE_Y + 4 })
|
|
787
|
+
expect(sorted(rect)).toEqual(['c0', 'c1'])
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
it('selects a clip a box lands fully INSIDE — touch, not containment', () => {
|
|
791
|
+
// x 600–700 is wholly within c1 (x 500–1000) and reaches neither edge.
|
|
792
|
+
const rect = normalizeRect({ x: 600, y: BASE_Y }, { x: 700, y: BASE_Y + 4 })
|
|
793
|
+
expect(sorted(rect)).toEqual(['c1'])
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('a tall thin box down one instant selects across every row at that time', () => {
|
|
797
|
+
// At t=3 (x=300): o0 (2–4), c0 (0–5) and a0 (1–6) all live.
|
|
798
|
+
const rect = normalizeRect(
|
|
799
|
+
{ x: 298, y: overlayRow.y },
|
|
800
|
+
{ x: 304, y: lane0.y + lane0.height },
|
|
801
|
+
)
|
|
802
|
+
expect(sorted(rect)).toEqual(['a0', 'c0', 'o0'])
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it('selects nothing when the box stays entirely below every row', () => {
|
|
806
|
+
const rect = normalizeRect(
|
|
807
|
+
{ x: 0, y: lane0.y + lane0.height + 20 },
|
|
808
|
+
{ x: 1000, y: lane0.y + lane0.height + 60 },
|
|
809
|
+
)
|
|
810
|
+
expect(sorted(rect)).toEqual([])
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it('selects nothing when the box misses every clip in time', () => {
|
|
814
|
+
// Past the last clip (x 1050–1200 → t 10.5–12) but within the base row band.
|
|
815
|
+
const rect = normalizeRect({ x: 1050, y: BASE_Y }, { x: 1200, y: BASE_Y + 4 })
|
|
816
|
+
expect(sorted(rect)).toEqual([])
|
|
817
|
+
})
|
|
818
|
+
})
|