@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,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas timeline snapping (SP5 T5) — ONE magnetic model for every gesture.
|
|
3
|
+
*
|
|
4
|
+
* The DOM timeline snapped in three different ways: the Scrubber's playhead
|
|
5
|
+
* drag used attract/release hysteresis (18px in, 28px out), while clip drags
|
|
6
|
+
* and edge trims used a flat 8px "nearest boundary wins" test with no memory.
|
|
7
|
+
* The flat version has the classic problem — right at the threshold the value
|
|
8
|
+
* flickers between snapped and free as the hand jitters — and the two radii
|
|
9
|
+
* disagreed about how strong a magnet feels. Plan decision 7 keeps the good one
|
|
10
|
+
* and retires the others. (It once applied to the canvas surface only, while
|
|
11
|
+
* the DOM rows kept the flat test; those rows are gone, so this is simply how
|
|
12
|
+
* snapping works.)
|
|
13
|
+
*
|
|
14
|
+
* The hysteresis is what makes it feel deliberate: a point attracts from one
|
|
15
|
+
* radius but holds until the cursor clears a wider one. The asymmetry is the
|
|
16
|
+
* whole trick — with one radius, "just past the edge of the magnet" is an
|
|
17
|
+
* unstable place to stand. See `DEFAULT_SNAP_CONFIG` for the two numbers and
|
|
18
|
+
* why they are as far apart as they are.
|
|
19
|
+
*
|
|
20
|
+
* Pixels, not seconds, are the unit of feel: a magnet must cover the same
|
|
21
|
+
* distance on screen whether the timeline shows ten seconds or ten minutes, so
|
|
22
|
+
* the radii convert through `pxPerSecond` on every call.
|
|
23
|
+
*
|
|
24
|
+
* Points come in two strengths. A boundary on the gesture's own track pulls
|
|
25
|
+
* hard; a boundary on some other track barely pulls at all, and loses to a
|
|
26
|
+
* same-track point outright when both are in range. Without that split, one
|
|
27
|
+
* busy overlay row is enough to make the base video track undraggable — see
|
|
28
|
+
* `SnapStrength`.
|
|
29
|
+
*
|
|
30
|
+
* Pure and stateless-by-value: `applySnap` takes a state and returns the next
|
|
31
|
+
* one, so a gesture threads it and a test drives a whole approach/release
|
|
32
|
+
* sequence without a component.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Viewport } from './viewport'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How hard a given snap point pulls.
|
|
39
|
+
*
|
|
40
|
+
* `strong` is a boundary on the gesture's OWN track — the next clip along, the
|
|
41
|
+
* cut you are butting up against. That is the alignment an editor actually
|
|
42
|
+
* means, and it gets the full magnet.
|
|
43
|
+
*
|
|
44
|
+
* `weak` is everything on some other track: an overlay two rows up, an audio
|
|
45
|
+
* bar below. Those alignments are occasionally useful and constantly in the
|
|
46
|
+
* way — a busy overlay track puts a boundary every few pixels, and at full
|
|
47
|
+
* strength they turn a simple drag into a fight. They keep a radius small
|
|
48
|
+
* enough to catch a deliberate aim and too small to catch a passing one.
|
|
49
|
+
*/
|
|
50
|
+
export type SnapStrength = 'strong' | 'weak'
|
|
51
|
+
|
|
52
|
+
export interface SnapPoint {
|
|
53
|
+
time: number
|
|
54
|
+
strength: SnapStrength
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SnapConfig {
|
|
58
|
+
/** Distance at which an unsnapped gesture is captured by a strong point. */
|
|
59
|
+
attractPx: number
|
|
60
|
+
/** Distance a captured strong point must travel to break free. Must exceed
|
|
61
|
+
* `attractPx`, or the magnet has no hysteresis at all. */
|
|
62
|
+
releasePx: number
|
|
63
|
+
/** The same pair for weak points. */
|
|
64
|
+
weakAttractPx: number
|
|
65
|
+
weakReleasePx: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The magnet's strength.
|
|
70
|
+
*
|
|
71
|
+
* These started as Scrubber.tsx's numbers (18/28) and were widened after using
|
|
72
|
+
* the canvas timeline in anger. Two separate complaints, one fix each:
|
|
73
|
+
*
|
|
74
|
+
* - `attractPx` 18 → 20. A cut you are aiming at should catch you slightly
|
|
75
|
+
* before you land on it, so the common case (butt this clip against that
|
|
76
|
+
* one) needs no precision at all.
|
|
77
|
+
* - `releasePx` 28 → 44. This is the one that changes how the timeline FEELS.
|
|
78
|
+
* A 28px release meant a snapped gesture came free about as easily as it was
|
|
79
|
+
* caught, so dragging PAST a boundary — a deliberate act — was
|
|
80
|
+
* indistinguishable from drifting off one. At 44px breaking out is a
|
|
81
|
+
* decision: the guide holds, you push through it, it lets go. The ratio
|
|
82
|
+
* (2.2× attract) is what reads as a detent rather than as friction.
|
|
83
|
+
*
|
|
84
|
+
* Widening the release radius is only tolerable because the snap is now VISIBLE
|
|
85
|
+
* — `drawSnapGuide` puts a line on the boundary you are held to. A strong
|
|
86
|
+
* magnet with no indicator is just a timeline that refuses to go where you put
|
|
87
|
+
* it; with the guide, the same magnet reads as help.
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_SNAP_CONFIG: SnapConfig = {
|
|
90
|
+
attractPx: 20,
|
|
91
|
+
releasePx: 44,
|
|
92
|
+
// A third of the strong radius, and barely wider than it is deep. A
|
|
93
|
+
// cross-track boundary should be catchable when you aim at it and
|
|
94
|
+
// effectively invisible when you don't — you can drag straight through one
|
|
95
|
+
// without noticing, which is the point.
|
|
96
|
+
weakAttractPx: 6,
|
|
97
|
+
weakReleasePx: 10,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SnapState {
|
|
101
|
+
/** The point currently held, or null when the gesture is running free. */
|
|
102
|
+
readonly snappedTo: number | null
|
|
103
|
+
/** How hard the held point pulls — it decides which release radius applies,
|
|
104
|
+
* so a weak capture stays as easy to leave as it was to enter. */
|
|
105
|
+
readonly strength: SnapStrength | null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const FREE: SnapState = { snappedTo: null, strength: null }
|
|
109
|
+
|
|
110
|
+
export function createSnapState(): SnapState {
|
|
111
|
+
return FREE
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface SnapResult {
|
|
115
|
+
/** The value to use: the snap point when caught, the candidate otherwise. */
|
|
116
|
+
time: number
|
|
117
|
+
snappedTo: number | null
|
|
118
|
+
/** Which tier caught it, so the caller can draw a guide that matches how
|
|
119
|
+
* hard it is holding. Null whenever `snappedTo` is. */
|
|
120
|
+
strength: SnapStrength | null
|
|
121
|
+
/** Thread this into the next call of the same gesture. */
|
|
122
|
+
state: SnapState
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Magnetize `candidateT` to the nearest snap point, with hysteresis.
|
|
127
|
+
*
|
|
128
|
+
* Tie-break is **nearest wins**, ties going to the earlier entry — deliberately
|
|
129
|
+
* unlike the Scrubber, which took the first point in array order that happened
|
|
130
|
+
* to be within range and so gave different answers for different orderings of
|
|
131
|
+
* the same boundary set. Nearest is what the flat implementations already did
|
|
132
|
+
* and what a user means by "it snapped to the cut".
|
|
133
|
+
*
|
|
134
|
+
* Snapping is off entirely when the viewport has no scale yet (before first
|
|
135
|
+
* layout), since pixel radii are meaningless without one.
|
|
136
|
+
*/
|
|
137
|
+
export function applySnap(
|
|
138
|
+
candidateT: number,
|
|
139
|
+
snapPoints: readonly SnapPoint[],
|
|
140
|
+
viewport: Viewport,
|
|
141
|
+
state: SnapState = FREE,
|
|
142
|
+
config: SnapConfig = DEFAULT_SNAP_CONFIG,
|
|
143
|
+
): SnapResult {
|
|
144
|
+
const pxPerSecond = viewport.pxPerSecond
|
|
145
|
+
if (!(pxPerSecond > 0) || snapPoints.length === 0) {
|
|
146
|
+
return { time: candidateT, snappedTo: null, strength: null, state: FREE }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const attract = { strong: config.attractPx / pxPerSecond, weak: config.weakAttractPx / pxPerSecond }
|
|
150
|
+
const release = { strong: config.releasePx / pxPerSecond, weak: config.weakReleasePx / pxPerSecond }
|
|
151
|
+
|
|
152
|
+
// Held: stay put until the cursor escapes the (wider) release radius of the
|
|
153
|
+
// tier that caught it. A weak capture is let go of on weak terms.
|
|
154
|
+
if (state.snappedTo !== null && state.strength !== null) {
|
|
155
|
+
if (Math.abs(candidateT - state.snappedTo) < release[state.strength]) {
|
|
156
|
+
return { time: state.snappedTo, snappedTo: state.snappedTo, strength: state.strength, state }
|
|
157
|
+
}
|
|
158
|
+
// Broken free. Fall through to a fresh scan — the point just released is
|
|
159
|
+
// by definition beyond its own `release` > `attract`, so it cannot
|
|
160
|
+
// re-capture at the same tier.
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Nearest wins WITHIN a tier; a strong point beats a weak one outright, even
|
|
164
|
+
// a nearer weak one. That precedence is the whole feature: cross-track
|
|
165
|
+
// boundaries must never steal a gesture away from the cut it is aiming at on
|
|
166
|
+
// its own track, and with plain nearest-wins they routinely would.
|
|
167
|
+
let bestStrong: number | null = null
|
|
168
|
+
let bestStrongDist = attract.strong
|
|
169
|
+
let bestWeak: number | null = null
|
|
170
|
+
let bestWeakDist = attract.weak
|
|
171
|
+
|
|
172
|
+
for (const point of snapPoints) {
|
|
173
|
+
const dist = Math.abs(candidateT - point.time)
|
|
174
|
+
if (point.strength === 'strong') {
|
|
175
|
+
if (dist < bestStrongDist) { bestStrongDist = dist; bestStrong = point.time }
|
|
176
|
+
} else if (dist < bestWeakDist) {
|
|
177
|
+
bestWeakDist = dist
|
|
178
|
+
bestWeak = point.time
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const best = bestStrong ?? bestWeak
|
|
183
|
+
if (best === null) return { time: candidateT, snappedTo: null, strength: null, state: FREE }
|
|
184
|
+
const strength: SnapStrength = bestStrong !== null ? 'strong' : 'weak'
|
|
185
|
+
return { time: best, snappedTo: best, strength, state: { snappedTo: best, strength } }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Snap points for dragging a whole item of length `duration`, expressed as
|
|
190
|
+
* candidate START positions: a point can catch the item's leading edge (start
|
|
191
|
+
* lands on it) or its trailing edge (start lands one duration earlier). Feeding
|
|
192
|
+
* these to `applySnap` with the raw start makes both edges magnetic through the
|
|
193
|
+
* same code path — and, because the state is a start position rather than an
|
|
194
|
+
* "edge + point" pair, hysteresis works across the pair for free.
|
|
195
|
+
*/
|
|
196
|
+
export function snapPointsForSpan(points: readonly SnapPoint[], duration: number): SnapPoint[] {
|
|
197
|
+
const out: SnapPoint[] = []
|
|
198
|
+
for (const p of points) out.push(p)
|
|
199
|
+
for (const p of points) out.push({ time: p.time - duration, strength: p.strength })
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const EPSILON = 1e-6
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Drop points that coincide with values the gesture is itself moving, and
|
|
207
|
+
* de-duplicate the rest.
|
|
208
|
+
*
|
|
209
|
+
* Every item's own start and end are in the boundary set, so without this an
|
|
210
|
+
* edge trim (or a clip drag) would be captured by the position it started from
|
|
211
|
+
* and refuse to move until the cursor cleared the 28px release radius. The DOM
|
|
212
|
+
* paths lived with it because an 8px flat magnet is easy to pull out of; at
|
|
213
|
+
* these radii it reads as a stuck gesture.
|
|
214
|
+
*/
|
|
215
|
+
export function snapPointsExcluding(points: readonly SnapPoint[], exclude: readonly number[]): SnapPoint[] {
|
|
216
|
+
const out: SnapPoint[] = []
|
|
217
|
+
for (const p of points) {
|
|
218
|
+
if (!Number.isFinite(p.time)) continue
|
|
219
|
+
if (exclude.some(e => Math.abs(p.time - e) <= EPSILON)) continue
|
|
220
|
+
const dupeAt = out.findIndex(kept => Math.abs(kept.time - p.time) <= EPSILON)
|
|
221
|
+
if (dupeAt >= 0) {
|
|
222
|
+
// One time, two tiers: a cut on this track that happens to line up with
|
|
223
|
+
// one two rows up is a STRONG point, not a weak one. Upgrading on
|
|
224
|
+
// collision keeps the strong tier complete no matter what order the
|
|
225
|
+
// rows were walked in.
|
|
226
|
+
if (p.strength === 'strong') out[dupeAt] = p
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
out.push(p)
|
|
230
|
+
}
|
|
231
|
+
return out
|
|
232
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas timeline viewport (SP5 T4) — the scroll/zoom model the canvas surface
|
|
3
|
+
* draws through, plus the DPR-crisp rendering plumbing.
|
|
4
|
+
*
|
|
5
|
+
* ── Why a new model ──────────────────────────────────────────────────────
|
|
6
|
+
* The DOM timeline zooms by widening a container to `zoom × 100%` and letting
|
|
7
|
+
* the browser scroll it, so its zoom is "multiples of container width" against
|
|
8
|
+
* a content-dependent `totalDuration`. That model can't zoom out past fit, and
|
|
9
|
+
* its px/second silently changes whenever a clip moves (totalDuration is
|
|
10
|
+
* derived from content). The canvas uses the model every NLE uses instead:
|
|
11
|
+
*
|
|
12
|
+
* pxPerSecond how many CSS pixels one second occupies
|
|
13
|
+
* scrollSeconds the time at the left edge of the surface
|
|
14
|
+
*
|
|
15
|
+
* Time↔pixel conversion is then a pure affine map, independent of content.
|
|
16
|
+
* The zoom badge reports a fit-relative multiple (`zoomMultiple`) so the
|
|
17
|
+
* numbers users knew from the old DOM path's zoom stay recognizable.
|
|
18
|
+
*
|
|
19
|
+
* Everything above `createViewportStore` is pure math with no DOM access, so
|
|
20
|
+
* it is unit-testable and reusable by hit-testing (T5) and the content layers
|
|
21
|
+
* (T6/T7).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useRef, useSyncExternalStore } from 'react'
|
|
25
|
+
|
|
26
|
+
export interface Viewport {
|
|
27
|
+
/** CSS pixels per second of timeline. */
|
|
28
|
+
pxPerSecond: number
|
|
29
|
+
/** Time at x=0 of the drawing surface, in seconds. */
|
|
30
|
+
scrollSeconds: number
|
|
31
|
+
/** Width of the drawing surface in CSS pixels (0 before first layout). */
|
|
32
|
+
widthPx: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Absolute floor on zoom-out. One pixel per second is already an hour of
|
|
36
|
+
* timeline across a 3600px surface — further out is not a useful view. */
|
|
37
|
+
export const MIN_PX_PER_SECOND = 1
|
|
38
|
+
|
|
39
|
+
/** Absolute ceiling on zoom-in: ~66px per frame at 30fps, which is as far as
|
|
40
|
+
* any frame-accurate trim needs. */
|
|
41
|
+
export const MAX_PX_PER_SECOND = 2000
|
|
42
|
+
|
|
43
|
+
/** How far below "fit the whole project" zoom-out is allowed to go, as a
|
|
44
|
+
* fraction of the fit scale. The DOM model bottomed out AT fit; pulling back
|
|
45
|
+
* from the content is a deliberate improvement (it gives drop room past the
|
|
46
|
+
* last clip) — but only so far. At `0.5` the content bottoms out filling HALF
|
|
47
|
+
* the surface width (one content-length of drop room past the end), instead of
|
|
48
|
+
* shrinking to a sliver: CapCut caps its max zoom-out the same way, and a
|
|
49
|
+
* quarter-width timeline (the old `0.25`) is more empty canvas than anyone
|
|
50
|
+
* needs to reach the end. `MIN_PX_PER_SECOND` still floors this for a project
|
|
51
|
+
* long enough that half-width would fall below it. */
|
|
52
|
+
export const MIN_FIT_MULTIPLE = 0.5
|
|
53
|
+
|
|
54
|
+
/** Extra scroll past the end of the content, as a fraction of one screenful,
|
|
55
|
+
* so the rightmost item can always be dragged/trimmed further out. */
|
|
56
|
+
export const OVERSCROLL_FRACTION = 0.25
|
|
57
|
+
|
|
58
|
+
/** Multiplicative step for the chrome's +/− zoom buttons. */
|
|
59
|
+
export const ZOOM_BUTTON_FACTOR = 1.5
|
|
60
|
+
|
|
61
|
+
// ── Pure math ────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** px/second at which `totalDuration` exactly fills `widthPx`. 0 when either
|
|
64
|
+
* is degenerate (no surface yet, or an empty project). */
|
|
65
|
+
export function fitPxPerSecond(widthPx: number, totalDuration: number): number {
|
|
66
|
+
if (widthPx <= 0 || totalDuration <= 0) return 0
|
|
67
|
+
return widthPx / totalDuration
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function clampPxPerSecond(pxPerSecond: number, widthPx: number, totalDuration: number): number {
|
|
71
|
+
const fit = fitPxPerSecond(widthPx, totalDuration)
|
|
72
|
+
const lower = Math.min(
|
|
73
|
+
fit > 0 ? Math.max(MIN_PX_PER_SECOND, fit * MIN_FIT_MULTIPLE) : MIN_PX_PER_SECOND,
|
|
74
|
+
MAX_PX_PER_SECOND,
|
|
75
|
+
)
|
|
76
|
+
if (!Number.isFinite(pxPerSecond)) return lower
|
|
77
|
+
return Math.min(MAX_PX_PER_SECOND, Math.max(lower, pxPerSecond))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Seconds visible across the surface at the current scale. */
|
|
81
|
+
export function visibleDuration(vp: Viewport): number {
|
|
82
|
+
return vp.pxPerSecond > 0 ? vp.widthPx / vp.pxPerSecond : 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function visibleRange(vp: Viewport): { start: number; end: number } {
|
|
86
|
+
return { start: vp.scrollSeconds, end: vp.scrollSeconds + visibleDuration(vp) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Surface x (CSS px) for a timeline time. */
|
|
90
|
+
export function timeToX(t: number, vp: Viewport): number {
|
|
91
|
+
return (t - vp.scrollSeconds) * vp.pxPerSecond
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Timeline time for a surface x (CSS px, relative to the surface's left). */
|
|
95
|
+
export function xToTime(x: number, vp: Viewport): number {
|
|
96
|
+
return vp.pxPerSecond > 0 ? vp.scrollSeconds + x / vp.pxPerSecond : vp.scrollSeconds
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Rightmost legal `scrollSeconds`: the end of the content, less a screenful,
|
|
100
|
+
* plus overscroll headroom. Zero whenever the content already fits. */
|
|
101
|
+
export function maxScrollSeconds(vp: Viewport, totalDuration: number): number {
|
|
102
|
+
const visible = visibleDuration(vp)
|
|
103
|
+
return Math.max(0, totalDuration - visible + visible * OVERSCROLL_FRACTION)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Clamp to [0, maxScroll]. Scrolling left of t=0 is never allowed — blank
|
|
107
|
+
* space before the start of the timeline reads as a bug, not headroom. */
|
|
108
|
+
export function clampScrollSeconds(scrollSeconds: number, vp: Viewport, totalDuration: number): number {
|
|
109
|
+
if (!Number.isFinite(scrollSeconds)) return 0
|
|
110
|
+
return Math.max(0, Math.min(maxScrollSeconds(vp, totalDuration), scrollSeconds))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** How close to the surface's left/right edge a drag's pointer must sit before
|
|
114
|
+
* edge auto-scroll starts panning the view to follow it (CSS px). This is
|
|
115
|
+
* only the TRIGGER band — how far the pointer must intrude before panning
|
|
116
|
+
* starts at all. It says nothing about how fast that panning ramps up; see
|
|
117
|
+
* `EDGE_SCROLL_RAMP_PX` for that. */
|
|
118
|
+
export const EDGE_SCROLL_ZONE_PX = 40
|
|
119
|
+
|
|
120
|
+
/** Panning speed once the ramp is fully spent (`EDGE_SCROLL_RAMP_PX` past the
|
|
121
|
+
* zone's outer boundary), in content CSS px/second — roughly a screen-width
|
|
122
|
+
* per second, fast enough to cross a long timeline without feeling like a
|
|
123
|
+
* jump. Reachable at all only because the ramp now keeps climbing past the
|
|
124
|
+
* surface edge itself (see `edgeScrollDelta`); a pointer merely parked at the
|
|
125
|
+
* zone's threshold stays well under this. */
|
|
126
|
+
export const EDGE_SCROLL_MAX_PX_PER_SEC = 1400
|
|
127
|
+
|
|
128
|
+
/** Total depth, from the edge zone's outer boundary outward, over which
|
|
129
|
+
* `edgeScrollDelta`'s speed ramps from 0 up to `EDGE_SCROLL_MAX_PX_PER_SEC`.
|
|
130
|
+
* Bigger than `EDGE_SCROLL_ZONE_PX` on purpose: a drag is driven by
|
|
131
|
+
* document-level listeners, so shoving the cursor to the surface edge is
|
|
132
|
+
* effortless and instantaneous, and a ramp that maxed out AT the edge would
|
|
133
|
+
* make every such shove feel identical — one flat speed, no matter how hard
|
|
134
|
+
* the user pushed. Letting the ramp keep climbing for the 30px beyond the
|
|
135
|
+
* zone (`70 - 40`) means the felt speed keeps responding to how far past the
|
|
136
|
+
* edge the pointer travels, which is the only room document-level tracking
|
|
137
|
+
* gives a user to express "faster." */
|
|
138
|
+
export const EDGE_SCROLL_RAMP_PX = 70
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* How many seconds to pan the viewport for one animation frame of an active
|
|
142
|
+
* drag whose pointer sits at `pointerX` (CSS px from the surface's left edge —
|
|
143
|
+
* the same `Point.x` a gesture already works in). Zero outside the
|
|
144
|
+
* `edgeZonePx` band at either edge; from the zone's outer boundary, speed
|
|
145
|
+
* ramps up over `rampPx` of further depth via `(depth / rampPx) ^ 1.5` — an
|
|
146
|
+
* ease-in curve, not linear — reaching `maxPxPerSec` once the pointer has
|
|
147
|
+
* travelled the full ramp distance past that boundary, and holding there for
|
|
148
|
+
* anything beyond (a drag driven by document-level listeners, not native
|
|
149
|
+
* pointer capture, can report an x arbitrarily outside [0, canvasWidth] once
|
|
150
|
+
* the real cursor leaves the surface, so depth must clamp rather than keep
|
|
151
|
+
* growing forever). The exponent keeps the curve gentle right at the trigger
|
|
152
|
+
* boundary — where a barely-crossed pointer would otherwise feel like it
|
|
153
|
+
* suddenly grabbed the view — while still reaching a punchy speed by the time
|
|
154
|
+
* the user has shoved the cursor visibly past the edge.
|
|
155
|
+
*
|
|
156
|
+
* Negative = pan left/earlier, positive = pan right/later, 0 = the pointer is
|
|
157
|
+
* not near either edge. Framerate-independent: the ramped speed is scaled by
|
|
158
|
+
* `dtSeconds` and converted from content px/second to timeline seconds via
|
|
159
|
+
* `pxPerSecond`, so callers can feed it a real frame delta and get a
|
|
160
|
+
* consistent pan regardless of the display's refresh rate.
|
|
161
|
+
*/
|
|
162
|
+
export function edgeScrollDelta(
|
|
163
|
+
pointerX: number,
|
|
164
|
+
canvasWidth: number,
|
|
165
|
+
pxPerSecond: number,
|
|
166
|
+
dtSeconds: number,
|
|
167
|
+
edgeZonePx: number,
|
|
168
|
+
maxPxPerSec: number,
|
|
169
|
+
rampPx: number,
|
|
170
|
+
): number {
|
|
171
|
+
if (canvasWidth <= 0 || pxPerSecond <= 0 || edgeZonePx <= 0 || dtSeconds <= 0 || rampPx <= 0) return 0
|
|
172
|
+
|
|
173
|
+
const leftBoundary = edgeZonePx
|
|
174
|
+
const rightBoundary = canvasWidth - edgeZonePx
|
|
175
|
+
|
|
176
|
+
let depthPx: number
|
|
177
|
+
let direction: -1 | 1
|
|
178
|
+
if (pointerX < leftBoundary) {
|
|
179
|
+
depthPx = leftBoundary - pointerX
|
|
180
|
+
direction = -1
|
|
181
|
+
} else if (pointerX > rightBoundary) {
|
|
182
|
+
depthPx = pointerX - rightBoundary
|
|
183
|
+
direction = 1
|
|
184
|
+
} else {
|
|
185
|
+
return 0
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const ratio = Math.min(1, depthPx / rampPx)
|
|
189
|
+
const contentPxPerSecond = maxPxPerSec * Math.pow(ratio, 1.5)
|
|
190
|
+
return (direction * contentPxPerSecond * dtSeconds) / pxPerSecond
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Zoom by `factor`, keeping the time under `pivotX` pinned to `pivotX`. Same
|
|
195
|
+
* semantics as the DOM path's `useTimelineZoom.zoomTo` (which pins a pivot
|
|
196
|
+
* percentage through a pending scrollLeft write) expressed directly in the
|
|
197
|
+
* px/second model: solve `pivotTime = scroll' + pivotX / pps'` for `scroll'`.
|
|
198
|
+
*
|
|
199
|
+
* The pivot only drifts when the new scroll would leave the legal range —
|
|
200
|
+
* i.e. at the ends of the timeline, exactly where the DOM path drifts too.
|
|
201
|
+
*/
|
|
202
|
+
export function zoomAtPivot(vp: Viewport, factor: number, pivotX: number, totalDuration: number): Viewport {
|
|
203
|
+
const pivotTime = xToTime(pivotX, vp)
|
|
204
|
+
const pxPerSecond = clampPxPerSecond(vp.pxPerSecond * factor, vp.widthPx, totalDuration)
|
|
205
|
+
const zoomed: Viewport = { ...vp, pxPerSecond }
|
|
206
|
+
return { ...zoomed, scrollSeconds: clampScrollSeconds(pivotTime - pivotX / pxPerSecond, zoomed, totalDuration) }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Horizontal pan by a pixel delta (positive = content moves left / later). */
|
|
210
|
+
export function panByPixels(vp: Viewport, deltaPx: number, totalDuration: number): Viewport {
|
|
211
|
+
if (vp.pxPerSecond <= 0) return vp
|
|
212
|
+
return { ...vp, scrollSeconds: clampScrollSeconds(vp.scrollSeconds + deltaPx / vp.pxPerSecond, vp, totalDuration) }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Scale so the whole project fills the surface, scrolled to the start. */
|
|
216
|
+
export function fitViewport(vp: Viewport, totalDuration: number): Viewport {
|
|
217
|
+
return {
|
|
218
|
+
...vp,
|
|
219
|
+
pxPerSecond: clampPxPerSecond(fitPxPerSecond(vp.widthPx, totalDuration), vp.widthPx, totalDuration),
|
|
220
|
+
scrollSeconds: 0,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The zoom number the chrome shows: 1× is fit, 2× is twice as close in. Keeps
|
|
225
|
+
* continuity with the DOM path's badge, whose 1× also meant fit. */
|
|
226
|
+
export function zoomMultiple(vp: Viewport, totalDuration: number): number {
|
|
227
|
+
const fit = fitPxPerSecond(vp.widthPx, totalDuration)
|
|
228
|
+
return fit > 0 ? vp.pxPerSecond / fit : 1
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function formatZoomMultiple(multiple: number): string {
|
|
232
|
+
if (!Number.isFinite(multiple) || multiple <= 0) return '1'
|
|
233
|
+
if (multiple >= 10) return String(Math.round(multiple))
|
|
234
|
+
return multiple.toFixed(1)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Re-derive the viewport for a new surface width. Scale is preserved (a wider
|
|
238
|
+
* window shows more time, it does not rescale the content) except on the very
|
|
239
|
+
* first layout, where there is no scale yet and we start fitted. */
|
|
240
|
+
export function withSurfaceWidth(vp: Viewport, widthPx: number, totalDuration: number): Viewport {
|
|
241
|
+
if (widthPx <= 0) return vp.widthPx === widthPx ? vp : { ...vp, widthPx }
|
|
242
|
+
const sized: Viewport = { ...vp, widthPx }
|
|
243
|
+
if (vp.pxPerSecond <= 0) return fitViewport(sized, totalDuration)
|
|
244
|
+
const rescaled: Viewport = { ...sized, pxPerSecond: clampPxPerSecond(sized.pxPerSecond, widthPx, totalDuration) }
|
|
245
|
+
return { ...rescaled, scrollSeconds: clampScrollSeconds(rescaled.scrollSeconds, rescaled, totalDuration) }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Re-clamp after the project's duration changed (clips added/moved/deleted). */
|
|
249
|
+
export function reclampForDuration(vp: Viewport, totalDuration: number): Viewport {
|
|
250
|
+
if (vp.widthPx <= 0) return vp
|
|
251
|
+
if (vp.pxPerSecond <= 0) return fitViewport(vp, totalDuration)
|
|
252
|
+
const rescaled: Viewport = { ...vp, pxPerSecond: clampPxPerSecond(vp.pxPerSecond, vp.widthPx, totalDuration) }
|
|
253
|
+
return { ...rescaled, scrollSeconds: clampScrollSeconds(rescaled.scrollSeconds, rescaled, totalDuration) }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function viewportsEqual(a: Viewport, b: Viewport): boolean {
|
|
257
|
+
return a.pxPerSecond === b.pxPerSecond && a.scrollSeconds === b.scrollSeconds && a.widthPx === b.widthPx
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Wheel semantics (ported from useTimelineZoom) ────────────────────────
|
|
261
|
+
|
|
262
|
+
export type WheelIntent =
|
|
263
|
+
| { kind: 'zoom'; factor: number; pivotX: number }
|
|
264
|
+
| { kind: 'pan'; deltaPx: number }
|
|
265
|
+
| { kind: 'none' }
|
|
266
|
+
|
|
267
|
+
export interface WheelLike {
|
|
268
|
+
deltaX: number
|
|
269
|
+
deltaY: number
|
|
270
|
+
ctrlKey: boolean
|
|
271
|
+
metaKey: boolean
|
|
272
|
+
altKey: boolean
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* What a wheel event means to the canvas timeline. Ported from the DOM path:
|
|
277
|
+
*
|
|
278
|
+
* ⌘/Ctrl + wheel zoom, multiplicatively, pivoted at the cursor. The
|
|
279
|
+
* `exp(-deltaY * 0.002)` step is the DOM path's verbatim, so
|
|
280
|
+
* a wheel tick (deltaY ≈ 100) is the same ≈18% either side.
|
|
281
|
+
* Alt + wheel horizontal pan by deltaY pixels.
|
|
282
|
+
* plain wheel NOT intercepted — the page/container must keep scrolling
|
|
283
|
+
* vertically. The one exception is a horizontally-dominant
|
|
284
|
+
* plain wheel (trackpad two-finger swipe), which the DOM
|
|
285
|
+
* path's `overflow-x: auto` container consumed for free and
|
|
286
|
+
* the canvas must consume explicitly or lose the gesture.
|
|
287
|
+
*/
|
|
288
|
+
export function wheelIntent(e: WheelLike, pivotX: number): WheelIntent {
|
|
289
|
+
if (e.ctrlKey || e.metaKey) return { kind: 'zoom', factor: Math.exp(-e.deltaY * 0.002), pivotX }
|
|
290
|
+
if (e.altKey) return { kind: 'pan', deltaPx: e.deltaY }
|
|
291
|
+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return { kind: 'pan', deltaPx: e.deltaX }
|
|
292
|
+
return { kind: 'none' }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function applyWheelIntent(vp: Viewport, intent: WheelIntent, totalDuration: number): Viewport {
|
|
296
|
+
switch (intent.kind) {
|
|
297
|
+
case 'zoom': return zoomAtPivot(vp, intent.factor, intent.pivotX, totalDuration)
|
|
298
|
+
case 'pan': return panByPixels(vp, intent.deltaPx, totalDuration)
|
|
299
|
+
default: return vp
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── DPR-crisp surface plumbing ───────────────────────────────────────────
|
|
304
|
+
// No precedent existed in this repo for a resolution-independent 2D canvas
|
|
305
|
+
// (the engine's painter draws into a fixed-size video canvas), so this is the
|
|
306
|
+
// one place that knows about devicePixelRatio. Everything else works in CSS
|
|
307
|
+
// pixels and never sees the backing store.
|
|
308
|
+
|
|
309
|
+
export interface SurfaceMetrics {
|
|
310
|
+
cssWidth: number
|
|
311
|
+
cssHeight: number
|
|
312
|
+
dpr: number
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Backing-store pixel size for a CSS box at a given DPR. Rounded, never 0 —
|
|
316
|
+
* a zero-sized canvas throws on some `getContext` paths. */
|
|
317
|
+
export function backingStoreSize(cssWidth: number, cssHeight: number, dpr: number): { width: number; height: number } {
|
|
318
|
+
const scale = dpr > 0 ? dpr : 1
|
|
319
|
+
return {
|
|
320
|
+
width: Math.max(1, Math.round(cssWidth * scale)),
|
|
321
|
+
height: Math.max(1, Math.round(cssHeight * scale)),
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Size a canvas' backing store for its CSS box. Returns true when it changed
|
|
326
|
+
* (writing `canvas.width` clears the canvas, so callers redraw on true). */
|
|
327
|
+
export function syncCanvasBackingStore(canvas: HTMLCanvasElement, metrics: SurfaceMetrics): boolean {
|
|
328
|
+
const { width, height } = backingStoreSize(metrics.cssWidth, metrics.cssHeight, metrics.dpr)
|
|
329
|
+
if (canvas.width === width && canvas.height === height) return false
|
|
330
|
+
canvas.width = width
|
|
331
|
+
canvas.height = height
|
|
332
|
+
return true
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Put the context in CSS-pixel space. `setTransform` rather than `scale` so
|
|
336
|
+
* it is idempotent — a per-frame `scale(dpr, dpr)` would compound. */
|
|
337
|
+
export function scaleContextToDpr(ctx: CanvasRenderingContext2D, dpr: number): void {
|
|
338
|
+
const scale = dpr > 0 ? dpr : 1
|
|
339
|
+
ctx.setTransform(scale, 0, 0, scale, 0, 0)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function currentDpr(): number {
|
|
343
|
+
if (typeof window === 'undefined') return 1
|
|
344
|
+
return window.devicePixelRatio || 1
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Watch an element's CSS size AND the window's device-pixel ratio, calling
|
|
349
|
+
* back with both. The DPR half matters when the window is dragged between a
|
|
350
|
+
* Retina and a non-Retina display: no resize fires, but every backing store is
|
|
351
|
+
* now the wrong size and text renders blurry (or over-sharp). `matchMedia` on
|
|
352
|
+
* a `resolution` query is the only event-driven DPR signal browsers give, and
|
|
353
|
+
* it must be re-armed after each change because the query pins the old value.
|
|
354
|
+
*
|
|
355
|
+
* Returns a disposer. Degrades to a window-resize fallback where
|
|
356
|
+
* ResizeObserver is unavailable (jsdom), and to nothing where matchMedia is.
|
|
357
|
+
*/
|
|
358
|
+
export function observeSurface(el: HTMLElement, onChange: (metrics: SurfaceMetrics) => void): () => void {
|
|
359
|
+
const emit = () => {
|
|
360
|
+
const rect = el.getBoundingClientRect()
|
|
361
|
+
onChange({ cssWidth: rect.width, cssHeight: rect.height, dpr: currentDpr() })
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const disposers: Array<() => void> = []
|
|
365
|
+
|
|
366
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
367
|
+
const ro = new ResizeObserver(() => emit())
|
|
368
|
+
ro.observe(el)
|
|
369
|
+
disposers.push(() => ro.disconnect())
|
|
370
|
+
} else if (typeof window !== 'undefined') {
|
|
371
|
+
const onResize = () => emit()
|
|
372
|
+
window.addEventListener('resize', onResize)
|
|
373
|
+
disposers.push(() => window.removeEventListener('resize', onResize))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
disposers.push(watchDpr(emit))
|
|
377
|
+
emit()
|
|
378
|
+
|
|
379
|
+
return () => { for (const d of disposers) d() }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Fire `onChange` whenever devicePixelRatio changes, re-arming the query. */
|
|
383
|
+
export function watchDpr(onChange: () => void): () => void {
|
|
384
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return () => {}
|
|
385
|
+
let disposed = false
|
|
386
|
+
let detach: () => void = () => {}
|
|
387
|
+
|
|
388
|
+
const arm = () => {
|
|
389
|
+
if (disposed) return
|
|
390
|
+
const mql = window.matchMedia(`(resolution: ${currentDpr()}dppx)`)
|
|
391
|
+
const handler = () => {
|
|
392
|
+
detach()
|
|
393
|
+
arm()
|
|
394
|
+
onChange()
|
|
395
|
+
}
|
|
396
|
+
// Safari < 14 only has the deprecated addListener/removeListener pair.
|
|
397
|
+
if (typeof mql.addEventListener === 'function') {
|
|
398
|
+
mql.addEventListener('change', handler)
|
|
399
|
+
detach = () => mql.removeEventListener('change', handler)
|
|
400
|
+
} else if (typeof mql.addListener === 'function') {
|
|
401
|
+
mql.addListener(handler)
|
|
402
|
+
detach = () => mql.removeListener(handler)
|
|
403
|
+
} else {
|
|
404
|
+
detach = () => {}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
arm()
|
|
409
|
+
return () => { disposed = true; detach() }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Store ────────────────────────────────────────────────────────────────
|
|
413
|
+
// The viewport is an external store, not React state, for the same reason the
|
|
414
|
+
// playhead is (see playback-clock.ts): a wheel-zoom gesture fires dozens of
|
|
415
|
+
// updates a second, and re-rendering Timeline — which owns the caption row's
|
|
416
|
+
// hundreds of DOM nodes — on each one is exactly the cost the canvas exists to
|
|
417
|
+
// avoid. The canvas redraws imperatively from `get()`; only the zoom badge
|
|
418
|
+
// subscribes for rendering.
|
|
419
|
+
|
|
420
|
+
export interface ViewportStore {
|
|
421
|
+
get(): Viewport
|
|
422
|
+
set(next: Viewport | ((prev: Viewport) => Viewport)): void
|
|
423
|
+
subscribe(cb: () => void): () => void
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export const INITIAL_VIEWPORT: Viewport = { pxPerSecond: 0, scrollSeconds: 0, widthPx: 0 }
|
|
427
|
+
|
|
428
|
+
export function createViewportStore(initial: Viewport = INITIAL_VIEWPORT): ViewportStore {
|
|
429
|
+
let value = initial
|
|
430
|
+
const subs = new Set<() => void>()
|
|
431
|
+
return {
|
|
432
|
+
get: () => value,
|
|
433
|
+
set(next) {
|
|
434
|
+
const resolved = typeof next === 'function' ? next(value) : next
|
|
435
|
+
if (viewportsEqual(resolved, value)) return
|
|
436
|
+
value = resolved
|
|
437
|
+
subs.forEach(cb => cb())
|
|
438
|
+
},
|
|
439
|
+
subscribe(cb) {
|
|
440
|
+
subs.add(cb)
|
|
441
|
+
return () => { subs.delete(cb) }
|
|
442
|
+
},
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A store whose identity is stable for the lifetime of the component. */
|
|
447
|
+
export function useViewportStore(): ViewportStore {
|
|
448
|
+
const ref = useRef<ViewportStore | null>(null)
|
|
449
|
+
if (ref.current === null) ref.current = createViewportStore()
|
|
450
|
+
return ref.current
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Subscribe a component to the viewport. Only display chrome (the zoom badge)
|
|
454
|
+
* should use this — the canvas itself reads `store.get()` inside its draw. */
|
|
455
|
+
export function useViewportValue(store: ViewportStore): Viewport {
|
|
456
|
+
return useSyncExternalStore(store.subscribe, store.get, store.get)
|
|
457
|
+
}
|