@bycrux/editor 0.11.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's ONE Web Audio context, and the gesture-anchored resume that keeps
|
|
3
|
+
* it running.
|
|
4
|
+
*
|
|
5
|
+
* SP4 T4 **moved** this out of `useVideoPlayback.ts` — it is not a copy. Both
|
|
6
|
+
* playback paths need the identical context and the identical resume rule:
|
|
7
|
+
*
|
|
8
|
+
* - the legacy `<video>` player wires every slot and every audio lane
|
|
9
|
+
* through `MediaElementSource → GainNode → ctx.destination`, so a
|
|
10
|
+
* suspended context silently kills audio *and* video frame production;
|
|
11
|
+
* - the WebCodecs engine (`engine/audio-clock.ts`) drives its MASTER CLOCK
|
|
12
|
+
* off an `AudioWorklet` on the same context, so a suspended context stalls
|
|
13
|
+
* the clock and therefore stalls *painting* — the same failure with a
|
|
14
|
+
* wider blast radius.
|
|
15
|
+
*
|
|
16
|
+
* Two copies of this logic would be a textbook divergence-registry hazard
|
|
17
|
+
* (two implementations of one rule, drifting silently), which is why the
|
|
18
|
+
* extraction is a hard requirement of the engine work rather than a tidy-up.
|
|
19
|
+
*
|
|
20
|
+
* Nothing here closes the context: it is window-scoped on purpose (see
|
|
21
|
+
* `getSharedAudioContext`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Typed extension for the shared AudioContext cached on window. */
|
|
25
|
+
export interface MontajWindow {
|
|
26
|
+
__montajSharedCtx?: AudioContext
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CRITICAL: ALL video slots AND ALL audio tracks AND the engine's master clock
|
|
31
|
+
* share the SAME AudioContext. Fresh AudioContexts start suspended and require
|
|
32
|
+
* a user gesture to resume. Creating separate contexts (one per slot, or a
|
|
33
|
+
* separate one for audio tracks) means those born inside a useEffect have no
|
|
34
|
+
* user-gesture activation — they stay suspended → silent. Worse, video frame
|
|
35
|
+
* production is gated on the wired AudioContext clock running, so a suspended
|
|
36
|
+
* context with a wired <video> produces "video plays but no frames render"
|
|
37
|
+
* after a hard refresh.
|
|
38
|
+
*
|
|
39
|
+
* Stash the single shared context on `window` so it survives strict-mode
|
|
40
|
+
* remounts and is reused across audio tracks, video slots, engine sessions,
|
|
41
|
+
* and re-mounts.
|
|
42
|
+
*/
|
|
43
|
+
export function getSharedAudioContext(): AudioContext {
|
|
44
|
+
const w = window as Window & MontajWindow
|
|
45
|
+
// If the cached context is closed (shouldn't happen, but defensive), recreate.
|
|
46
|
+
if (!w.__montajSharedCtx || w.__montajSharedCtx.state === 'closed') {
|
|
47
|
+
w.__montajSharedCtx = new AudioContext()
|
|
48
|
+
}
|
|
49
|
+
return w.__montajSharedCtx
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the shared context WITHOUT creating one. Callers that need to sample a
|
|
54
|
+
* live property off the context (latency, state) but must not force its
|
|
55
|
+
* creation — because creation off the gesture stack is precisely the failure
|
|
56
|
+
* mode `getSharedAudioContext` exists to make explicit — use this instead.
|
|
57
|
+
* Returns undefined until something on the gesture stack has minted the ctx.
|
|
58
|
+
*/
|
|
59
|
+
export function peekSharedAudioContext(): AudioContext | undefined {
|
|
60
|
+
return (window as Window & MontajWindow).__montajSharedCtx
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* MUST be called from inside a user-gesture handler (keydown, click, etc.).
|
|
65
|
+
* Browsers credit a `resume()` call as gesture-driven only when it happens
|
|
66
|
+
* synchronously inside a gesture-rooted call stack. Calling resume() from
|
|
67
|
+
* a useEffect or a setTimeout silently fails — the context stays suspended.
|
|
68
|
+
*
|
|
69
|
+
* Symptom of a suspended context with a wired <video>: video appears to
|
|
70
|
+
* play (paused=false, currentTime advances internally per the DOM clock)
|
|
71
|
+
* but no frames render and no audio plays — the entire pipeline is gated
|
|
72
|
+
* on the AudioContext clock running. This bites specifically after a page
|
|
73
|
+
* refresh, because SPA navigation carries gesture activation across pages
|
|
74
|
+
* but a hard reload does not.
|
|
75
|
+
*
|
|
76
|
+
* The engine path has the same requirement for a different reason: its master
|
|
77
|
+
* clock counts frames an `AudioWorklet` has actually rendered, and a suspended
|
|
78
|
+
* context renders none — the clock (and the canvas painting that follows it)
|
|
79
|
+
* freezes until something resumes it from a gesture.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately does NOT create the context: this is only ever called from a
|
|
82
|
+
* gesture that is *also* about to use an already-created context, and creating
|
|
83
|
+
* one here would hide the "nobody has wired audio yet" case.
|
|
84
|
+
*/
|
|
85
|
+
export function resumeAudioContextFromGesture() {
|
|
86
|
+
const w = window as Window & MontajWindow
|
|
87
|
+
const ctx: AudioContext | undefined = w.__montajSharedCtx
|
|
88
|
+
if (ctx && ctx.state === 'suspended') {
|
|
89
|
+
// Fire-and-forget; resume() returns a Promise but the gesture-credit
|
|
90
|
+
// happens at the synchronous call site, not when the promise resolves.
|
|
91
|
+
ctx.resume().catch(() => {})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Seconds between the moment a sample is handed to the output device and the
|
|
97
|
+
* moment it becomes audible: the sum of `outputLatency` (the device queue) and
|
|
98
|
+
* `baseLatency` (the graph's own processing lead). The engine's master clock
|
|
99
|
+
* counts *frames consumed* by `process()` — a frame the clock has counted is
|
|
100
|
+
* still `latencySeconds()` away from the ear, and the `<video>` fallback's
|
|
101
|
+
* `currentTime` has the same gap on the same graph, so the value bridged to
|
|
102
|
+
* the painter is `now() - latencySeconds(ctx)` on both paths.
|
|
103
|
+
*
|
|
104
|
+
* Read live on every emit (device switches can change it), clamp ≥ 0, and
|
|
105
|
+
* tolerate missing fields on non-Chromium contexts / test stubs.
|
|
106
|
+
*/
|
|
107
|
+
export function latencySeconds(ctx: Pick<AudioContext, 'outputLatency' | 'baseLatency'>): number {
|
|
108
|
+
const out = Number.isFinite(ctx.outputLatency) ? ctx.outputLatency : 0
|
|
109
|
+
const base = Number.isFinite(ctx.baseLatency) ? ctx.baseLatency : 0
|
|
110
|
+
return Math.max(0, out + base)
|
|
111
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure drag maths
|
|
2
|
+
* Pure drag maths — and the one DOM measurement they depend on — for
|
|
3
|
+
* caption-segment positioning in the preview. Everything here is React-free so
|
|
4
|
+
* it can be unit-tested without mounting CaptionPreview.
|
|
3
5
|
*
|
|
4
6
|
* Why this is NOT `useDragOverlay` generalised
|
|
5
7
|
* --------------------------------------------
|
|
@@ -199,3 +201,91 @@ export function captionDragPatch(
|
|
|
199
201
|
? { offsetX: geom.offsetX, offsetY: geom.offsetY }
|
|
200
202
|
: { scale: geom.scale }
|
|
201
203
|
}
|
|
204
|
+
|
|
205
|
+
// ── Measuring where a caption actually paints ───────────────────────────────
|
|
206
|
+
|
|
207
|
+
/** A rectangle in client (screen) coordinates. */
|
|
208
|
+
export interface CaptionContentRect {
|
|
209
|
+
left: number
|
|
210
|
+
top: number
|
|
211
|
+
width: number
|
|
212
|
+
height: number
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The element a measurement should be scoped to: the subtree a caption
|
|
217
|
+
* template marked with `data-caption-id="<segmentId>"`, or `root` itself when
|
|
218
|
+
* no id was asked for or no marker was found.
|
|
219
|
+
*
|
|
220
|
+
* Matched by walking `[data-caption-id]` and comparing the attribute rather
|
|
221
|
+
* than building a `[data-caption-id="…"]` selector, so a segment id containing
|
|
222
|
+
* a quote or backslash (project.json is hand-editable) can't throw a selector
|
|
223
|
+
* SyntaxError. The subtree is a handful of nodes, so the walk is free.
|
|
224
|
+
*/
|
|
225
|
+
function scopeForSegment(root: HTMLElement, segmentId?: string): HTMLElement {
|
|
226
|
+
if (!segmentId) return root
|
|
227
|
+
for (const el of root.querySelectorAll<HTMLElement>('[data-caption-id]')) {
|
|
228
|
+
if (el.getAttribute('data-caption-id') === segmentId) return el
|
|
229
|
+
}
|
|
230
|
+
return root
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Union of the client rects of every painted text run (and replaced element)
|
|
235
|
+
* under `root` — or, when `segmentId` is given, under just that segment's
|
|
236
|
+
* marked subtree.
|
|
237
|
+
*
|
|
238
|
+
* Deliberately NOT `root.getBoundingClientRect()`: the template's own outermost
|
|
239
|
+
* element is a frame-sized `position: fixed; inset: 0` box (captionOuterStyle),
|
|
240
|
+
* so its rect — and the rect of any wrapper we put around it — is the entire
|
|
241
|
+
* preview, which would put the selection box around the whole frame. Walking to
|
|
242
|
+
* the text nodes is the only markup-agnostic way to find where the caption
|
|
243
|
+
* actually paints, and it costs nothing on a subtree this small.
|
|
244
|
+
*
|
|
245
|
+
* WHY `segmentId`: the templates draw EVERY caption active at an instant now
|
|
246
|
+
* that captions have lanes, so an unscoped walk would union two unrelated
|
|
247
|
+
* captions into one selection box spanning both. Naming the target segment
|
|
248
|
+
* narrows the walk to that caption alone.
|
|
249
|
+
*
|
|
250
|
+
* FALLBACK, deliberately: a template with no `data-caption-id` anywhere — one
|
|
251
|
+
* written before the attribute existed, or supplied by a host — measures the
|
|
252
|
+
* whole root, i.e. exactly the pre-lane behaviour. A missing marker degrades to
|
|
253
|
+
* the old selection box, never to no selection box.
|
|
254
|
+
*
|
|
255
|
+
* Returns null when nothing is painted (e.g. `pop` renders no word between two
|
|
256
|
+
* word windows), which is the signal to hide the selection box entirely.
|
|
257
|
+
*/
|
|
258
|
+
export function measureCaptionContentRect(
|
|
259
|
+
root: HTMLElement,
|
|
260
|
+
segmentId?: string,
|
|
261
|
+
): CaptionContentRect | null {
|
|
262
|
+
const scope = scopeForSegment(root, segmentId)
|
|
263
|
+
const doc = scope.ownerDocument
|
|
264
|
+
const walker = doc.createTreeWalker(scope, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT)
|
|
265
|
+
const range = doc.createRange()
|
|
266
|
+
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity
|
|
267
|
+
|
|
268
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
269
|
+
let rect: DOMRect | null = null
|
|
270
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
271
|
+
if (!node.nodeValue?.trim()) continue
|
|
272
|
+
range.selectNodeContents(node)
|
|
273
|
+
rect = range.getBoundingClientRect()
|
|
274
|
+
} else {
|
|
275
|
+
// Replaced elements paint without text nodes. Everything else is a
|
|
276
|
+
// container whose box is either the frame-sized outer wrapper or a
|
|
277
|
+
// full-width anchor box — including them would defeat the point.
|
|
278
|
+
const tag = (node as Element).tagName?.toUpperCase()
|
|
279
|
+
if (tag !== 'IMG' && tag !== 'SVG' && tag !== 'VIDEO' && tag !== 'CANVAS') continue
|
|
280
|
+
rect = (node as Element).getBoundingClientRect()
|
|
281
|
+
}
|
|
282
|
+
if (!rect || (!rect.width && !rect.height)) continue
|
|
283
|
+
left = Math.min(left, rect.left)
|
|
284
|
+
top = Math.min(top, rect.top)
|
|
285
|
+
right = Math.max(right, rect.right)
|
|
286
|
+
bottom = Math.max(bottom, rect.bottom)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!Number.isFinite(left)) return null
|
|
290
|
+
return { left, top, width: right - left, height: bottom - top }
|
|
291
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP3 fix B2 — proxy playback capability gate + per-session failure suppression.
|
|
3
|
+
*
|
|
4
|
+
* Editing proxies are H.264+Opus in MP4. Not every browser decodes that
|
|
5
|
+
* (Opus-in-MP4 support is not universal — a browser can decode H.264 and
|
|
6
|
+
* Opus separately yet still fail on the muxed combination), and a
|
|
7
|
+
* `<video>` element given an undecodable src fails SILENTLY — a black preview
|
|
8
|
+
* with no event the user can act on. Two defenses, both editor-side so
|
|
9
|
+
* timeline-core stays pure (no environment probing in the resolver):
|
|
10
|
+
*
|
|
11
|
+
* 1. Capability gate: probed once per session. Unsupported ⇒ `gateProxy()`
|
|
12
|
+
* strips `proxySrc` from the item BEFORE src selection, so the tier
|
|
13
|
+
* chain falls through to normalizedSrc/src exactly as if no proxy
|
|
14
|
+
* existed.
|
|
15
|
+
* 2. Failure suppression: when a proxy that passed the probe still fails to
|
|
16
|
+
* decode (corrupt file, dangling pointer after an out-of-band delete),
|
|
17
|
+
* the error handler marks that proxySrc failed for the rest of the
|
|
18
|
+
* session and the player reloads the clip without its proxy tier.
|
|
19
|
+
*
|
|
20
|
+
* jsdom note: the vitest environment stubs `canPlayType` to report support
|
|
21
|
+
* (see src/test-setup.ts) so the existing proxy-selection tests keep meaning;
|
|
22
|
+
* the unsupported path is tested via `__setProxySupportForTests`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// avc1.640028 = High profile @ level 4.0 — libx264's actual default output
|
|
26
|
+
// (High profile; 720p above 30fps needs level 4.0), not Main@3.1. Pinning
|
|
27
|
+
// -profile:v main on the encoder to match a cheaper string would cost
|
|
28
|
+
// bitrate for no benefit, so the declared codec follows the encoder instead.
|
|
29
|
+
const PROXY_MIME = 'video/mp4; codecs="avc1.640028, opus"'
|
|
30
|
+
|
|
31
|
+
let supportedCache: boolean | null = null
|
|
32
|
+
|
|
33
|
+
export function proxyPlaybackSupported(): boolean {
|
|
34
|
+
if (supportedCache !== null) return supportedCache
|
|
35
|
+
if (typeof document === 'undefined') {
|
|
36
|
+
supportedCache = false
|
|
37
|
+
return supportedCache
|
|
38
|
+
}
|
|
39
|
+
let ok = false
|
|
40
|
+
try {
|
|
41
|
+
const ms = (globalThis as { MediaSource?: { isTypeSupported?: (t: string) => boolean } }).MediaSource
|
|
42
|
+
if (ms?.isTypeSupported?.(PROXY_MIME)) {
|
|
43
|
+
ok = true
|
|
44
|
+
} else {
|
|
45
|
+
ok = document.createElement('video').canPlayType(PROXY_MIME) !== ''
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
ok = false
|
|
49
|
+
}
|
|
50
|
+
supportedCache = ok
|
|
51
|
+
return supportedCache
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Test hook: force (true/false) or reset (null) the cached probe result. */
|
|
55
|
+
export function __setProxySupportForTests(value: boolean | null): void {
|
|
56
|
+
supportedCache = value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const failedProxies = new Set<string>()
|
|
60
|
+
|
|
61
|
+
/** Record a proxy file that failed to decode this session. */
|
|
62
|
+
export function markProxyFailed(proxySrc: string): void {
|
|
63
|
+
failedProxies.add(proxySrc)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True when this item's proxy is present, supported, and not failed. */
|
|
67
|
+
export function isProxyUsable(proxySrc: string | undefined): boolean {
|
|
68
|
+
return !!proxySrc && proxyPlaybackSupported() && !failedProxies.has(proxySrc)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Test hook: clear the per-session failure set. */
|
|
72
|
+
export function __clearFailedProxiesForTests(): void {
|
|
73
|
+
failedProxies.clear()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Return the item as-is when its proxy is usable, otherwise a shallow copy
|
|
78
|
+
* with `proxySrc` removed — the input object is never mutated (it is shared
|
|
79
|
+
* project state).
|
|
80
|
+
*/
|
|
81
|
+
export function gateProxy<T extends { proxySrc?: string }>(item: T): T {
|
|
82
|
+
if (!item.proxySrc || isProxyUsable(item.proxySrc)) return item
|
|
83
|
+
const gated = { ...item }
|
|
84
|
+
delete (gated as { proxySrc?: string }).proxySrc
|
|
85
|
+
return gated
|
|
86
|
+
}
|
|
@@ -10,9 +10,15 @@
|
|
|
10
10
|
// the container's own (frame) size, and scale() around center.
|
|
11
11
|
|
|
12
12
|
import type { CSSProperties } from 'react'
|
|
13
|
+
import { geometryFor, toCssBoxPct } from '@bycrux/timeline-core'
|
|
13
14
|
|
|
14
15
|
export interface VideoTransform {
|
|
16
|
+
/** The legacy UNIFORM knob, and still the fallback for both axes. */
|
|
15
17
|
scale?: number
|
|
18
|
+
/** Multiplier on WIDTH. Absent ⇒ falls back to `scale` (then 1). */
|
|
19
|
+
scaleX?: number
|
|
20
|
+
/** Multiplier on HEIGHT. Absent ⇒ falls back to `scale` (then 1). */
|
|
21
|
+
scaleY?: number
|
|
16
22
|
offsetX?: number // percent of frame width
|
|
17
23
|
offsetY?: number // percent of frame height
|
|
18
24
|
}
|
|
@@ -20,25 +26,29 @@ export interface VideoTransform {
|
|
|
20
26
|
// CSS transform for a frame-sized container wrapping the <video>. translate() %
|
|
21
27
|
// is relative to the container (= frame), matching the renderer's frame-percent
|
|
22
28
|
// offset; scale() is around center, matching the renderer's centered box.
|
|
29
|
+
//
|
|
30
|
+
// The two-argument `scale(sx, sy)` is a strict superset of the old one-argument
|
|
31
|
+
// form: a legacy item carrying only `scale` resolves both axes to that same
|
|
32
|
+
// number, so it renders the identical box it always did.
|
|
23
33
|
export function videoTransformContainerStyle(t: VideoTransform): CSSProperties {
|
|
24
|
-
const
|
|
34
|
+
const sx = t.scaleX ?? t.scale ?? 1
|
|
35
|
+
const sy = t.scaleY ?? t.scale ?? 1
|
|
25
36
|
const ox = t.offsetX ?? 0
|
|
26
37
|
const oy = t.offsetY ?? 0
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
// Identity on BOTH axes and no offset — emit nothing rather than an inert
|
|
39
|
+
// CSS transform (which would otherwise create a containing block and a
|
|
40
|
+
// compositing layer for every unmodified clip).
|
|
41
|
+
if (sx === 1 && sy === 1 && ox === 0 && oy === 0) return {}
|
|
42
|
+
return { transform: `translate(${ox}%, ${oy}%) scale(${sx}, ${sy})`, transformOrigin: 'center center' }
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
// The transform box as a frame-relative % rect (left/top/width/height in %).
|
|
32
46
|
// This is the canvas-aspect box the cropped video is contained within; the crop
|
|
33
47
|
// handles for the on-canvas transform are drawn on it.
|
|
48
|
+
//
|
|
49
|
+
// Per-axis scale needs no handling here: `geometryFor` resolves scaleX/scaleY
|
|
50
|
+
// (falling back to `scale`) and `toCssBoxPct` takes width/left from the X scale
|
|
51
|
+
// and height/top from the Y scale, so this inherits the split unchanged.
|
|
34
52
|
export function videoTransformBoxPct(t: VideoTransform): { left: number; top: number; width: number; height: number } {
|
|
35
|
-
|
|
36
|
-
const ox = t.offsetX ?? 0
|
|
37
|
-
const oy = t.offsetY ?? 0
|
|
38
|
-
return {
|
|
39
|
-
width: s * 100,
|
|
40
|
-
height: s * 100,
|
|
41
|
-
left: ((1 - s) / 2) * 100 + ox,
|
|
42
|
-
top: ((1 - s) / 2) * 100 + oy,
|
|
43
|
-
}
|
|
53
|
+
return toCssBoxPct(geometryFor(t, 'video'))
|
|
44
54
|
}
|
|
@@ -2,7 +2,14 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
2
2
|
import type { VisualItem } from '../../schema'
|
|
3
3
|
|
|
4
4
|
export type Corner = 'nw' | 'ne' | 'sw' | 'se'
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Single-axis resize handles. A corner drag scales BOTH axes together (the
|
|
7
|
+
* legacy uniform gesture); an edge drag scales exactly one — `e`/`w` the X
|
|
8
|
+
* axis, `n`/`s` the Y axis. The handles themselves are drawn by a later slice;
|
|
9
|
+
* the hook understands the gesture already so the two can land independently.
|
|
10
|
+
*/
|
|
11
|
+
export type Edge = 'n' | 's' | 'e' | 'w'
|
|
12
|
+
export type DragType = 'move' | `resize-${Corner}` | `resize-${Edge}` | 'rotate'
|
|
6
13
|
|
|
7
14
|
// Shared shape for `onOverlayChange` across the preview layer: drag/resize/rotate
|
|
8
15
|
// gestures (useDragOverlay) only ever populate the geometric subset; content-editing
|
|
@@ -11,7 +18,12 @@ export type DragType = 'move' | `resize-${Corner}` | 'rotate'
|
|
|
11
18
|
export interface OverlayChanges {
|
|
12
19
|
offsetX?: number
|
|
13
20
|
offsetY?: number
|
|
21
|
+
/** The legacy UNIFORM scale. Still written by every corner drag. */
|
|
14
22
|
scale?: number
|
|
23
|
+
/** Per-axis width multiplier. Absent ⇒ the item keeps falling back to `scale`. */
|
|
24
|
+
scaleX?: number
|
|
25
|
+
/** Per-axis height multiplier. Absent ⇒ the item keeps falling back to `scale`. */
|
|
26
|
+
scaleY?: number
|
|
15
27
|
rotation?: number
|
|
16
28
|
fit?: 'cover' | 'contain' | 'fill'
|
|
17
29
|
sourceCrop?: VisualItem['sourceCrop']
|
|
@@ -35,7 +47,20 @@ interface DragState {
|
|
|
35
47
|
initY: number
|
|
36
48
|
initOffsetX: number
|
|
37
49
|
initOffsetY: number
|
|
50
|
+
/** The RESOLVED uniform scale at gesture start. */
|
|
38
51
|
initScale: number
|
|
52
|
+
/**
|
|
53
|
+
* The RESOLVED per-axis scales at gesture start. Both equal `initScale` on a
|
|
54
|
+
* uniform item, so omitting them (they default to `initScale`) is always the
|
|
55
|
+
* legacy behavior.
|
|
56
|
+
*/
|
|
57
|
+
initScaleX?: number
|
|
58
|
+
initScaleY?: number
|
|
59
|
+
/**
|
|
60
|
+
* Whether the ITEM ITSELF carries `scaleX`/`scaleY`, as opposed to inheriting
|
|
61
|
+
* both from the legacy uniform `scale`. Decides the commit shape — see `onUp`.
|
|
62
|
+
*/
|
|
63
|
+
initHasPerAxis?: boolean
|
|
39
64
|
initRotation: number
|
|
40
65
|
// rotate-specific: center of element in page coords and initial angle
|
|
41
66
|
cx?: number
|
|
@@ -50,7 +75,10 @@ export function useDragOverlay(
|
|
|
50
75
|
const [dragState, setDragState] = useState<DragState | null>(null)
|
|
51
76
|
|
|
52
77
|
const [liveOffset, setLiveOffset] = useState<{ id: string; x: number; y: number } | null>(null)
|
|
53
|
-
|
|
78
|
+
// `scale` is the uniform value (only a CORNER drag moves it); `scaleX`/`scaleY`
|
|
79
|
+
// are what the preview actually renders, so an edge drag can move one axis
|
|
80
|
+
// without the other. On a uniform item all three stay equal.
|
|
81
|
+
const [liveScale, setLiveScale] = useState<{ id: string; scale: number; scaleX: number; scaleY: number } | null>(null)
|
|
54
82
|
const [liveRotation, setLiveRotation] = useState<{ id: string; rotation: number } | null>(null)
|
|
55
83
|
const liveOffsetRef = useRef<typeof liveOffset>(null)
|
|
56
84
|
const liveScaleRef = useRef<typeof liveScale>(null)
|
|
@@ -80,13 +108,18 @@ export function useDragOverlay(
|
|
|
80
108
|
const rawX = dragState.initOffsetX + dx
|
|
81
109
|
const rawY = dragState.initOffsetY + dy
|
|
82
110
|
|
|
83
|
-
// Edge snap positions depend on scale.
|
|
84
|
-
// Element is inset-0 (fills container) then scaled from center
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
111
|
+
// Edge snap positions depend on scale, PER AXIS.
|
|
112
|
+
// Element is inset-0 (fills container) then scaled from center, so its half-width is
|
|
113
|
+
// sx/2 of the frame and its half-height sy/2.
|
|
114
|
+
// Left edge hits screen left / right edge hits screen right when offsetX = ±(0.5 - sx/2)*100;
|
|
115
|
+
// top/bottom likewise at ±(0.5 - sy/2)*100.
|
|
116
|
+
// For a scale of 1 that is 0 (same as center snap), so edge snap only activates for an axis
|
|
117
|
+
// that is scaled DOWN — and with a non-uniform item the axes decide independently, e.g. a
|
|
118
|
+
// full-width-but-squat item gets top/bottom edge snap and no left/right snap at all.
|
|
119
|
+
const sx = dragState.initScaleX ?? dragState.initScale
|
|
120
|
+
const sy = dragState.initScaleY ?? dragState.initScale
|
|
121
|
+
const edgeX = (0.5 - sx / 2) * 100 // offset where the element's left/right edge meets the frame's
|
|
122
|
+
const edgeY = (0.5 - sy / 2) * 100 // offset where the element's top/bottom edge meets the frame's
|
|
90
123
|
const hasEdgeX = edgeX > SNAP_THRESHOLD // skip if too close to center snap
|
|
91
124
|
const hasEdgeY = edgeY > SNAP_THRESHOLD
|
|
92
125
|
|
|
@@ -140,13 +173,37 @@ export function useDragOverlay(
|
|
|
140
173
|
setLiveRotation(next)
|
|
141
174
|
liveRotationRef.current = next
|
|
142
175
|
} else {
|
|
143
|
-
// Resize from corner
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
176
|
+
// Resize from a corner ('resize-se' → 'se') or an edge ('resize-e' → 'e').
|
|
177
|
+
const handle = dragState.type.slice(7) as Corner | Edge
|
|
178
|
+
// dirX/dirY are DIRECTION SIGNS (+1/-1), never scales: dragging the east
|
|
179
|
+
// side rightwards grows the item, dragging the west side rightwards
|
|
180
|
+
// shrinks it. Deliberately NOT named sx/sy — those mean per-axis SCALE
|
|
181
|
+
// everywhere else in this hook, and conflating the two silently inverts
|
|
182
|
+
// the drag direction.
|
|
183
|
+
const dirX = handle.includes('e') ? 1 : -1
|
|
184
|
+
const dirY = handle.includes('s') ? 1 : -1
|
|
185
|
+
|
|
186
|
+
const baseX = dragState.initScaleX ?? dragState.initScale
|
|
187
|
+
const baseY = dragState.initScaleY ?? dragState.initScale
|
|
188
|
+
const isCorner = handle.length === 2
|
|
189
|
+
|
|
190
|
+
let nextScaleX = baseX
|
|
191
|
+
let nextScaleY = baseY
|
|
192
|
+
let nextScale = dragState.initScale
|
|
193
|
+
if (isCorner) {
|
|
194
|
+
// Both axes by the SAME delta — identical to the pre-per-axis formula,
|
|
195
|
+
// so a uniform item's corner drag feels exactly as it always did.
|
|
196
|
+
const delta = (dx * dirX + dy * dirY) / 100
|
|
197
|
+
nextScaleX = Math.max(0.1, baseX * (1 + delta))
|
|
198
|
+
nextScaleY = Math.max(0.1, baseY * (1 + delta))
|
|
199
|
+
nextScale = Math.max(0.1, dragState.initScale * (1 + delta))
|
|
200
|
+
} else if (handle === 'e' || handle === 'w') {
|
|
201
|
+
nextScaleX = Math.max(0.1, baseX * (1 + (dx * dirX) / 100))
|
|
202
|
+
} else {
|
|
203
|
+
nextScaleY = Math.max(0.1, baseY * (1 + (dy * dirY) / 100))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const next = { id: dragState.id, scale: nextScale, scaleX: nextScaleX, scaleY: nextScaleY }
|
|
150
207
|
setLiveScale(next)
|
|
151
208
|
liveScaleRef.current = next
|
|
152
209
|
}
|
|
@@ -156,9 +213,26 @@ export function useDragOverlay(
|
|
|
156
213
|
const lo = liveOffsetRef.current
|
|
157
214
|
const ls = liveScaleRef.current
|
|
158
215
|
const lr = liveRotationRef.current
|
|
159
|
-
const changes:
|
|
216
|
+
const changes: OverlayChanges = {}
|
|
160
217
|
if (lo) { changes.offsetX = lo.x; changes.offsetY = lo.y }
|
|
161
|
-
if (ls) {
|
|
218
|
+
if (ls) {
|
|
219
|
+
changes.scale = ls.scale
|
|
220
|
+
// Commit shape is deliberately asymmetric, to keep legacy projects legacy:
|
|
221
|
+
// a CORNER drag of an item that has never carried scaleX/scaleY is the old
|
|
222
|
+
// uniform gesture and writes ONLY `scale`. Writing per-axis fields there
|
|
223
|
+
// would migrate every existing item to per-axis storage the first time
|
|
224
|
+
// anyone nudged it — a silent schema change for zero user benefit, since
|
|
225
|
+
// both axes would just hold the same number.
|
|
226
|
+
// An EDGE drag is per-axis by definition, and an item that ALREADY carries
|
|
227
|
+
// scaleX/scaleY is already per-axis (and would be corrupted by a
|
|
228
|
+
// scale-only commit, because `scaleX ?? scale` means the stale scaleX
|
|
229
|
+
// would keep winning). Both of those write both axes.
|
|
230
|
+
const handle = dragState!.type.startsWith('resize-') ? dragState!.type.slice(7) : ''
|
|
231
|
+
if (dragState!.initHasPerAxis || handle.length === 1) {
|
|
232
|
+
changes.scaleX = ls.scaleX
|
|
233
|
+
changes.scaleY = ls.scaleY
|
|
234
|
+
}
|
|
235
|
+
}
|
|
162
236
|
if (lr) { changes.rotation = lr.rotation }
|
|
163
237
|
if (Object.keys(changes).length) onOverlayChange?.(dragState!.id, changes)
|
|
164
238
|
setDragState(null)
|