@bycrux/editor 0.12.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas-timeline test helpers — select a clip, drag a clip, focus the surface.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this exists ──────────────────────────────────────────────────────
|
|
5
|
+
* The DOM timeline gave every clip an element, so a test could say
|
|
6
|
+
* `fireEvent.click(await screen.findByText('▪ video'))` and be done. The canvas
|
|
7
|
+
* has ONE surface and no per-clip nodes: the address of a clip is a
|
|
8
|
+
* (clientX, clientY) pair, and computing it means composing the same three
|
|
9
|
+
* things the production code composes — `computeTimelineLayout` for the row's
|
|
10
|
+
* `y`, `timeToX` through a `Viewport` for the item's `x`, and the surface's own
|
|
11
|
+
* offset from the page.
|
|
12
|
+
*
|
|
13
|
+
* Four test files already do that by hand (Timeline.backgroundClick /
|
|
14
|
+
* .fadeCurveMenu / .crossfade / .keyframeMenu). This is that work factored out
|
|
15
|
+
* so the DOM-era tests can migrate mechanically rather than each re-deriving
|
|
16
|
+
* the math.
|
|
17
|
+
*
|
|
18
|
+
* ── The two pinned constants, and nothing else ───────────────────────────
|
|
19
|
+
* jsdom lays every element out at 0×0, so a `clientX → item` hit-test would
|
|
20
|
+
* resolve nothing at all. `installCanvasHarness` pins the canvas surface to a
|
|
21
|
+
* real rect — `SURFACE_LEFT` from the left of the page, `SURFACE_WIDTH` wide —
|
|
22
|
+
* and that rect is the ONLY hardcoded geometry here. Everything else (px per
|
|
23
|
+
* second, row `y`, item `x`) is read out of the production math, so a change to
|
|
24
|
+
* a row height or the fit rule moves these helpers with it instead of
|
|
25
|
+
* silently mis-aiming.
|
|
26
|
+
*
|
|
27
|
+
* The scale therefore follows from the project: the surface fits the whole
|
|
28
|
+
* timeline on mount (`withSurfaceWidth` → `fitViewport`), and "the whole
|
|
29
|
+
* timeline" is `computeDerivedTiming(project).totalDuration` — content duration
|
|
30
|
+
* PLUS the drag headroom (`+ max(5, content * 0.2)`), not the content duration.
|
|
31
|
+
* A 4s project therefore fits 9s across 1000px, not 4s. Never assume a round
|
|
32
|
+
* px/second; call `canvasViewport`.
|
|
33
|
+
*
|
|
34
|
+
* ── Not a test file ──────────────────────────────────────────────────────
|
|
35
|
+
* Vitest collects `src/**\/*.{test,spec}.{ts,tsx}` (vitest.config.ts), so the
|
|
36
|
+
* leading underscore is cosmetic — what keeps this out of the suite is that it
|
|
37
|
+
* is neither `.test.` nor `.spec.`. Its own coverage lives in
|
|
38
|
+
* `_canvasSelect.test.tsx`.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { act, fireEvent } from '@testing-library/react'
|
|
42
|
+
import type { Project } from '../../../types'
|
|
43
|
+
import { computeTimelineLayout } from '../canvas/draw'
|
|
44
|
+
import { AUDIO_EDGE_TOLERANCE_PX, PLAYHEAD_GRAB_PX, VISUAL_EDGE_TOLERANCE_PX } from '../canvas/hit-test'
|
|
45
|
+
import { fitPxPerSecond, timeToX, type Viewport } from '../canvas/viewport'
|
|
46
|
+
import { computeDerivedTiming } from '../timeline-model'
|
|
47
|
+
import { DRAG_THRESHOLD_PX } from '../useItemDragDrop'
|
|
48
|
+
|
|
49
|
+
/** How far the canvas surface sits from the left of the page. Non-zero on
|
|
50
|
+
* purpose: the real surface is inset by the track rail, and a helper that
|
|
51
|
+
* pinned it at 0 would let an off-by-the-rail bug pass. */
|
|
52
|
+
export const SURFACE_LEFT = 100
|
|
53
|
+
|
|
54
|
+
/** The surface's width, and therefore the denominator of the fit zoom. */
|
|
55
|
+
export const SURFACE_WIDTH = 1000
|
|
56
|
+
|
|
57
|
+
/** The surface's height. Only `observeSurface` reads it (for the backing
|
|
58
|
+
* store); no hit-test consults it, so it just has to be non-degenerate. */
|
|
59
|
+
export const SURFACE_HEIGHT = 200
|
|
60
|
+
|
|
61
|
+
// ── Harness ──────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Stub `getContext` and `getBoundingClientRect` for a canvas-mode test, and
|
|
65
|
+
* hand back the teardown.
|
|
66
|
+
*
|
|
67
|
+
* Typical use:
|
|
68
|
+
*
|
|
69
|
+
* let uninstall: () => void
|
|
70
|
+
* beforeEach(() => { uninstall = installCanvasHarness() })
|
|
71
|
+
* afterEach(() => { cleanup(); uninstall() })
|
|
72
|
+
*
|
|
73
|
+
* `getContext` returns a Proxy that no-ops every 2D call — the painter runs for
|
|
74
|
+
* real, it just draws nowhere. `createLinearGradient` is special-cased because
|
|
75
|
+
* the painter calls `.addColorStop` on what it returns.
|
|
76
|
+
*/
|
|
77
|
+
export function installCanvasHarness(): () => void {
|
|
78
|
+
const realGetContext = HTMLCanvasElement.prototype.getContext
|
|
79
|
+
const realGetRect = Element.prototype.getBoundingClientRect
|
|
80
|
+
|
|
81
|
+
HTMLCanvasElement.prototype.getContext = (() =>
|
|
82
|
+
new Proxy({}, {
|
|
83
|
+
get(_t, prop: string) {
|
|
84
|
+
if (prop === 'createLinearGradient') return () => ({ addColorStop: () => {} })
|
|
85
|
+
return () => {}
|
|
86
|
+
},
|
|
87
|
+
set() { return true },
|
|
88
|
+
})) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
89
|
+
|
|
90
|
+
// The canvas surface sits inset from the left of the column — that gap is the
|
|
91
|
+
// track rail, which is why a click at clientX < SURFACE_LEFT has no time.
|
|
92
|
+
// Everything else gets a rect that spans the whole column, so a test that
|
|
93
|
+
// measures some other element doesn't get a 0×0 back.
|
|
94
|
+
Element.prototype.getBoundingClientRect = function (this: Element) {
|
|
95
|
+
const isSurface = (this as HTMLElement).hasAttribute?.('data-timeline-canvas')
|
|
96
|
+
const left = isSurface ? SURFACE_LEFT : 0
|
|
97
|
+
const width = isSurface ? SURFACE_WIDTH : SURFACE_LEFT + SURFACE_WIDTH
|
|
98
|
+
return {
|
|
99
|
+
x: left, y: 0, top: 0, left, right: left + width, bottom: SURFACE_HEIGHT,
|
|
100
|
+
width, height: SURFACE_HEIGHT, toJSON: () => ({}),
|
|
101
|
+
} as DOMRect
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
HTMLCanvasElement.prototype.getContext = realGetContext
|
|
106
|
+
Element.prototype.getBoundingClientRect = realGetRect
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The canvas surface node — the element every pointer gesture is dispatched
|
|
111
|
+
* on, and the one `installCanvasHarness` gives a real rect to. */
|
|
112
|
+
export function canvasSurface(container: HTMLElement): HTMLElement {
|
|
113
|
+
const surface = container.querySelector('[data-timeline-canvas]')
|
|
114
|
+
if (!surface) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'[_canvasSelect] no [data-timeline-canvas] in the container — is the timeline mounted?',
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
return surface as HTMLElement
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Focus the element Timeline's own Delete/Enter bindings are scoped to.
|
|
124
|
+
*
|
|
125
|
+
* Those bindings only fire with focus inside Timeline's `tabIndex={0}` root
|
|
126
|
+
* (see Timeline.tsx), and the canvas surface itself is `tabIndex={-1}`, so
|
|
127
|
+
* this walks up to the root rather than focusing the surface. A real press
|
|
128
|
+
* does the same thing from inside `TimelineCanvas`'s own `down` handler; this
|
|
129
|
+
* is for tests that need the focus WITHOUT a press (e.g. a selection made
|
|
130
|
+
* through the preview).
|
|
131
|
+
*/
|
|
132
|
+
export function focusCanvasRoot(container: HTMLElement): void {
|
|
133
|
+
const surface = canvasSurface(container)
|
|
134
|
+
const root = (surface.closest('[tabindex="0"]') as HTMLElement | null) ?? surface
|
|
135
|
+
root.focus()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Coordinates ──────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The viewport a freshly mounted surface settles to: fitted to the whole
|
|
142
|
+
* timeline, scrolled to 0.
|
|
143
|
+
*
|
|
144
|
+
* This mirrors what `TimelineCanvas` actually does on mount — `observeSurface`
|
|
145
|
+
* fires once synchronously with the stubbed rect, and `withSurfaceWidth` on a
|
|
146
|
+
* zero-scale viewport falls through to `fitViewport`. It is only valid for a
|
|
147
|
+
* surface nobody has zoomed or scrolled since; a test that clicks "Zoom in"
|
|
148
|
+
* first must compute its own.
|
|
149
|
+
*/
|
|
150
|
+
export function canvasViewport(project: Project): Viewport {
|
|
151
|
+
const { totalDuration } = computeDerivedTiming(project)
|
|
152
|
+
return {
|
|
153
|
+
pxPerSecond: fitPxPerSecond(SURFACE_WIDTH, totalDuration),
|
|
154
|
+
scrollSeconds: 0,
|
|
155
|
+
widthPx: SURFACE_WIDTH,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Page x for a timeline time, on a freshly mounted surface. */
|
|
160
|
+
export function timeToClientX(project: Project, t: number): number {
|
|
161
|
+
return SURFACE_LEFT + timeToX(t, canvasViewport(project))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** How a caller names the thing to press. `{ type }` picks the FIRST visual
|
|
165
|
+
* item of that kind in draw order (topmost row first), which is what
|
|
166
|
+
* `findByText('▪ video')` used to resolve to; `{ id }` addresses one item
|
|
167
|
+
* exactly, and reaches audio bars and caption blocks too. */
|
|
168
|
+
export type CanvasItemSelector = { id: string } | { type: string }
|
|
169
|
+
|
|
170
|
+
/** A resolved target's span in time and its band on the surface. */
|
|
171
|
+
export interface CanvasTarget {
|
|
172
|
+
id: string
|
|
173
|
+
start: number
|
|
174
|
+
end: number
|
|
175
|
+
/** Top of the row / lane / caption band the target lives in. */
|
|
176
|
+
y: number
|
|
177
|
+
height: number
|
|
178
|
+
/** The trim-handle width to stay clear of when picking a press point. */
|
|
179
|
+
edgeTolerancePx: number
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Where `selector` sits on a freshly mounted surface. Throws (loudly, with the
|
|
183
|
+
* ids that ARE there) rather than returning null — a mis-typed id in a test
|
|
184
|
+
* should read as a broken test, not as a gesture that quietly did nothing. */
|
|
185
|
+
export function resolveCanvasTarget(project: Project, selector: CanvasItemSelector): CanvasTarget {
|
|
186
|
+
const layout = computeTimelineLayout(project)
|
|
187
|
+
const known: string[] = []
|
|
188
|
+
|
|
189
|
+
for (const row of layout.rows) {
|
|
190
|
+
for (const item of row.items) {
|
|
191
|
+
known.push(item.id)
|
|
192
|
+
const match = 'id' in selector ? item.id === selector.id : item.type === selector.type
|
|
193
|
+
if (match) {
|
|
194
|
+
return {
|
|
195
|
+
id: item.id, start: item.start, end: item.end,
|
|
196
|
+
y: row.y, height: row.height, edgeTolerancePx: VISUAL_EDGE_TOLERANCE_PX,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Audio bars and caption blocks are addressable by id only — neither carries
|
|
203
|
+
// a `type` field, and the DOM helpers this replaces never selected one.
|
|
204
|
+
if ('id' in selector) {
|
|
205
|
+
for (const lane of layout.lanes) {
|
|
206
|
+
for (const track of lane.tracks) {
|
|
207
|
+
known.push(track.id)
|
|
208
|
+
if (track.id === selector.id) {
|
|
209
|
+
return {
|
|
210
|
+
id: track.id, start: track.start, end: track.end,
|
|
211
|
+
y: lane.y, height: lane.height, edgeTolerancePx: AUDIO_EDGE_TOLERANCE_PX,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const band of layout.captions ?? []) {
|
|
217
|
+
for (const seg of band.segments) {
|
|
218
|
+
if (typeof seg.id !== 'string') continue
|
|
219
|
+
known.push(seg.id)
|
|
220
|
+
if (seg.id === selector.id) {
|
|
221
|
+
return {
|
|
222
|
+
id: seg.id, start: seg.start, end: seg.end,
|
|
223
|
+
y: band.y, height: band.height, edgeTolerancePx: AUDIO_EDGE_TOLERANCE_PX,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const asked = 'id' in selector ? `id ${JSON.stringify(selector.id)}` : `type ${JSON.stringify(selector.type)}`
|
|
231
|
+
throw new Error(`[_canvasSelect] no timeline item matching ${asked}. Ids in this project: ${known.join(', ') || '(none)'}`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface CanvasPointOptions {
|
|
235
|
+
/** Press at this absolute timeline time instead of the target's middle. */
|
|
236
|
+
at?: number
|
|
237
|
+
/** Where the playhead is, so the press point can stay clear of its grab
|
|
238
|
+
* band. Defaults to 0, which is where every mounted test starts. */
|
|
239
|
+
playheadTime?: number
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A page point that lands on the target's BODY — clear of both trim handles,
|
|
244
|
+
* and clear of the playhead's grab band.
|
|
245
|
+
*
|
|
246
|
+
* The playhead dodge is not fussiness. `grabsPlayhead` gives the playhead
|
|
247
|
+
* priority over a clip body, so a press within `PLAYHEAD_GRAB_PX` of the red
|
|
248
|
+
* line starts a SCRUB and never selects anything. A clip that starts at t=0
|
|
249
|
+
* with the playhead parked at 0 is the common case for these fixtures, so the
|
|
250
|
+
* dodge earns its keep whenever the item is short enough for its middle to
|
|
251
|
+
* fall inside the band.
|
|
252
|
+
*/
|
|
253
|
+
export function canvasItemPoint(
|
|
254
|
+
project: Project,
|
|
255
|
+
selector: CanvasItemSelector,
|
|
256
|
+
opts: CanvasPointOptions = {},
|
|
257
|
+
): { clientX: number; clientY: number } {
|
|
258
|
+
const target = resolveCanvasTarget(project, selector)
|
|
259
|
+
const viewport = canvasViewport(project)
|
|
260
|
+
|
|
261
|
+
const clientY = target.y + target.height / 2
|
|
262
|
+
|
|
263
|
+
if (opts.at !== undefined) {
|
|
264
|
+
return { clientX: SURFACE_LEFT + timeToX(opts.at, viewport), clientY }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const x0 = timeToX(target.start, viewport)
|
|
268
|
+
const x1 = timeToX(target.end, viewport)
|
|
269
|
+
// Inside both handles by a pixel. A degenerate span (a clip narrower than
|
|
270
|
+
// two handles) leaves no body at all, in which case the middle is the least
|
|
271
|
+
// wrong answer and the caller gets an edge hit — visible immediately as a
|
|
272
|
+
// trim rather than a selection.
|
|
273
|
+
const lo = x0 + target.edgeTolerancePx + 1
|
|
274
|
+
const hi = x1 - target.edgeTolerancePx - 1
|
|
275
|
+
const middle = (x0 + x1) / 2
|
|
276
|
+
let x = lo <= hi ? Math.min(Math.max(middle, lo), hi) : middle
|
|
277
|
+
|
|
278
|
+
const playheadX = timeToX(opts.playheadTime ?? 0, viewport)
|
|
279
|
+
if (lo <= hi && Math.abs(x - playheadX) <= PLAYHEAD_GRAB_PX) {
|
|
280
|
+
const right = playheadX + PLAYHEAD_GRAB_PX + 1
|
|
281
|
+
const left = playheadX - PLAYHEAD_GRAB_PX - 1
|
|
282
|
+
if (right <= hi) x = right
|
|
283
|
+
else if (left >= lo) x = left
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { clientX: SURFACE_LEFT + x, clientY }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Gestures ─────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
/** Named exactly as the DOM event fields are, so a migrated call site keeps
|
|
292
|
+
* the modifier flags it already had (`{ metaKey: true }`). */
|
|
293
|
+
export interface CanvasModifiers {
|
|
294
|
+
metaKey?: boolean
|
|
295
|
+
ctrlKey?: boolean
|
|
296
|
+
shiftKey?: boolean
|
|
297
|
+
altKey?: boolean
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function mouseInit(point: { clientX: number; clientY: number }, mods: CanvasModifiers) {
|
|
301
|
+
return {
|
|
302
|
+
clientX: point.clientX,
|
|
303
|
+
clientY: point.clientY,
|
|
304
|
+
metaKey: mods.metaKey ?? false,
|
|
305
|
+
ctrlKey: mods.ctrlKey ?? false,
|
|
306
|
+
shiftKey: mods.shiftKey ?? false,
|
|
307
|
+
altKey: mods.altKey ?? false,
|
|
308
|
+
button: 0,
|
|
309
|
+
bubbles: true,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** `TimelineCanvas` attaches move/up to `document` for the life of a gesture
|
|
314
|
+
* (the DOM drag hooks' pattern), so those two go straight to `document`
|
|
315
|
+
* rather than through the surface. `act` is explicit here because
|
|
316
|
+
* `document.dispatchEvent` is not one of RTL's own wrapped fire helpers. */
|
|
317
|
+
function dispatchOnDocument(type: 'mousemove' | 'mouseup', init: ReturnType<typeof mouseInit>): void {
|
|
318
|
+
act(() => { document.dispatchEvent(new MouseEvent(type, init)) })
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface CanvasSelectOptions extends CanvasPointOptions, CanvasModifiers {}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Click a timeline item on the canvas — the canvas-native replacement for
|
|
325
|
+
* `fireEvent.click(await screen.findByText('▪ video'))`.
|
|
326
|
+
*
|
|
327
|
+
* Press and release with no movement in between, which is exactly what the
|
|
328
|
+
* pointer machine reads as a click: selection happens on RELEASE from the
|
|
329
|
+
* `pressed` state, and only for a press that never crossed
|
|
330
|
+
* `DRAG_THRESHOLD_PX`. The modifier semantics are the machine's own
|
|
331
|
+
* `isAdditive` — shift OR meta OR ctrl extends the selection, a bare click
|
|
332
|
+
* replaces it — so `{ metaKey: true }` here means what it meant on the DOM
|
|
333
|
+
* block.
|
|
334
|
+
*/
|
|
335
|
+
export function selectCanvasItem(
|
|
336
|
+
container: HTMLElement,
|
|
337
|
+
project: Project,
|
|
338
|
+
selector: CanvasItemSelector,
|
|
339
|
+
opts: CanvasSelectOptions = {},
|
|
340
|
+
): void {
|
|
341
|
+
const surface = canvasSurface(container)
|
|
342
|
+
const point = canvasItemPoint(project, selector, opts)
|
|
343
|
+
const init = mouseInit(point, opts)
|
|
344
|
+
fireEvent.mouseDown(surface, init)
|
|
345
|
+
dispatchOnDocument('mouseup', init)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export interface CanvasDragOptions extends CanvasModifiers {
|
|
349
|
+
/** Absolute timeline time to press at. Defaults to the target's body point. */
|
|
350
|
+
fromTime?: number
|
|
351
|
+
/** Absolute timeline time to release at. Mutually exclusive with `dxPx`. */
|
|
352
|
+
toTime?: number
|
|
353
|
+
/** Horizontal travel in page pixels, instead of `toTime`. */
|
|
354
|
+
dxPx?: number
|
|
355
|
+
/** Vertical travel in page pixels — a cross-track move. Defaults to 0. */
|
|
356
|
+
dyPx?: number
|
|
357
|
+
/** How many intermediate `mousemove`s to send. Defaults to 5. */
|
|
358
|
+
steps?: number
|
|
359
|
+
/** Where the playhead is, so the press point stays clear of its grab band. */
|
|
360
|
+
playheadTime?: number
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Drag a timeline item across the canvas: press, travel in `steps` moves,
|
|
365
|
+
* release.
|
|
366
|
+
*
|
|
367
|
+
* The intermediate moves are not decoration. Each one drives a fresh
|
|
368
|
+
* `projectChange` (a live, uncommitted edit) and only the RELEASE emits the
|
|
369
|
+
* single `commit` — which is exactly the property "a whole drag undoes in one
|
|
370
|
+
* step, not one step per mousemove" tests, so collapsing this to one move
|
|
371
|
+
* would stop testing the thing.
|
|
372
|
+
*
|
|
373
|
+
* Throws when the requested travel wouldn't cross `DRAG_THRESHOLD_PX`: below
|
|
374
|
+
* it the machine keeps the gesture a click, and a test that meant to drag
|
|
375
|
+
* would silently assert against a selection instead.
|
|
376
|
+
*/
|
|
377
|
+
export function dragCanvasItem(
|
|
378
|
+
container: HTMLElement,
|
|
379
|
+
project: Project,
|
|
380
|
+
selector: CanvasItemSelector,
|
|
381
|
+
opts: CanvasDragOptions,
|
|
382
|
+
): void {
|
|
383
|
+
const surface = canvasSurface(container)
|
|
384
|
+
const from = canvasItemPoint(project, selector, {
|
|
385
|
+
at: opts.fromTime,
|
|
386
|
+
playheadTime: opts.playheadTime,
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
if (opts.toTime === undefined && opts.dxPx === undefined) {
|
|
390
|
+
throw new Error('[_canvasSelect] dragCanvasItem needs either `toTime` or `dxPx`')
|
|
391
|
+
}
|
|
392
|
+
const toX = opts.toTime !== undefined
|
|
393
|
+
? timeToClientX(project, opts.toTime)
|
|
394
|
+
: from.clientX + (opts.dxPx as number)
|
|
395
|
+
const toY = from.clientY + (opts.dyPx ?? 0)
|
|
396
|
+
|
|
397
|
+
const dx = toX - from.clientX
|
|
398
|
+
const dy = toY - from.clientY
|
|
399
|
+
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`[_canvasSelect] a ${Math.hypot(dx, dy).toFixed(1)}px drag never crosses DRAG_THRESHOLD_PX `
|
|
402
|
+
+ `(${DRAG_THRESHOLD_PX}) — the machine would keep this a click. Ask for more travel, `
|
|
403
|
+
+ 'or use selectCanvasItem if a click is what you meant.',
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const steps = Math.max(1, opts.steps ?? 5)
|
|
408
|
+
fireEvent.mouseDown(surface, mouseInit(from, opts))
|
|
409
|
+
for (let i = 1; i <= steps; i++) {
|
|
410
|
+
const at = { clientX: from.clientX + (dx * i) / steps, clientY: from.clientY + (dy * i) / steps }
|
|
411
|
+
dispatchOnDocument('mousemove', mouseInit(at, opts))
|
|
412
|
+
}
|
|
413
|
+
dispatchOnDocument('mouseup', mouseInit({ clientX: toX, clientY: toY }, opts))
|
|
414
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* SP5 T5 — the drag/resize arithmetic extracted out of `useItemDragDrop` so the
|
|
4
|
+
* canvas pointer machine can reuse it instead of reimplementing trims.
|
|
5
|
+
*
|
|
6
|
+
* The hook's own suite (`useItemDragDrop.test.ts`), which drove the DOM shell,
|
|
7
|
+
* is retired along with that shell — this file is now the only coverage for
|
|
8
|
+
* this arithmetic, including the cases the shell couldn't easily reach
|
|
9
|
+
* (source-media clamps, tie-breaks).
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
import {
|
|
13
|
+
DRAG_THRESHOLD_PX,
|
|
14
|
+
computeResizedItem,
|
|
15
|
+
snapMovedSpan,
|
|
16
|
+
snapToBoundaries,
|
|
17
|
+
type Draggable,
|
|
18
|
+
} from '../useItemDragDrop'
|
|
19
|
+
|
|
20
|
+
describe('DRAG_THRESHOLD_PX', () => {
|
|
21
|
+
it('is the value both surfaces use to tell a click from a drag', () => {
|
|
22
|
+
expect(DRAG_THRESHOLD_PX).toBe(4)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('snapToBoundaries', () => {
|
|
27
|
+
it('takes the nearest boundary inside the threshold', () => {
|
|
28
|
+
expect(snapToBoundaries(5.1, [3, 5, 8], 0.5)).toBe(5)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('leaves a value with nothing in range alone', () => {
|
|
32
|
+
expect(snapToBoundaries(5.1, [3, 8], 0.5)).toBe(5.1)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('prefers the nearer of two candidates', () => {
|
|
36
|
+
expect(snapToBoundaries(5, [4.6, 5.2], 1)).toBe(5.2)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('keeps the earlier entry on an exact tie', () => {
|
|
40
|
+
expect(snapToBoundaries(5, [4.5, 5.5], 1)).toBe(4.5)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('does not snap at exactly the threshold', () => {
|
|
44
|
+
expect(snapToBoundaries(5.5, [5], 0.5)).toBe(5.5)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('is a no-op with no boundaries or a zero threshold', () => {
|
|
48
|
+
expect(snapToBoundaries(5.1, [], 0.5)).toBe(5.1)
|
|
49
|
+
expect(snapToBoundaries(5.1, [5], 0)).toBe(5.1)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('snapMovedSpan', () => {
|
|
54
|
+
it('snaps the leading edge to a boundary', () => {
|
|
55
|
+
expect(snapMovedSpan(4.9, 2, [5], 0.5)).toEqual({ start: 5, end: 7 })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('snaps the trailing edge to a boundary', () => {
|
|
59
|
+
expect(snapMovedSpan(2.9, 2, [5], 0.5)).toEqual({ start: 3, end: 5 })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('picks whichever edge is closer', () => {
|
|
63
|
+
// start 4.8 is 0.2 from 5; end 6.8 is 0.8 from 6 — the start edge wins.
|
|
64
|
+
expect(snapMovedSpan(4.8, 2, [5, 6], 1)).toEqual({ start: 5, end: 7 })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('resolves an exact tie in favour of the start edge', () => {
|
|
68
|
+
// Both edges are 0.5 away from their nearest boundary.
|
|
69
|
+
expect(snapMovedSpan(4.5, 2, [4, 7], 1)).toEqual({ start: 4, end: 6 })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns the raw span when nothing is in range', () => {
|
|
73
|
+
expect(snapMovedSpan(4.9, 2, [1, 9], 0.05)).toEqual({ start: 4.9, end: 6.9 })
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('computeResizedItem — non-video items', () => {
|
|
78
|
+
const overlay: Draggable = { id: 'o', type: 'overlay', start: 2, end: 6 }
|
|
79
|
+
|
|
80
|
+
it('moves the start edge only', () => {
|
|
81
|
+
expect(computeResizedItem(overlay, 'start', 3)).toEqual({ ...overlay, start: 3 })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('moves the end edge only', () => {
|
|
85
|
+
expect(computeResizedItem(overlay, 'end', 5)).toEqual({ ...overlay, end: 5 })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('keeps at least 0.1s from either edge', () => {
|
|
89
|
+
expect(computeResizedItem(overlay, 'start', 99).start).toBeCloseTo(5.9)
|
|
90
|
+
expect(computeResizedItem(overlay, 'end', -99).end).toBeCloseTo(2.1)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('computeResizedItem — video clips', () => {
|
|
95
|
+
const clip: Draggable = { id: 'c', type: 'video', start: 10, end: 20, inPoint: 5, outPoint: 15, sourceDuration: 30 }
|
|
96
|
+
|
|
97
|
+
it('walks the inPoint with the start edge', () => {
|
|
98
|
+
const trimmed = computeResizedItem(clip, 'start', 12)
|
|
99
|
+
expect(trimmed.start).toBe(12)
|
|
100
|
+
expect(trimmed.inPoint).toBeCloseTo(7)
|
|
101
|
+
expect(trimmed.outPoint).toBe(15)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('walks the outPoint with the end edge', () => {
|
|
105
|
+
const trimmed = computeResizedItem(clip, 'end', 18)
|
|
106
|
+
expect(trimmed.end).toBe(18)
|
|
107
|
+
expect(trimmed.outPoint).toBeCloseTo(13)
|
|
108
|
+
expect(trimmed.inPoint).toBe(5)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('never pulls the inPoint before the start of the source', () => {
|
|
112
|
+
expect(computeResizedItem(clip, 'start', -99).inPoint).toBe(0)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('never pushes the outPoint past the end of the source', () => {
|
|
116
|
+
expect(computeResizedItem(clip, 'end', 999).outPoint).toBe(30)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('keeps the source window at least 0.1s wide', () => {
|
|
120
|
+
expect(computeResizedItem(clip, 'start', 19.99).inPoint).toBeCloseTo(14.9)
|
|
121
|
+
expect(computeResizedItem(clip, 'end', 10.01).outPoint).toBeCloseTo(5.1)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('treats a missing source window as starting at zero', () => {
|
|
125
|
+
const bare: Draggable = { id: 'b', type: 'video', start: 0, end: 4 }
|
|
126
|
+
const trimmed = computeResizedItem(bare, 'end', 3)
|
|
127
|
+
expect(trimmed.end).toBe(3)
|
|
128
|
+
expect(trimmed.outPoint).toBeCloseTo(3)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('lets an unbounded source extend freely', () => {
|
|
132
|
+
const unbounded: Draggable = { id: 'u', type: 'video', start: 0, end: 4, inPoint: 0, outPoint: 4 }
|
|
133
|
+
expect(computeResizedItem(unbounded, 'end', 100).outPoint).toBe(100)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* The track x item audio fold: a track's volume/mute settings combine with
|
|
4
|
+
* an item's own to produce what actually plays. Volume multiplies (never
|
|
5
|
+
* replaces, so a clip an editor already turned down stays proportionally
|
|
6
|
+
* quieter under a track pulled down too); mute is either/or (either one
|
|
7
|
+
* silences it). Mirrored by `effectiveItemAudio` in
|
|
8
|
+
* montaj_assets/render/project-tracks.js for the render path — the two must
|
|
9
|
+
* agree.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
import { effectiveItemAudio } from '../timeline-model'
|
|
13
|
+
|
|
14
|
+
describe('effectiveItemAudio', () => {
|
|
15
|
+
it('multiplies track and item volume', () => {
|
|
16
|
+
expect(effectiveItemAudio({ volume: 0.5 }, { volume: 0.4 }).volume).toBeCloseTo(0.2)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('does not let track volume replace a clip volume that was already turned down', () => {
|
|
20
|
+
const track = { volume: 0.5 }
|
|
21
|
+
const loud = effectiveItemAudio(track, { volume: 1.0 })
|
|
22
|
+
const quiet = effectiveItemAudio(track, { volume: 0.4 })
|
|
23
|
+
expect(loud.volume / quiet.volume).toBeCloseTo(1.0 / 0.4)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('defaults to unity volume when both track and item omit it', () => {
|
|
27
|
+
expect(effectiveItemAudio({}, {}).volume).toBe(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('respects an explicit item volume of 0 (nullish coalescing, not falsy)', () => {
|
|
31
|
+
expect(effectiveItemAudio({ volume: 1 }, { volume: 0 }).volume).toBe(0)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('mutes when the track is muted, regardless of the item', () => {
|
|
35
|
+
expect(effectiveItemAudio({ muted: true }, { muted: false }).muted).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('mutes when the item is muted, regardless of the track', () => {
|
|
39
|
+
expect(effectiveItemAudio({ muted: false }, { muted: true }).muted).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('is unmuted only when neither track nor item is muted', () => {
|
|
43
|
+
expect(effectiveItemAudio({}, {}).muted).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('tolerates undefined/null track and item', () => {
|
|
47
|
+
expect(effectiveItemAudio(undefined, undefined)).toEqual({ volume: 1, muted: false })
|
|
48
|
+
expect(effectiveItemAudio(null, null)).toEqual({ volume: 1, muted: false })
|
|
49
|
+
})
|
|
50
|
+
})
|