@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
|
@@ -1,18 +1,29 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
|
2
|
+
import { containsTime, geometryAt, geometryFor } from '@bycrux/timeline-core'
|
|
3
|
+
import { isProxyUsable, markProxyFailed } from './proxySupport'
|
|
2
4
|
import type { EditorProject as Project, VisualItem } from '../../schema'
|
|
3
5
|
import type { OverlayFactory } from '../../types'
|
|
4
6
|
import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
|
|
5
7
|
import { getOverlayDesignCanvas } from '../design-canvas'
|
|
6
8
|
import { ensureGoogleFontsLoaded } from '../../lib/google-fonts'
|
|
7
|
-
import type { Corner, OverlayChanges } from './useDragOverlay'
|
|
9
|
+
import type { Corner, Edge, OverlayChanges } from './useDragOverlay'
|
|
8
10
|
import type { useDragOverlay } from './useDragOverlay'
|
|
11
|
+
import { enabledTrackItems } from '../timeline/timeline-model'
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
// Mount video items this many seconds before item.start so the frame is ready.
|
|
14
|
+
//
|
|
15
|
+
// This pre-mount window is deliberately NOT part of @bycrux/timeline-core's
|
|
16
|
+
// activation predicate: a pre-mounted item is rendered at opacity 0 and is not
|
|
17
|
+
// "on screen" in any sense the renderer or sample-frame would agree with. It
|
|
18
|
+
// exists purely to give the browser time to decode the first frame so the item
|
|
19
|
+
// doesn't flash in — presentation, not activation. `containsTime` (the shared
|
|
20
|
+
// predicate) still decides `visible`; see timeline-core/index.d.ts.
|
|
21
|
+
const VIDEO_PRELOAD_S = 0.4
|
|
11
22
|
|
|
12
23
|
// Synced video overlay — seeks to the correct position within the item's inPoint/outPoint range
|
|
13
|
-
function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted, visible }: {
|
|
14
|
-
src: string; currentTime: number; itemStart: number; inPoint: number
|
|
15
|
-
isPlaying: boolean; muted?: boolean; visible: boolean
|
|
24
|
+
function OverlayVideo({ src, currentTime, itemStart, inPoint, speed = 1, isPlaying, muted, visible, onSrcError }: {
|
|
25
|
+
src: string; currentTime: number; itemStart: number; inPoint: number; speed?: number
|
|
26
|
+
isPlaying: boolean; muted?: boolean; visible: boolean; onSrcError?: () => void
|
|
16
27
|
}) {
|
|
17
28
|
const ref = useRef<HTMLVideoElement>(null)
|
|
18
29
|
// Refs so the onSeeked handler can read current playback intent without stale closures
|
|
@@ -28,7 +39,11 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted,
|
|
|
28
39
|
useEffect(() => {
|
|
29
40
|
const v = ref.current
|
|
30
41
|
if (!v) return
|
|
31
|
-
|
|
42
|
+
// Source time = inPoint + S·(projectTime − start), matching timeline-core's
|
|
43
|
+
// `seekTime`. Strict no-op at S=1. A sped/slowed overlay walks its source
|
|
44
|
+
// faster/slower than project time, so without S it would seek to the wrong
|
|
45
|
+
// frame (and, past S=1, run off the end).
|
|
46
|
+
const target = Math.max(inPoint, inPoint + speed * (currentTime - itemStart))
|
|
32
47
|
v.currentTime = target
|
|
33
48
|
}, [])
|
|
34
49
|
|
|
@@ -40,24 +55,31 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted,
|
|
|
40
55
|
const v = ref.current
|
|
41
56
|
if (!v) return
|
|
42
57
|
if (v.readyState < 2) return
|
|
43
|
-
const target = inPoint + (currentTime - itemStart)
|
|
58
|
+
const target = inPoint + speed * (currentTime - itemStart)
|
|
44
59
|
const drift = Math.abs(v.currentTime - target)
|
|
45
60
|
if (!v.paused && drift < 1.5) return
|
|
46
61
|
if (drift > 0.3) {
|
|
47
62
|
v.currentTime = Math.max(inPoint, target)
|
|
48
63
|
}
|
|
49
|
-
}, [currentTime, itemStart, inPoint])
|
|
64
|
+
}, [currentTime, itemStart, inPoint, speed])
|
|
50
65
|
|
|
51
66
|
// Play/pause sync — only play when visible; pause when pre-loading or past end
|
|
52
67
|
useEffect(() => {
|
|
53
68
|
const v = ref.current
|
|
54
69
|
if (!v) return
|
|
70
|
+
// Play at S× so one project-second consumes S source-seconds — keeps the
|
|
71
|
+
// element in step with project time instead of drifting until a re-seek
|
|
72
|
+
// fires. `preservesPitch` matters only for an audible overlay; harmless
|
|
73
|
+
// otherwise. Set before play(); a fresh src load resets rate to 1, so the
|
|
74
|
+
// dependency list re-runs this whenever speed changes under us.
|
|
75
|
+
v.playbackRate = speed
|
|
76
|
+
v.preservesPitch = true
|
|
55
77
|
if (isPlaying && visible) {
|
|
56
78
|
v.play().catch(() => {})
|
|
57
79
|
} else {
|
|
58
80
|
v.pause()
|
|
59
81
|
}
|
|
60
|
-
}, [isPlaying, visible])
|
|
82
|
+
}, [isPlaying, visible, speed])
|
|
61
83
|
|
|
62
84
|
return (
|
|
63
85
|
<video
|
|
@@ -68,6 +90,7 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted,
|
|
|
68
90
|
src={src}
|
|
69
91
|
muted={muted}
|
|
70
92
|
preload="auto"
|
|
93
|
+
onError={onSrcError}
|
|
71
94
|
onSeeked={() => {
|
|
72
95
|
// After a mid-clip seek the browser may have paused to buffer — restart if we should be playing
|
|
73
96
|
const v = ref.current
|
|
@@ -172,8 +195,9 @@ function CustomOverlay({
|
|
|
172
195
|
}, [src, watchFile, compile])
|
|
173
196
|
|
|
174
197
|
// Deep-clone/rewrite the props once per props change instead of every frame.
|
|
175
|
-
// Live prop edits (
|
|
176
|
-
// produce a new `props` object reference, so this recomputes on every
|
|
198
|
+
// Live prop edits (the panel's Content tab → VideoEditor.withItemProps)
|
|
199
|
+
// always produce a new `props` object reference, so this recomputes on every
|
|
200
|
+
// edit.
|
|
177
201
|
const resolvedProps = useMemo(
|
|
178
202
|
() => resolveOverlayPropPaths(props, fileUrl) as Record<string, unknown>,
|
|
179
203
|
[props, fileUrl],
|
|
@@ -198,56 +222,97 @@ function CustomOverlay({
|
|
|
198
222
|
}
|
|
199
223
|
|
|
200
224
|
// ---------------------------------------------------------------------------
|
|
201
|
-
//
|
|
225
|
+
// Selection chrome — box, resize handles, rotate handle, snap guides
|
|
202
226
|
// ---------------------------------------------------------------------------
|
|
203
227
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
228
|
+
// Everything the selection draws reads the host theme's selection colour, so a
|
|
229
|
+
// selected OVERLAY and a selected base CLIP are the same object to the eye. The
|
|
230
|
+
// clip's box is PreviewPlayer.tsx (~:526-559): a 2px outline plus 12px white
|
|
231
|
+
// squares with a 1.5px selection-coloured border. These constants exist so the
|
|
232
|
+
// two can't drift into "nearly the same" — change the look in one place.
|
|
233
|
+
const SELECTION = 'var(--editor-selection)'
|
|
234
|
+
const SELECTION_OUTLINE = `2px solid ${SELECTION}`
|
|
235
|
+
const HANDLE_PX = 12
|
|
236
|
+
|
|
237
|
+
/** White square with a selection-coloured border — the clip box's handle. */
|
|
238
|
+
const HANDLE_FACE: React.CSSProperties = {
|
|
239
|
+
width: HANDLE_PX,
|
|
240
|
+
height: HANDLE_PX,
|
|
241
|
+
backgroundColor: '#fff',
|
|
242
|
+
border: `1.5px solid ${SELECTION}`,
|
|
243
|
+
borderRadius: 2,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Eight handles: four corners scale BOTH axes together, four edge midpoints
|
|
247
|
+
// scale exactly one (`useDragOverlay` maps `resize-e`/`resize-w` to X and
|
|
248
|
+
// `resize-n`/`resize-s` to Y). Ordered corners-then-edges only for readability.
|
|
249
|
+
const RESIZE_HANDLES: Array<Corner | Edge> = ['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w']
|
|
250
|
+
|
|
251
|
+
const HANDLE_CURSOR: Record<Corner | Edge, string> = {
|
|
252
|
+
nw: 'cursor-nw-resize', ne: 'cursor-ne-resize',
|
|
253
|
+
sw: 'cursor-sw-resize', se: 'cursor-se-resize',
|
|
254
|
+
n: 'cursor-ns-resize', s: 'cursor-ns-resize',
|
|
255
|
+
e: 'cursor-ew-resize', w: 'cursor-ew-resize',
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Anchor point on the item's bounding box, in % of the box. */
|
|
259
|
+
const HANDLE_ANCHOR: Record<Corner | Edge, [number, number]> = {
|
|
260
|
+
nw: [0, 0], n: [50, 0], ne: [100, 0],
|
|
261
|
+
w: [0, 50], e: [100, 50],
|
|
262
|
+
sw: [0, 100], s: [50, 100], se: [100, 100],
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Every chrome element inside the item's wrapper inherits the wrapper's
|
|
266
|
+
// `scale(scaleX, scaleY)`, so each counter-scales by the INVERSE OF BOTH AXES to
|
|
267
|
+
// stay a constant, SQUARE visual size. A single `scale(1/s)` would leave the
|
|
268
|
+
// handles visibly stretched into rectangles on a non-uniformly scaled item.
|
|
269
|
+
function ResizeHandle({ handle, scaleX, scaleY, onMouseDown }: {
|
|
270
|
+
handle: Corner | Edge
|
|
271
|
+
scaleX: number
|
|
272
|
+
scaleY: number
|
|
207
273
|
onMouseDown: (e: React.MouseEvent) => void
|
|
208
274
|
}) {
|
|
209
|
-
const
|
|
210
|
-
nw: 'cursor-nw-resize', ne: 'cursor-ne-resize',
|
|
211
|
-
sw: 'cursor-sw-resize', se: 'cursor-se-resize',
|
|
212
|
-
}[corner]
|
|
213
|
-
|
|
214
|
-
const posClass = {
|
|
215
|
-
nw: 'top-0 left-0', ne: 'top-0 right-0',
|
|
216
|
-
sw: 'bottom-0 left-0', se: 'bottom-0 right-0',
|
|
217
|
-
}[corner]
|
|
218
|
-
|
|
219
|
-
// L-shaped bracket: show only the two relevant border sides
|
|
220
|
-
const borderClass = {
|
|
221
|
-
nw: 'border-t-2 border-l-2',
|
|
222
|
-
ne: 'border-t-2 border-r-2',
|
|
223
|
-
sw: 'border-b-2 border-l-2',
|
|
224
|
-
se: 'border-b-2 border-r-2',
|
|
225
|
-
}[corner]
|
|
226
|
-
|
|
227
|
-
// Inverse scale so handle stays constant visual size; origin at the corner itself
|
|
228
|
-
const origin = `${corner.includes('n') ? 'top' : 'bottom'} ${corner.includes('w') ? 'left' : 'right'}`
|
|
229
|
-
|
|
275
|
+
const [ax, ay] = HANDLE_ANCHOR[handle]
|
|
230
276
|
return (
|
|
231
277
|
<div
|
|
232
|
-
|
|
233
|
-
|
|
278
|
+
data-handle={handle}
|
|
279
|
+
className={`absolute z-50 ${HANDLE_CURSOR[handle]}`}
|
|
280
|
+
style={{
|
|
281
|
+
...HANDLE_FACE,
|
|
282
|
+
// `left`/`top` put the handle's TOP-LEFT on the anchor point, and the
|
|
283
|
+
// origin is pinned there too — so the transform below is measured from
|
|
284
|
+
// the anchor, not from the handle's own centre.
|
|
285
|
+
left: `${ax}%`,
|
|
286
|
+
top: `${ay}%`,
|
|
287
|
+
transformOrigin: '0 0',
|
|
288
|
+
// Order is load-bearing: `scale` OUTSIDE `translate`. The -50%/-50%
|
|
289
|
+
// resolves against the handle's own 12px box, and only in this order
|
|
290
|
+
// does the wrapper's scale(sx, sy) cancel out of BOTH the size and the
|
|
291
|
+
// centring offset — leaving a constant 12px square centred on the
|
|
292
|
+
// anchor at any scale. `translate(...) scale(...)` would leave the
|
|
293
|
+
// centring offset scaled and walk the handle off the box.
|
|
294
|
+
transform: `scale(${1 / scaleX}, ${1 / scaleY}) translate(-50%, -50%)`,
|
|
295
|
+
}}
|
|
234
296
|
onMouseDown={onMouseDown}
|
|
235
297
|
/>
|
|
236
298
|
)
|
|
237
299
|
}
|
|
238
300
|
|
|
239
|
-
function RotateHandle({
|
|
240
|
-
|
|
301
|
+
function RotateHandle({ scaleX, scaleY, onMouseDown }: {
|
|
302
|
+
scaleX: number
|
|
303
|
+
scaleY: number
|
|
241
304
|
onMouseDown: (e: React.MouseEvent) => void
|
|
242
305
|
}) {
|
|
243
306
|
return (
|
|
244
307
|
<div
|
|
245
308
|
className="absolute top-0 left-1/2 z-50 cursor-grab flex flex-col items-center"
|
|
246
|
-
style={{ transform: `translateX(-50%) translateY(-100%) scale(${1 /
|
|
309
|
+
style={{ transform: `translateX(-50%) translateY(-100%) scale(${1 / scaleX}, ${1 / scaleY})`, transformOrigin: 'bottom center' }}
|
|
247
310
|
onMouseDown={onMouseDown}
|
|
248
311
|
>
|
|
249
|
-
|
|
250
|
-
|
|
312
|
+
{/* Same white square as a resize handle, lifted clear of the box on a
|
|
313
|
+
short stalk so it never collides with the `n` edge handle. */}
|
|
314
|
+
<div style={HANDLE_FACE} />
|
|
315
|
+
<div style={{ width: 1, height: HANDLE_PX, backgroundColor: SELECTION }} />
|
|
251
316
|
</div>
|
|
252
317
|
)
|
|
253
318
|
}
|
|
@@ -256,15 +321,23 @@ function RotateHandle({ scale, onMouseDown }: {
|
|
|
256
321
|
// image's bounding box; counter-scales so it stays a constant size regardless of
|
|
257
322
|
// the item's scale. 'fill' is the legacy stretch behavior (kept for opt-in).
|
|
258
323
|
const FIT_OPTIONS: Array<'cover' | 'contain' | 'fill'> = ['cover', 'contain', 'fill']
|
|
259
|
-
function FitControl({ value,
|
|
324
|
+
function FitControl({ value, scaleX, scaleY, onChange }: {
|
|
260
325
|
value: 'cover' | 'contain' | 'fill'
|
|
261
|
-
|
|
326
|
+
scaleX: number
|
|
327
|
+
scaleY: number
|
|
262
328
|
onChange: (fit: 'cover' | 'contain' | 'fill') => void
|
|
263
329
|
}) {
|
|
264
330
|
return (
|
|
265
331
|
<div
|
|
266
|
-
|
|
267
|
-
|
|
332
|
+
// Part of the selection treatment (it only exists while the item is
|
|
333
|
+
// selected), so it rides the same token as the box and handles rather
|
|
334
|
+
// than keeping an accent of its own.
|
|
335
|
+
className="absolute bottom-0 left-1/2 z-50 flex gap-px rounded bg-black/70 border overflow-hidden"
|
|
336
|
+
style={{
|
|
337
|
+
borderColor: SELECTION,
|
|
338
|
+
transform: `translateX(-50%) translateY(140%) scale(${1 / scaleX}, ${1 / scaleY})`,
|
|
339
|
+
transformOrigin: 'top center',
|
|
340
|
+
}}
|
|
268
341
|
onMouseDown={(e) => e.stopPropagation()}
|
|
269
342
|
>
|
|
270
343
|
{FIT_OPTIONS.map(opt => (
|
|
@@ -273,8 +346,9 @@ function FitControl({ value, scale, onChange }: {
|
|
|
273
346
|
type="button"
|
|
274
347
|
onClick={(e) => { e.stopPropagation(); onChange(opt) }}
|
|
275
348
|
className={`px-2 py-1 text-[11px] font-mono capitalize ${
|
|
276
|
-
value === opt ? '
|
|
349
|
+
value === opt ? 'text-black' : 'text-gray-300 hover:bg-white/10'
|
|
277
350
|
}`}
|
|
351
|
+
style={value === opt ? { backgroundColor: SELECTION } : undefined}
|
|
278
352
|
>
|
|
279
353
|
{opt}
|
|
280
354
|
</button>
|
|
@@ -338,55 +412,83 @@ export default function OverlayItemsLayer({
|
|
|
338
412
|
fileUrl,
|
|
339
413
|
}: OverlayItemsLayerProps) {
|
|
340
414
|
const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
|
|
415
|
+
// SP3 fix B2: re-render trigger for proxy decode failures — marking a proxy
|
|
416
|
+
// failed flips isProxyUsable() below, swapping the overlay video back to its
|
|
417
|
+
// master src on the forced re-render.
|
|
418
|
+
const [, bumpProxyFail] = useReducer((x: number) => x + 1, 0)
|
|
341
419
|
|
|
342
420
|
// Interactive tracks — in canvas mode this includes track 0; otherwise overlays only.
|
|
343
|
-
|
|
421
|
+
// Enabled only: a skipped track must not be draggable in the preview either.
|
|
422
|
+
const interactiveTracks = isCanvasProject ? enabledTrackItems(project) : overlayTracks
|
|
344
423
|
|
|
345
424
|
return (
|
|
346
425
|
<>
|
|
347
426
|
{/* tracks[0] non-video items (background images) — rendered with drag support at base z-level */}
|
|
348
427
|
{!isCanvasProject && tracks0NonVideo.map((item) => {
|
|
349
428
|
if (item.type !== 'image' || !item.src) return null
|
|
350
|
-
const visible =
|
|
429
|
+
const visible = containsTime(item.start, item.end, currentTime)
|
|
351
430
|
if (!visible) return null
|
|
352
431
|
const isSel = selectedOverlayId === item.id
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
432
|
+
// Persisted geometry from the resolver; live drag state layered on top.
|
|
433
|
+
// The resolver only ever sees the SAVED project, so it cannot know about
|
|
434
|
+
// an in-flight drag — `liveOffset`/`liveScale`/`liveRotation` must keep
|
|
435
|
+
// winning, with `geometryFor` supplying the base each falls back to.
|
|
436
|
+
// Animated for the same reason as the items branch below: the renderer
|
|
437
|
+
// composites tracks[0] images through the very same
|
|
438
|
+
// buildImageItemFilterParts, which since SP9d compiles their curves into
|
|
439
|
+
// ffmpeg expressions. Leaving this branch on the static resolve would
|
|
440
|
+
// put back a preview/render divergence in the one place nobody would
|
|
441
|
+
// look for it. Opacity stays static here too — ffmpeg cannot vary it.
|
|
442
|
+
const gAnimated = geometryAt(item, 'image', currentTime - item.start)
|
|
443
|
+
const g = { ...gAnimated, opacity: geometryFor(item, 'image').opacity }
|
|
444
|
+
const fit = g.fit ?? 'cover'
|
|
445
|
+
const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? g.offsetX
|
|
446
|
+
const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? g.offsetY
|
|
447
|
+
const scale = (liveScale?.id === item.id ? liveScale.scale : null) ?? g.scale
|
|
448
|
+
// The rendered box is per-axis; `scale` above stays the uniform value the
|
|
449
|
+
// drag state is seeded with. `g.scaleX`/`g.scaleY` already fall back to
|
|
450
|
+
// `g.scale`, so a legacy scale-only item resolves both to the same number.
|
|
451
|
+
const scaleX = (liveScale?.id === item.id ? liveScale.scaleX : null) ?? g.scaleX
|
|
452
|
+
const scaleY = (liveScale?.id === item.id ? liveScale.scaleY : null) ?? g.scaleY
|
|
453
|
+
const rotation = (liveRotation?.id === item.id ? liveRotation.rotation : null) ?? g.rotation
|
|
454
|
+
const hasPerAxis = item.scaleX != null || item.scaleY != null
|
|
357
455
|
const wrapperStyle: React.CSSProperties = {
|
|
358
|
-
transform: `translate(${offsetX}%, ${offsetY}%) rotate(${rotation}deg) scale(${
|
|
456
|
+
transform: `translate(${offsetX}%, ${offsetY}%) rotate(${rotation}deg) scale(${scaleX}, ${scaleY})`,
|
|
359
457
|
transformOrigin: 'center center',
|
|
360
458
|
// Raise above play/pause div (z=10) when selected so pointer events land here
|
|
361
459
|
zIndex: isSel ? 11 : 2,
|
|
362
|
-
opacity:
|
|
460
|
+
opacity: g.opacity,
|
|
461
|
+
// Selection box — the same 2px token outline a selected base clip
|
|
462
|
+
// gets (PreviewPlayer.tsx ~:536), so the two read as one treatment.
|
|
463
|
+
...(isSel ? { outline: SELECTION_OUTLINE } : null),
|
|
363
464
|
}
|
|
364
465
|
const wrapperClass = `absolute inset-0 ${
|
|
365
466
|
isSel
|
|
366
|
-
?
|
|
467
|
+
? (dragState?.type === 'move' ? 'cursor-grabbing' : 'cursor-grab')
|
|
367
468
|
: 'pointer-events-none'
|
|
368
469
|
}`
|
|
470
|
+
const initGeom = { initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initScaleX: scaleX, initScaleY: scaleY, initHasPerAxis: hasPerAxis, initRotation: rotation }
|
|
369
471
|
function startMove(e: React.MouseEvent) {
|
|
370
472
|
if (!isSel) return
|
|
371
473
|
e.stopPropagation()
|
|
372
|
-
setDragState({ id: item.id, type: 'move', initX: e.clientX, initY: e.clientY,
|
|
474
|
+
setDragState({ id: item.id, type: 'move', initX: e.clientX, initY: e.clientY, ...initGeom })
|
|
373
475
|
}
|
|
374
476
|
const handles = isSel && (
|
|
375
477
|
<>
|
|
376
|
-
{
|
|
377
|
-
<
|
|
478
|
+
{RESIZE_HANDLES.map(h => (
|
|
479
|
+
<ResizeHandle key={h} handle={h} scaleX={scaleX} scaleY={scaleY} onMouseDown={(e) => {
|
|
378
480
|
e.stopPropagation()
|
|
379
|
-
setDragState({ id: item.id, type: `resize-${
|
|
481
|
+
setDragState({ id: item.id, type: `resize-${h}`, initX: e.clientX, initY: e.clientY, ...initGeom })
|
|
380
482
|
}} />
|
|
381
483
|
))}
|
|
382
|
-
<RotateHandle
|
|
484
|
+
<RotateHandle scaleX={scaleX} scaleY={scaleY} onMouseDown={(e) => {
|
|
383
485
|
e.stopPropagation()
|
|
384
486
|
const rect = containerRef.current?.getBoundingClientRect()
|
|
385
487
|
if (!rect) return
|
|
386
488
|
const cx = rect.left + rect.width * (0.5 + offsetX / 100)
|
|
387
489
|
const cy = rect.top + rect.height * (0.5 + offsetY / 100)
|
|
388
490
|
const initAngle = Math.atan2(e.clientY - cy, e.clientX - cx)
|
|
389
|
-
setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY,
|
|
491
|
+
setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY, ...initGeom, cx, cy, initAngle })
|
|
390
492
|
}} />
|
|
391
493
|
</>
|
|
392
494
|
)
|
|
@@ -396,11 +498,11 @@ export default function OverlayItemsLayer({
|
|
|
396
498
|
src={fileUrl(item.src)}
|
|
397
499
|
draggable={false}
|
|
398
500
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
|
399
|
-
style={{ objectFit:
|
|
501
|
+
style={{ objectFit: fit }}
|
|
400
502
|
/>
|
|
401
503
|
{handles}
|
|
402
504
|
{isSel && onOverlayChange && (
|
|
403
|
-
<FitControl value={
|
|
505
|
+
<FitControl value={fit} scaleX={scaleX} scaleY={scaleY} onChange={(next) => onOverlayChange(item.id, { fit: next })} />
|
|
404
506
|
)}
|
|
405
507
|
</div>
|
|
406
508
|
)
|
|
@@ -409,29 +511,68 @@ export default function OverlayItemsLayer({
|
|
|
409
511
|
{/* All interactive tracks — in canvas mode this includes track 0; otherwise overlays only */}
|
|
410
512
|
{interactiveTracks.map((trackItems, trackIdx) =>
|
|
411
513
|
trackItems.map((item) => {
|
|
412
|
-
|
|
413
|
-
|
|
514
|
+
// Activation: the shared half-open `start <= t < end` predicate.
|
|
515
|
+
const visible = containsTime(item.start, item.end, currentTime)
|
|
516
|
+
// Presentation-only pre-mount window — see VIDEO_PRELOAD_S above for
|
|
517
|
+
// why it is not (and must not be) a resolver concern. A pre-mounted
|
|
518
|
+
// item still renders at opacity 0 until `visible` flips true.
|
|
414
519
|
const mounted = item.type === 'video'
|
|
415
|
-
?
|
|
520
|
+
? containsTime(item.start - VIDEO_PRELOAD_S, item.end, currentTime)
|
|
416
521
|
: visible
|
|
417
522
|
if (!mounted) return null
|
|
418
523
|
|
|
419
524
|
const isSel = selectedOverlayId === item.id
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
525
|
+
// Persisted geometry from the resolver; live drag state layered on top
|
|
526
|
+
// (see the tracks[0] block above for why the override has to win).
|
|
527
|
+
//
|
|
528
|
+
// EVERY item kind animates its position, scale and rotation: since
|
|
529
|
+
// SP9d the renderer compiles a clip's curves into time-varying ffmpeg
|
|
530
|
+
// expressions (encode-segment.js `animatedGeometry`), so a moving clip
|
|
531
|
+
// in this preview is a promise the export now keeps.
|
|
532
|
+
//
|
|
533
|
+
// OPACITY IS THE ONE EXCEPTION, and it is not a cost decision — it is
|
|
534
|
+
// a wall. ffmpeg's `colorchannelmixer` takes its alpha gain `aa` as a
|
|
535
|
+
// <double> and accepts no expression at all, so a clip's opacity curve
|
|
536
|
+
// cannot reach the render in any form. Animating it here would put
|
|
537
|
+
// back exactly the preview/render divergence this package exists to
|
|
538
|
+
// prevent: a fade the viewer sees and the export silently drops. So
|
|
539
|
+
// clips sample the curve for geometry and keep their STATIC opacity.
|
|
540
|
+
//
|
|
541
|
+
// Do not "finish the job" by dropping this override. Overlays are
|
|
542
|
+
// unaffected — they are baked per frame in a browser, where opacity is
|
|
543
|
+
// just another CSS value.
|
|
544
|
+
const animated = geometryAt(item, item.type, currentTime - item.start)
|
|
545
|
+
const g = item.type === 'overlay'
|
|
546
|
+
? animated
|
|
547
|
+
: { ...animated, opacity: geometryFor(item, item.type).opacity }
|
|
548
|
+
const fit = g.fit ?? 'cover'
|
|
549
|
+
const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? g.offsetX
|
|
550
|
+
const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? g.offsetY
|
|
551
|
+
const scale = (liveScale?.id === item.id ? liveScale.scale : null) ?? g.scale
|
|
552
|
+
// Per-axis is what the box actually renders at; `scale` stays the
|
|
553
|
+
// uniform value the drag state carries. `g.scaleX`/`g.scaleY` already
|
|
554
|
+
// fall back to `g.scale`, so a legacy item resolves both to one number.
|
|
555
|
+
const scaleX = (liveScale?.id === item.id ? liveScale.scaleX : null) ?? g.scaleX
|
|
556
|
+
const scaleY = (liveScale?.id === item.id ? liveScale.scaleY : null) ?? g.scaleY
|
|
557
|
+
const rotation = (liveRotation?.id === item.id ? liveRotation.rotation : null) ?? g.rotation
|
|
558
|
+
// Whether the item PERSISTS per-axis scale (vs inheriting both from the
|
|
559
|
+
// legacy uniform `scale`) — decides whether a resize commits per-axis
|
|
560
|
+
// fields; see `onUp` in useDragOverlay.ts.
|
|
561
|
+
const hasPerAxis = item.scaleX != null || item.scaleY != null
|
|
562
|
+
const initGeom = { initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initScaleX: scaleX, initScaleY: scaleY, initHasPerAxis: hasPerAxis, initRotation: rotation }
|
|
424
563
|
|
|
425
564
|
function startMove(e: React.MouseEvent) {
|
|
426
565
|
if (!isSel) return
|
|
427
566
|
e.stopPropagation()
|
|
428
|
-
setDragState({ id: item.id, type: 'move', initX: e.clientX, initY: e.clientY,
|
|
567
|
+
setDragState({ id: item.id, type: 'move', initX: e.clientX, initY: e.clientY, ...initGeom })
|
|
429
568
|
}
|
|
430
569
|
|
|
431
|
-
|
|
570
|
+
// Corner OR edge — `useDragOverlay` reads the suffix and decides
|
|
571
|
+
// between a both-axes and a single-axis gesture.
|
|
572
|
+
function startResize(handle: Corner | Edge) {
|
|
432
573
|
return (e: React.MouseEvent) => {
|
|
433
574
|
e.stopPropagation()
|
|
434
|
-
setDragState({ id: item.id, type: `resize-${
|
|
575
|
+
setDragState({ id: item.id, type: `resize-${handle}`, initX: e.clientX, initY: e.clientY, ...initGeom })
|
|
435
576
|
}
|
|
436
577
|
}
|
|
437
578
|
|
|
@@ -442,7 +583,7 @@ export default function OverlayItemsLayer({
|
|
|
442
583
|
const cx = rect.left + rect.width * (0.5 + offsetX / 100)
|
|
443
584
|
const cy = rect.top + rect.height * (0.5 + offsetY / 100)
|
|
444
585
|
const initAngle = Math.atan2(e.clientY - cy, e.clientX - cx)
|
|
445
|
-
setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY,
|
|
586
|
+
setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY, ...initGeom, cx, cy, initAngle })
|
|
446
587
|
}
|
|
447
588
|
|
|
448
589
|
// Double-click a selected JSX overlay opens the props dialog (owned by
|
|
@@ -453,28 +594,58 @@ export default function OverlayItemsLayer({
|
|
|
453
594
|
if (item.type === 'overlay' && item.src) onEditOverlay?.(item.id)
|
|
454
595
|
}
|
|
455
596
|
|
|
456
|
-
// zIndex
|
|
597
|
+
// zIndex derives from the same back-to-front ordering the resolver
|
|
598
|
+
// defines (`byTrackIdx`: lower trackIdx = further back = composited
|
|
599
|
+
// first), but the mapping onto CSS stacking contexts stays local —
|
|
600
|
+
// `trackIdx` here indexes `interactiveTracks`, which in non-canvas mode
|
|
601
|
+
// is `project.tracks.slice(1)`, and the base offsets exist only to clear
|
|
602
|
+
// the play-toggle div (z=10). Canvas mode track 0 sits just above it,
|
|
603
|
+
// others stack above.
|
|
457
604
|
const zIndex = isCanvasProject ? trackIdx + 11 : trackIdx + 12
|
|
458
605
|
|
|
606
|
+
// PARITY-CRITICAL: `render/test/overlay-transform-parity.test.mjs`
|
|
607
|
+
// hand-mirrors this exact `transform`/`transformOrigin`/`opacity`
|
|
608
|
+
// template (its `previewStyle`, ~:56-63) to prove the render bake
|
|
609
|
+
// produces the same CSS string this preview does — it cannot import
|
|
610
|
+
// this .tsx file into its plain node:test runner, so it transcribes
|
|
611
|
+
// instead. This file's own suite
|
|
612
|
+
// (preview/__tests__/OverlayItemsLayer.keyframes.test.tsx, the
|
|
613
|
+
// "pins the exact preview template" test) pins the SAME literal
|
|
614
|
+
// template against what this component actually renders, so a drift
|
|
615
|
+
// here fails THAT test even though the render-side test can't see
|
|
616
|
+
// this file at all. Change this template, update both.
|
|
617
|
+
//
|
|
618
|
+
// The template ships in FIVE places, two real and three transcribed:
|
|
619
|
+
// this line, the tracks[0] block above, the keyframes suite's
|
|
620
|
+
// `previewStyle`, render/bundle.js's overlay bake, and the render
|
|
621
|
+
// parity suite's `previewStyle`. The render-side pair is mid-migration
|
|
622
|
+
// onto the two-argument `scale(sx, sy)` form under its own slice, so
|
|
623
|
+
// it still transcribes the one-argument form for now — that gap is
|
|
624
|
+
// known and tracked, not a drift to "fix" from here.
|
|
459
625
|
const wrapperStyle: React.CSSProperties = {
|
|
460
|
-
transform: `translate(${offsetX}%, ${offsetY}%) rotate(${rotation}deg) scale(${
|
|
626
|
+
transform: `translate(${offsetX}%, ${offsetY}%) rotate(${rotation}deg) scale(${scaleX}, ${scaleY})`,
|
|
461
627
|
transformOrigin: 'center center',
|
|
462
628
|
zIndex,
|
|
463
|
-
opacity:
|
|
629
|
+
opacity: g.opacity,
|
|
630
|
+
// Selection box. NOT part of the parity template above — the render
|
|
631
|
+
// bake has no selection state to reproduce — so it is appended
|
|
632
|
+
// here, after the three transcribed properties, and only when the
|
|
633
|
+
// item is actually selected.
|
|
634
|
+
...(isSel ? { outline: SELECTION_OUTLINE } : null),
|
|
464
635
|
}
|
|
465
636
|
|
|
466
637
|
const wrapperClass = `absolute inset-0 ${
|
|
467
638
|
isSel
|
|
468
|
-
?
|
|
639
|
+
? (dragState?.type === 'move' ? 'cursor-grabbing' : 'cursor-grab')
|
|
469
640
|
: 'pointer-events-none'
|
|
470
641
|
}`
|
|
471
642
|
|
|
472
643
|
const handles = isSel && (
|
|
473
644
|
<>
|
|
474
|
-
{
|
|
475
|
-
<
|
|
645
|
+
{RESIZE_HANDLES.map(h => (
|
|
646
|
+
<ResizeHandle key={h} handle={h} scaleX={scaleX} scaleY={scaleY} onMouseDown={startResize(h)} />
|
|
476
647
|
))}
|
|
477
|
-
<RotateHandle
|
|
648
|
+
<RotateHandle scaleX={scaleX} scaleY={scaleY} onMouseDown={startRotate} />
|
|
478
649
|
</>
|
|
479
650
|
)
|
|
480
651
|
|
|
@@ -486,11 +657,11 @@ export default function OverlayItemsLayer({
|
|
|
486
657
|
src={fileUrl(item.src)}
|
|
487
658
|
draggable={false}
|
|
488
659
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
|
489
|
-
style={{ objectFit:
|
|
660
|
+
style={{ objectFit: fit }}
|
|
490
661
|
/>
|
|
491
662
|
{handles}
|
|
492
663
|
{isSel && onOverlayChange && (
|
|
493
|
-
<FitControl value={
|
|
664
|
+
<FitControl value={fit} scaleX={scaleX} scaleY={scaleY} onChange={(next) => onOverlayChange(item.id, { fit: next })} />
|
|
494
665
|
)}
|
|
495
666
|
</div>
|
|
496
667
|
)
|
|
@@ -501,10 +672,38 @@ export default function OverlayItemsLayer({
|
|
|
501
672
|
return (
|
|
502
673
|
<div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove}>
|
|
503
674
|
<OverlayVideo
|
|
504
|
-
src
|
|
675
|
+
// This is a THIRD src-precedence chain, deliberately not
|
|
676
|
+
// @bycrux/timeline-core's `playbackSrcFor` (see
|
|
677
|
+
// PreviewPlayer.tsx / useVideoPlayback.ts for that one): an
|
|
678
|
+
// overlay-track video loads the ORIGINAL src (skipping
|
|
679
|
+
// `normalizedSrc` entirely) and pairs it with a raw,
|
|
680
|
+
// un-rebased `inPoint`. Switching this to
|
|
681
|
+
// `playbackSrcFor(item, 'preview')` would add the
|
|
682
|
+
// `normalizedSrc` tier and require rebasing `inPoint` to
|
|
683
|
+
// match — an unbudgeted behavior change for this item
|
|
684
|
+
// class. Out of scope for SP2; owned by SP4.
|
|
685
|
+
//
|
|
686
|
+
// SP3 adds `proxySrc` as a middle tier (between
|
|
687
|
+
// `nobg_preview_src` and `src`), same precedence order as
|
|
688
|
+
// the main chain. `proxySrc` is full-source like `src`
|
|
689
|
+
// itself — no window, no rebase — so it slots in without
|
|
690
|
+
// disturbing the raw, un-rebased `inPoint` this chain
|
|
691
|
+
// already passes through. The tier is capability/failure
|
|
692
|
+
// gated (SP3 fix B2) exactly like the main chain's.
|
|
693
|
+
src={fileUrl(item.nobg_preview_src
|
|
694
|
+
?? (isProxyUsable(item.proxySrc) ? item.proxySrc : undefined)
|
|
695
|
+
?? item.src)}
|
|
696
|
+
onSrcError={() => {
|
|
697
|
+
if (!item.nobg_preview_src && isProxyUsable(item.proxySrc) && item.proxySrc) {
|
|
698
|
+
console.warn(`[montaj] overlay proxy failed to decode — falling back to the master: ${item.proxySrc}`)
|
|
699
|
+
markProxyFailed(item.proxySrc)
|
|
700
|
+
bumpProxyFail()
|
|
701
|
+
}
|
|
702
|
+
}}
|
|
505
703
|
currentTime={currentTime}
|
|
506
704
|
itemStart={item.start}
|
|
507
705
|
inPoint={item.inPoint ?? 0}
|
|
706
|
+
speed={item.speed ?? 1}
|
|
508
707
|
isPlaying={isPlaying}
|
|
509
708
|
muted={item.muted}
|
|
510
709
|
visible={visible}
|
|
@@ -569,7 +768,9 @@ export default function OverlayItemsLayer({
|
|
|
569
768
|
return (
|
|
570
769
|
<div
|
|
571
770
|
key={item.id}
|
|
572
|
-
|
|
771
|
+
// The selection outline rides on `wrapperStyle` (shared with the
|
|
772
|
+
// branches above), so only the cursor differs from unselected.
|
|
773
|
+
className={`absolute ${isSel ? 'cursor-grab' : 'pointer-events-none'} ${posClass[pos] ?? posClass['bottom-left']}`}
|
|
573
774
|
style={wrapperStyle}
|
|
574
775
|
onMouseDown={startMove}
|
|
575
776
|
>
|
|
@@ -584,26 +785,35 @@ export default function OverlayItemsLayer({
|
|
|
584
785
|
})
|
|
585
786
|
)}
|
|
586
787
|
|
|
788
|
+
{/* Snap guides. All of these are selection chrome, so they read the same
|
|
789
|
+
token as the box and handles. The faint reference frame gets its
|
|
790
|
+
fade from element `opacity` rather than a translucent colour: the
|
|
791
|
+
token is an opaque colour string, and this codebase deliberately
|
|
792
|
+
avoids `color-mix` (see ControlsInfoModal.tsx ~:130). */}
|
|
587
793
|
{/* Center snap guide lines */}
|
|
588
794
|
{dragState?.type === 'move' && snapGuides.x && (
|
|
589
|
-
<div className="absolute top-0 bottom-0 left-1/2 w-px
|
|
590
|
-
style={{ transform: 'translateX(-50%)' }} />
|
|
795
|
+
<div className="absolute top-0 bottom-0 left-1/2 w-px pointer-events-none z-50"
|
|
796
|
+
style={{ backgroundColor: SELECTION, transform: 'translateX(-50%)' }} />
|
|
591
797
|
)}
|
|
592
798
|
{dragState?.type === 'move' && snapGuides.y && (
|
|
593
|
-
<div className="absolute left-0 right-0 top-1/2 h-px
|
|
594
|
-
style={{ transform: 'translateY(-50%)' }} />
|
|
799
|
+
<div className="absolute left-0 right-0 top-1/2 h-px pointer-events-none z-50"
|
|
800
|
+
style={{ backgroundColor: SELECTION, transform: 'translateY(-50%)' }} />
|
|
595
801
|
)}
|
|
596
802
|
{/* Edge guide lines — always visible during a move drag as reference frame */}
|
|
597
|
-
{dragState?.type === 'move' && <div className="absolute top-0 bottom-0 left-0 w-px
|
|
598
|
-
{dragState?.type === 'move' && <div className="absolute top-0 bottom-0 right-0 w-px
|
|
599
|
-
{dragState?.type === 'move' && <div className="absolute left-0 right-0 top-0 h-px
|
|
600
|
-
{dragState?.type === 'move' && <div className="absolute left-0 right-0 bottom-0 h-px
|
|
803
|
+
{dragState?.type === 'move' && <div className="absolute top-0 bottom-0 left-0 w-px pointer-events-none z-50" style={{ backgroundColor: SELECTION, opacity: 0.3 }} />}
|
|
804
|
+
{dragState?.type === 'move' && <div className="absolute top-0 bottom-0 right-0 w-px pointer-events-none z-50" style={{ backgroundColor: SELECTION, opacity: 0.3 }} />}
|
|
805
|
+
{dragState?.type === 'move' && <div className="absolute left-0 right-0 top-0 h-px pointer-events-none z-50" style={{ backgroundColor: SELECTION, opacity: 0.3 }} />}
|
|
806
|
+
{dragState?.type === 'move' && <div className="absolute left-0 right-0 bottom-0 h-px pointer-events-none z-50" style={{ backgroundColor: SELECTION, opacity: 0.3 }} />}
|
|
601
807
|
{/* Edge snap highlight — brighten when snapping to an edge */}
|
|
602
|
-
{dragState?.type === 'move' && snapGuides.left && <div className="absolute top-0 bottom-0 left-0 w-px
|
|
603
|
-
{dragState?.type === 'move' && snapGuides.right && <div className="absolute top-0 bottom-0 right-0 w-px
|
|
604
|
-
{dragState?.type === 'move' && snapGuides.top && <div className="absolute left-0 right-0 top-0 h-px
|
|
605
|
-
{dragState?.type === 'move' && snapGuides.bottom && <div className="absolute left-0 right-0 bottom-0 h-px
|
|
606
|
-
{/* Rotation snap guide — line through center at the snapped angle
|
|
808
|
+
{dragState?.type === 'move' && snapGuides.left && <div className="absolute top-0 bottom-0 left-0 w-px pointer-events-none z-50" style={{ backgroundColor: SELECTION }} />}
|
|
809
|
+
{dragState?.type === 'move' && snapGuides.right && <div className="absolute top-0 bottom-0 right-0 w-px pointer-events-none z-50" style={{ backgroundColor: SELECTION }} />}
|
|
810
|
+
{dragState?.type === 'move' && snapGuides.top && <div className="absolute left-0 right-0 top-0 h-px pointer-events-none z-50" style={{ backgroundColor: SELECTION }} />}
|
|
811
|
+
{dragState?.type === 'move' && snapGuides.bottom && <div className="absolute left-0 right-0 bottom-0 h-px pointer-events-none z-50" style={{ backgroundColor: SELECTION }} />}
|
|
812
|
+
{/* Rotation snap guide — line through center at the snapped angle.
|
|
813
|
+
`stroke` goes through the CSS `style` prop, NOT the SVG presentation
|
|
814
|
+
attribute: `var()` is only resolved by the CSS cascade, so
|
|
815
|
+
stroke="var(--editor-selection)" would be parsed as an unknown paint
|
|
816
|
+
and drop the line entirely. */}
|
|
607
817
|
{dragState?.type === 'rotate' && snapRotation !== null && (
|
|
608
818
|
<div className="absolute inset-0 pointer-events-none z-50">
|
|
609
819
|
<svg width="100%" height="100%" overflow="visible">
|
|
@@ -611,13 +821,15 @@ export default function OverlayItemsLayer({
|
|
|
611
821
|
x1="50%" y1="50%"
|
|
612
822
|
x2={`calc(50% + 200% * ${Math.cos((snapRotation - 90) * Math.PI / 180)})`}
|
|
613
823
|
y2={`calc(50% + 200% * ${Math.sin((snapRotation - 90) * Math.PI / 180)})`}
|
|
614
|
-
|
|
824
|
+
style={{ stroke: SELECTION }}
|
|
825
|
+
strokeWidth="1" strokeDasharray="4 3" opacity="0.8"
|
|
615
826
|
/>
|
|
616
827
|
<line
|
|
617
828
|
x1="50%" y1="50%"
|
|
618
829
|
x2={`calc(50% - 200% * ${Math.cos((snapRotation - 90) * Math.PI / 180)})`}
|
|
619
830
|
y2={`calc(50% - 200% * ${Math.sin((snapRotation - 90) * Math.PI / 180)})`}
|
|
620
|
-
|
|
831
|
+
style={{ stroke: SELECTION }}
|
|
832
|
+
strokeWidth="1" strokeDasharray="4 3" opacity="0.8"
|
|
621
833
|
/>
|
|
622
834
|
</svg>
|
|
623
835
|
</div>
|