@bycrux/editor 0.6.3 → 0.7.0
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 +1 -1
- package/src/crop/VideoSourceCropModal.tsx +276 -0
- package/src/crop/__tests__/crop-math.test.ts +94 -1
- package/src/crop/crop-math.ts +125 -0
- package/src/types.ts +14 -0
- package/src/video/VideoEditor.tsx +59 -56
- package/src/video/preview/CaptionPreview.tsx +7 -1
- package/src/video/preview/PreviewPlayer.tsx +110 -3
- package/src/video/preview/__tests__/transformStyle.test.ts +34 -0
- package/src/video/preview/transformStyle.ts +44 -0
- package/src/video/preview/useVideoPlayback.ts +42 -17
- package/src/crop/VideoSourceCropOverlay.tsx +0 -249
- package/src/crop/__tests__/VideoSourceCropOverlay.test.tsx +0 -105
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Crop, Magnet } from 'lucide-react'
|
|
3
3
|
import type { Project, VideoEditorProps } from '../types'
|
|
4
|
-
import {
|
|
4
|
+
import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
|
|
5
5
|
import { getOverlayDesignCanvas } from './design-canvas'
|
|
6
6
|
import { applyTheme, defaultMontajTheme } from '../theme'
|
|
7
7
|
import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './cuts'
|
|
@@ -45,6 +45,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
45
45
|
renderSubcutRegen,
|
|
46
46
|
regenEnabled,
|
|
47
47
|
isClipQueued,
|
|
48
|
+
onProvideRenderTrigger,
|
|
48
49
|
}: Props<P>) {
|
|
49
50
|
const emit = onProjectChange ?? (() => {})
|
|
50
51
|
|
|
@@ -93,6 +94,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
93
94
|
renderSubcutRegen={renderSubcutRegen}
|
|
94
95
|
regenEnabled={regenEnabled}
|
|
95
96
|
isClipQueued={isClipQueued}
|
|
97
|
+
onProvideRenderTrigger={onProvideRenderTrigger}
|
|
96
98
|
/>
|
|
97
99
|
</div>
|
|
98
100
|
)
|
|
@@ -122,6 +124,7 @@ interface SurfaceProps<P extends Project> {
|
|
|
122
124
|
getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
|
|
123
125
|
resolveFilePath: (path: string) => string
|
|
124
126
|
save: (p: P) => void
|
|
127
|
+
onProvideRenderTrigger?: VideoEditorProps<P>['onProvideRenderTrigger']
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
function PendingSurface<P extends Project>({
|
|
@@ -265,6 +268,7 @@ function ReviewSurface<P extends Project>({
|
|
|
265
268
|
renderSubcutRegen,
|
|
266
269
|
regenEnabled,
|
|
267
270
|
isClipQueued,
|
|
271
|
+
onProvideRenderTrigger,
|
|
268
272
|
}: SurfaceProps<P> & {
|
|
269
273
|
renderClipInspector?: VideoEditorProps<P>['renderClipInspector']
|
|
270
274
|
renderSubcutRegen?: VideoEditorProps<P>['renderSubcutRegen']
|
|
@@ -279,8 +283,8 @@ function ReviewSurface<P extends Project>({
|
|
|
279
283
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
280
284
|
const primarySelectedId = selectedIds[0] ?? null
|
|
281
285
|
const [rippleMode, setRippleMode] = useState(false)
|
|
282
|
-
// Source-crop mode: when on,
|
|
283
|
-
//
|
|
286
|
+
// Source-crop mode: when on, the VideoSourceCropModal opens for the selected
|
|
287
|
+
// tracks[0] video item. Cleared when selection changes.
|
|
284
288
|
const [cropMode, setCropMode] = useState(false)
|
|
285
289
|
const [renderOpen, setRenderOpen] = useState(false)
|
|
286
290
|
const [regenCaptionsOpen, setRegenCaptionsOpen] = useState(false)
|
|
@@ -288,22 +292,22 @@ function ReviewSurface<P extends Project>({
|
|
|
288
292
|
// callbacks. A Montaj-agnostic { kind, id } selector, not a project entity.
|
|
289
293
|
const [inspecting, setInspecting] = useState<{ kind: 'clip' | 'audio'; id: string } | null>(null)
|
|
290
294
|
|
|
291
|
-
|
|
295
|
+
// Render trigger — marks the project final, saves, and opens the RenderModal.
|
|
296
|
+
// Kept stable (latest project/onChange/save read via refs) so a host that
|
|
297
|
+
// places Render in its own header (onProvideRenderTrigger) can store the
|
|
298
|
+
// callback once without it going stale.
|
|
299
|
+
const projectRef = useRef(project); projectRef.current = project
|
|
300
|
+
const onProjectChangeRef = useRef(onProjectChange); onProjectChangeRef.current = onProjectChange
|
|
301
|
+
const saveRef = useRef(save); saveRef.current = save
|
|
302
|
+
const openRender = useCallback(() => {
|
|
303
|
+
const final = { ...projectRef.current, status: 'final' } as P
|
|
304
|
+
onProjectChangeRef.current(final)
|
|
305
|
+
saveRef.current(final)
|
|
306
|
+
setRenderOpen(true)
|
|
307
|
+
}, [])
|
|
308
|
+
useEffect(() => { onProvideRenderTrigger?.(openRender) }, [onProvideRenderTrigger, openRender])
|
|
292
309
|
|
|
293
|
-
|
|
294
|
-
// overlay as its wrapper dims so renderedSourceRect letterboxes correctly.
|
|
295
|
-
const previewBoxRef = useRef<HTMLDivElement>(null)
|
|
296
|
-
const [previewBox, setPreviewBox] = useState<{ w: number; h: number }>({ w: 0, h: 0 })
|
|
297
|
-
useEffect(() => {
|
|
298
|
-
if (!cropMode) return
|
|
299
|
-
const el = previewBoxRef.current
|
|
300
|
-
if (!el) return
|
|
301
|
-
const obs = new ResizeObserver(([entry]) => {
|
|
302
|
-
setPreviewBox({ w: entry.contentRect.width, h: entry.contentRect.height })
|
|
303
|
-
})
|
|
304
|
-
obs.observe(el)
|
|
305
|
-
return () => obs.disconnect()
|
|
306
|
-
}, [cropMode])
|
|
310
|
+
const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
|
|
307
311
|
|
|
308
312
|
// Repair caption segments whose words[] text has diverged from edited seg.text.
|
|
309
313
|
// Inline caption edits update seg.text but not seg.words; this normalizes the
|
|
@@ -434,7 +438,6 @@ function ReviewSurface<P extends Project>({
|
|
|
434
438
|
<div className="flex-1 flex items-center justify-center bg-black overflow-hidden p-2">
|
|
435
439
|
{hasContent ? (
|
|
436
440
|
<div
|
|
437
|
-
ref={previewBoxRef}
|
|
438
441
|
className="relative h-full max-w-full"
|
|
439
442
|
style={{ aspectRatio: (() => { const [w, h] = getOverlayDesignCanvas(project.settings?.resolution); return `${w} / ${h}` })() }}
|
|
440
443
|
>
|
|
@@ -450,30 +453,6 @@ function ReviewSurface<P extends Project>({
|
|
|
450
453
|
fileUrl={adapter.fileUrl}
|
|
451
454
|
resolveCaptionTemplate={adapter.resolveCaptionTemplate}
|
|
452
455
|
/>
|
|
453
|
-
{/* Source-crop overlay — mounted over the preview for the selected
|
|
454
|
-
tracks[0] video. Persists sourceCrop through handleOverlayChange. */}
|
|
455
|
-
{cropMode && cropTarget && (
|
|
456
|
-
<div className="absolute inset-0" style={{ zIndex: 200 }}>
|
|
457
|
-
<VideoSourceCropOverlay
|
|
458
|
-
item={cropTarget}
|
|
459
|
-
resolveSrc={(it) => adapter.fileUrl(it.nobg_preview_src ?? it.src ?? '')}
|
|
460
|
-
wrapperWidth={previewBox.w}
|
|
461
|
-
wrapperHeight={previewBox.h}
|
|
462
|
-
onChange={(next) => handleOverlayChange(cropTarget.id, {
|
|
463
|
-
sourceCrop: {
|
|
464
|
-
x: Math.min(1, Math.max(0, next.x)),
|
|
465
|
-
y: Math.min(1, Math.max(0, next.y)),
|
|
466
|
-
w: Math.min(1, Math.max(0, next.w)),
|
|
467
|
-
h: Math.min(1, Math.max(0, next.h)),
|
|
468
|
-
},
|
|
469
|
-
})}
|
|
470
|
-
onSrcDimsLoaded={(dims) => {
|
|
471
|
-
if (cropTarget.sourceWidth && cropTarget.sourceHeight) return
|
|
472
|
-
handleOverlayChange(cropTarget.id, { sourceWidth: dims.width, sourceHeight: dims.height })
|
|
473
|
-
}}
|
|
474
|
-
/>
|
|
475
|
-
</div>
|
|
476
|
-
)}
|
|
477
456
|
</div>
|
|
478
457
|
) : (
|
|
479
458
|
<p className="text-[var(--editor-text)]/60 text-sm">No clips</p>
|
|
@@ -522,17 +501,16 @@ function ReviewSurface<P extends Project>({
|
|
|
522
501
|
>
|
|
523
502
|
<Crop size={12} />
|
|
524
503
|
</button>
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
</button>
|
|
504
|
+
{/* Default placement. A host that sets onProvideRenderTrigger renders
|
|
505
|
+
Render in its own chrome instead, so the toolbar button is hidden. */}
|
|
506
|
+
{!onProvideRenderTrigger && (
|
|
507
|
+
<button
|
|
508
|
+
onClick={openRender}
|
|
509
|
+
className="text-xs px-2.5 py-1 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors"
|
|
510
|
+
>
|
|
511
|
+
Render →
|
|
512
|
+
</button>
|
|
513
|
+
)}
|
|
536
514
|
</div>
|
|
537
515
|
|
|
538
516
|
<div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
@@ -577,7 +555,7 @@ function ReviewSurface<P extends Project>({
|
|
|
577
555
|
right below, one column — not a separate assets column. */}
|
|
578
556
|
{(adapter.listVersionHistory || slots?.runHistory ||
|
|
579
557
|
(assetsPlacement === 'sidebar' && slots?.assetsPanel)) && (
|
|
580
|
-
<div className={`${assetsPlacement === 'sidebar' ? 'w-
|
|
558
|
+
<div className={`${assetsPlacement === 'sidebar' ? 'w-56' : 'w-48'} shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden`}>
|
|
581
559
|
{adapter.listVersionHistory && (
|
|
582
560
|
<VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
|
|
583
561
|
)}
|
|
@@ -606,6 +584,31 @@ function ReviewSurface<P extends Project>({
|
|
|
606
584
|
</div>
|
|
607
585
|
)}
|
|
608
586
|
|
|
587
|
+
{/* Source-crop modal — drag-to-pan, aspect presets, zoom. Commits
|
|
588
|
+
sourceCrop (+ source dims) to the selected tracks[0] video on Apply. */}
|
|
589
|
+
{cropMode && cropTarget && (
|
|
590
|
+
<VideoSourceCropModal
|
|
591
|
+
item={cropTarget}
|
|
592
|
+
// Prefer the conformed per-window cache (short, starts at the clip's
|
|
593
|
+
// first frame → loads instantly and shows a representative frame).
|
|
594
|
+
// Falls back to the bg-removed proxy, then the raw source.
|
|
595
|
+
resolveSrc={(it) => adapter.fileUrl(it.nobg_preview_src ?? it.normalizedSrc ?? it.src ?? '')}
|
|
596
|
+
onApply={(next) => handleOverlayChange(cropTarget.id, {
|
|
597
|
+
sourceCrop: {
|
|
598
|
+
x: Math.min(1, Math.max(0, next.x)),
|
|
599
|
+
y: Math.min(1, Math.max(0, next.y)),
|
|
600
|
+
w: Math.min(1, Math.max(0, next.w)),
|
|
601
|
+
h: Math.min(1, Math.max(0, next.h)),
|
|
602
|
+
},
|
|
603
|
+
})}
|
|
604
|
+
onSrcDimsLoaded={(dims) => {
|
|
605
|
+
if (cropTarget.sourceWidth && cropTarget.sourceHeight) return
|
|
606
|
+
handleOverlayChange(cropTarget.id, { sourceWidth: dims.width, sourceHeight: dims.height })
|
|
607
|
+
}}
|
|
608
|
+
onClose={() => setCropMode(false)}
|
|
609
|
+
/>
|
|
610
|
+
)}
|
|
611
|
+
|
|
609
612
|
{/* Render modal — adapter.render stream + host export controls */}
|
|
610
613
|
{renderOpen && (
|
|
611
614
|
<RenderModal
|
|
@@ -63,7 +63,13 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
|
|
|
63
63
|
: null
|
|
64
64
|
|
|
65
65
|
return (
|
|
66
|
-
<
|
|
66
|
+
// zIndex 45 keeps captions above the active <video> (z 1) and overlay items
|
|
67
|
+
// (z `trackIdx + 12`, ≈12–20) — mirroring the final render, where the caption
|
|
68
|
+
// track composites on top — while staying below the editing affordances
|
|
69
|
+
// (selection handles z 50, play button z 100). Without an explicit z-index the
|
|
70
|
+
// root sits at `auto`, so the opaque active video paints over it and captions
|
|
71
|
+
// never appear in the preview.
|
|
72
|
+
<div ref={wrapRef} className="absolute inset-0 pointer-events-none overflow-hidden" style={{ zIndex: 45 }}>
|
|
67
73
|
<OverlayErrorBoundary label={`caption: ${track.style}`} resetKey={track.style}>
|
|
68
74
|
{element && scale !== null && (
|
|
69
75
|
<div style={{
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react'
|
|
2
|
+
import { videoTransformContainerStyle, videoTransformBoxPct, type VideoTransform } from './transformStyle'
|
|
2
3
|
import type { EditorProject as Project } from '../../schema'
|
|
3
4
|
import type { OverlayFactory } from '../../types'
|
|
4
5
|
import CaptionPreview from './CaptionPreview'
|
|
@@ -119,6 +120,71 @@ export default function PreviewPlayer({
|
|
|
119
120
|
? { ...cropStyle }
|
|
120
121
|
: { position: 'absolute' as const, inset: 0, width: '100%', height: '100%', objectFit: 'contain' as const }
|
|
121
122
|
|
|
123
|
+
// ── Base-video on-canvas transform (position + zoom) ─────────────────────────
|
|
124
|
+
// The selected tracks[0] video shows drag/resize handles like an overlay: drag
|
|
125
|
+
// to move (offsetX/offsetY), corner-drag or scroll to scale. Live during a
|
|
126
|
+
// pointer drag, committed on pointer-up via onOverlayChange; scroll commits
|
|
127
|
+
// directly. The <video> container reflects it (WYSIWYG with the renderer).
|
|
128
|
+
const selectedClip = useMemo(
|
|
129
|
+
() => clips.find(c => c.id === selectedOverlayId) ?? null,
|
|
130
|
+
[clips, selectedOverlayId],
|
|
131
|
+
)
|
|
132
|
+
const showVideoTransform = !isCanvasProject && !!selectedClip && selectedClip.id === activeClip?.id
|
|
133
|
+
const [liveXf, setLiveXf] = useState<VideoTransform | null>(null)
|
|
134
|
+
const xfDragRef = useRef<
|
|
135
|
+
| { kind: 'move'; startClient: { x: number; y: number }; start: VideoTransform }
|
|
136
|
+
| { kind: 'scale'; center: { x: number; y: number }; startDist: number; start: VideoTransform }
|
|
137
|
+
| null
|
|
138
|
+
>(null)
|
|
139
|
+
const baseXf: VideoTransform = {
|
|
140
|
+
scale: activeClip?.scale ?? 1,
|
|
141
|
+
offsetX: activeClip?.offsetX ?? 0,
|
|
142
|
+
offsetY: activeClip?.offsetY ?? 0,
|
|
143
|
+
}
|
|
144
|
+
const xf = liveXf ?? baseXf
|
|
145
|
+
const transformContainerStyle = videoTransformContainerStyle(xf)
|
|
146
|
+
const xfBox = videoTransformBoxPct(xf)
|
|
147
|
+
|
|
148
|
+
const onXfMoveDown = (e: ReactPointerEvent) => {
|
|
149
|
+
e.stopPropagation(); e.currentTarget.setPointerCapture?.(e.pointerId)
|
|
150
|
+
xfDragRef.current = { kind: 'move', startClient: { x: e.clientX, y: e.clientY }, start: baseXf }
|
|
151
|
+
}
|
|
152
|
+
const onXfScaleDown = (e: ReactPointerEvent) => {
|
|
153
|
+
e.stopPropagation(); e.currentTarget.setPointerCapture?.(e.pointerId)
|
|
154
|
+
const r = containerRef.current?.getBoundingClientRect()
|
|
155
|
+
if (!r) return
|
|
156
|
+
const center = { x: r.left + r.width / 2, y: r.top + r.height / 2 }
|
|
157
|
+
const startDist = Math.hypot(e.clientX - center.x, e.clientY - center.y) || 1
|
|
158
|
+
xfDragRef.current = { kind: 'scale', center, startDist, start: baseXf }
|
|
159
|
+
}
|
|
160
|
+
const onXfMove = (e: ReactPointerEvent) => {
|
|
161
|
+
const d = xfDragRef.current
|
|
162
|
+
if (!d) return
|
|
163
|
+
if (d.kind === 'move') {
|
|
164
|
+
const ox = (d.start.offsetX ?? 0) + ((e.clientX - d.startClient.x) / (frameSize.w || 1)) * 100
|
|
165
|
+
const oy = (d.start.offsetY ?? 0) + ((e.clientY - d.startClient.y) / (frameSize.h || 1)) * 100
|
|
166
|
+
setLiveXf({ ...d.start, offsetX: ox, offsetY: oy })
|
|
167
|
+
} else {
|
|
168
|
+
const dist = Math.hypot(e.clientX - d.center.x, e.clientY - d.center.y)
|
|
169
|
+
const s = Math.min(8, Math.max(0.2, (d.start.scale ?? 1) * (dist / d.startDist)))
|
|
170
|
+
setLiveXf({ ...d.start, scale: s })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const onXfUp = () => {
|
|
174
|
+
const live = liveXf
|
|
175
|
+
xfDragRef.current = null
|
|
176
|
+
setLiveXf(null)
|
|
177
|
+
if (live && selectedClip && onOverlayChange) {
|
|
178
|
+
onOverlayChange(selectedClip.id, { offsetX: live.offsetX, offsetY: live.offsetY, scale: live.scale })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const onXfWheel = (e: ReactWheelEvent) => {
|
|
182
|
+
if (!showVideoTransform || !selectedClip || !onOverlayChange) return
|
|
183
|
+
const factor = e.deltaY < 0 ? 1.06 : 1 / 1.06
|
|
184
|
+
const s = Math.min(8, Math.max(0.2, (activeClip?.scale ?? 1) * factor))
|
|
185
|
+
onOverlayChange(selectedClip.id, { scale: s })
|
|
186
|
+
}
|
|
187
|
+
|
|
122
188
|
return (
|
|
123
189
|
<div ref={containerRef} className="relative bg-black h-full max-w-full overflow-hidden rounded" style={{ aspectRatio: `${RENDER_W} / ${RENDER_H}`, isolation: 'isolate' }}>
|
|
124
190
|
{isCanvasProject ? (
|
|
@@ -128,7 +194,10 @@ export default function PreviewPlayer({
|
|
|
128
194
|
No clips
|
|
129
195
|
</div>
|
|
130
196
|
) : (
|
|
131
|
-
|
|
197
|
+
// Transform container — applies the active clip's scale/offset to both
|
|
198
|
+
// slots (the inactive one is invisible). The frame's overflow-hidden clips
|
|
199
|
+
// anything pushed outside. Mirrors the renderer's crop→scale→position.
|
|
200
|
+
<div className="absolute inset-0" style={transformContainerStyle}>
|
|
132
201
|
{/* Slot 0 */}
|
|
133
202
|
<video
|
|
134
203
|
ref={video0Ref}
|
|
@@ -161,7 +230,45 @@ export default function PreviewPlayer({
|
|
|
161
230
|
playsInline
|
|
162
231
|
style={{ ...baseVideoStyle, opacity: showVideo && activeSlot === 1 ? 1 : 0, pointerEvents: activeSlot === 1 ? 'auto' : 'none', zIndex: activeSlot === 1 ? 1 : 0 }}
|
|
163
232
|
/>
|
|
164
|
-
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{/* Base-video transform handles — drag to move, corner-drag or scroll to
|
|
237
|
+
zoom. Shown only when the on-screen clip is selected. Above the
|
|
238
|
+
play-toggle (z 10), below overlays (z 12+). */}
|
|
239
|
+
{showVideoTransform && (
|
|
240
|
+
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 11 }}>
|
|
241
|
+
<div
|
|
242
|
+
onPointerDown={onXfMoveDown}
|
|
243
|
+
onPointerMove={onXfMove}
|
|
244
|
+
onPointerUp={onXfUp}
|
|
245
|
+
onWheel={onXfWheel}
|
|
246
|
+
style={{
|
|
247
|
+
position: 'absolute',
|
|
248
|
+
left: `${xfBox.left}%`, top: `${xfBox.top}%`, width: `${xfBox.width}%`, height: `${xfBox.height}%`,
|
|
249
|
+
outline: '2px solid var(--editor-selection)', cursor: 'move', pointerEvents: 'auto', touchAction: 'none',
|
|
250
|
+
}}
|
|
251
|
+
/>
|
|
252
|
+
{([['nw', 0, 0], ['ne', 1, 0], ['sw', 0, 1], ['se', 1, 1]] as const).map(([k, dx, dy]) => (
|
|
253
|
+
<div
|
|
254
|
+
key={k}
|
|
255
|
+
onPointerDown={onXfScaleDown}
|
|
256
|
+
onPointerMove={onXfMove}
|
|
257
|
+
onPointerUp={onXfUp}
|
|
258
|
+
style={{
|
|
259
|
+
position: 'absolute',
|
|
260
|
+
// Clamp into the frame so corners stay grabbable even when the box
|
|
261
|
+
// is scaled beyond the frame (scale uses pointer-distance, not the
|
|
262
|
+
// handle's position, so a clamped grab still scales correctly).
|
|
263
|
+
left: `calc(${Math.min(98, Math.max(2, xfBox.left + dx * xfBox.width))}% - 6px)`,
|
|
264
|
+
top: `calc(${Math.min(98, Math.max(2, xfBox.top + dy * xfBox.height))}% - 6px)`,
|
|
265
|
+
width: 12, height: 12, backgroundColor: '#fff',
|
|
266
|
+
border: '1.5px solid var(--editor-selection)', borderRadius: 2,
|
|
267
|
+
cursor: 'nwse-resize', pointerEvents: 'auto', touchAction: 'none',
|
|
268
|
+
}}
|
|
269
|
+
/>
|
|
270
|
+
))}
|
|
271
|
+
</div>
|
|
165
272
|
)}
|
|
166
273
|
|
|
167
274
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { videoTransformContainerStyle, videoTransformBoxPct } from '../transformStyle'
|
|
3
|
+
|
|
4
|
+
describe('videoTransformContainerStyle', () => {
|
|
5
|
+
it('identity transform → empty style (no-op)', () => {
|
|
6
|
+
expect(videoTransformContainerStyle({})).toEqual({})
|
|
7
|
+
expect(videoTransformContainerStyle({ scale: 1, offsetX: 0, offsetY: 0 })).toEqual({})
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('scale + offset → translate then scale, origin center', () => {
|
|
11
|
+
const s = videoTransformContainerStyle({ scale: 2, offsetX: 10, offsetY: -5 })
|
|
12
|
+
expect(s.transform).toBe('translate(10%, -5%) scale(2)')
|
|
13
|
+
expect(s.transformOrigin).toBe('center center')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('videoTransformBoxPct', () => {
|
|
18
|
+
it('scale 1, no offset → fills the frame', () => {
|
|
19
|
+
expect(videoTransformBoxPct({})).toEqual({ width: 100, height: 100, left: 0, top: 0 })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('scale 2 centered → 200% box, centered (offset -50%)', () => {
|
|
23
|
+
expect(videoTransformBoxPct({ scale: 2 })).toEqual({ width: 200, height: 200, left: -50, top: -50 })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('scale 0.5 → 50% box centered at 25%,25%', () => {
|
|
27
|
+
expect(videoTransformBoxPct({ scale: 0.5 })).toEqual({ width: 50, height: 50, left: 25, top: 25 })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('offset shifts the centered box by frame percent', () => {
|
|
31
|
+
expect(videoTransformBoxPct({ scale: 2, offsetX: 10, offsetY: -5 }))
|
|
32
|
+
.toEqual({ width: 200, height: 200, left: -40, top: -55 })
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Pure geometry for the base video's on-canvas TRANSFORM (position + zoom),
|
|
2
|
+
// distinct from its sourceCrop (which sub-rect of the footage). Mirrors the
|
|
3
|
+
// renderer (encode-segment.js buildVideoItemFilterParts): the cropped/contained
|
|
4
|
+
// video is fit into a box of size (canvas × scale), centered, then shifted by
|
|
5
|
+
// offsetX/offsetY which are percentages of the frame — `overlay=x=vw*(0.5*(1-s)
|
|
6
|
+
// + offsetX/100)`. The frame's overflow-hidden clips anything outside.
|
|
7
|
+
//
|
|
8
|
+
// Both helpers are expressed in frame-relative units so they're pixel-size
|
|
9
|
+
// independent: the container transform uses CSS translate %, which is relative to
|
|
10
|
+
// the container's own (frame) size, and scale() around center.
|
|
11
|
+
|
|
12
|
+
import type { CSSProperties } from 'react'
|
|
13
|
+
|
|
14
|
+
export interface VideoTransform {
|
|
15
|
+
scale?: number
|
|
16
|
+
offsetX?: number // percent of frame width
|
|
17
|
+
offsetY?: number // percent of frame height
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// CSS transform for a frame-sized container wrapping the <video>. translate() %
|
|
21
|
+
// is relative to the container (= frame), matching the renderer's frame-percent
|
|
22
|
+
// offset; scale() is around center, matching the renderer's centered box.
|
|
23
|
+
export function videoTransformContainerStyle(t: VideoTransform): CSSProperties {
|
|
24
|
+
const s = t.scale ?? 1
|
|
25
|
+
const ox = t.offsetX ?? 0
|
|
26
|
+
const oy = t.offsetY ?? 0
|
|
27
|
+
if (s === 1 && ox === 0 && oy === 0) return {}
|
|
28
|
+
return { transform: `translate(${ox}%, ${oy}%) scale(${s})`, transformOrigin: 'center center' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The transform box as a frame-relative % rect (left/top/width/height in %).
|
|
32
|
+
// This is the canvas-aspect box the cropped video is contained within; the crop
|
|
33
|
+
// handles for the on-canvas transform are drawn on it.
|
|
34
|
+
export function videoTransformBoxPct(t: VideoTransform): { left: number; top: number; width: number; height: number } {
|
|
35
|
+
const s = t.scale ?? 1
|
|
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
|
+
}
|
|
44
|
+
}
|
|
@@ -214,6 +214,18 @@ export function useVideoPlayback(
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// Start playback on a slot during an automated clip-boundary switch (context
|
|
218
|
+
// already running — no gesture needed). If the slot isn't buffered yet (a slow
|
|
219
|
+
// non-faststart HEVC tail-moov fetch can still be in flight), play() may reject
|
|
220
|
+
// with AbortError; retry on `canplay` so the switch never dead-stops at the cut.
|
|
221
|
+
function playSoon(video: HTMLVideoElement) {
|
|
222
|
+
const p = video.play()
|
|
223
|
+
if (p) p.catch(() => {
|
|
224
|
+
const onCanPlay = () => { video.removeEventListener('canplay', onCanPlay); video.play().catch(() => {}) }
|
|
225
|
+
video.addEventListener('canplay', onCanPlay)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
217
229
|
function ensureVideoGain(slot: 0 | 1): GainNode | null {
|
|
218
230
|
if (videoGainRef.current[slot]) return videoGainRef.current[slot]
|
|
219
231
|
const video = slot === 0 ? video0Ref.current : video1Ref.current
|
|
@@ -569,7 +581,7 @@ export function useVideoPlayback(
|
|
|
569
581
|
if (preloadSrcRef.current !== src) { nv.src = src; nv.currentTime = effectiveInPoint(nc) }
|
|
570
582
|
const gain = ensureVideoGain(ns)
|
|
571
583
|
if (gain) gain.gain.value = nc.muted ? 0 : (nc.volume ?? 1)
|
|
572
|
-
nv
|
|
584
|
+
playSoon(nv)
|
|
573
585
|
}
|
|
574
586
|
void (activeSlotRef.current === 0 ? video0Ref.current : video1Ref.current)?.pause()
|
|
575
587
|
activeSlotRef.current = ns
|
|
@@ -598,6 +610,14 @@ export function useVideoPlayback(
|
|
|
598
610
|
gapNextIdxRef.current = nextIdx
|
|
599
611
|
getActiveVideo()?.pause()
|
|
600
612
|
gapRAFRef.current = requestAnimationFrame(tickGap)
|
|
613
|
+
} else {
|
|
614
|
+
// Scrubbed PAST the last clip into trailing empty space — there is no
|
|
615
|
+
// next clip to advance to. Without this the active <video> keeps
|
|
616
|
+
// playing under the hidden (showVideo=false) frame: the picture goes
|
|
617
|
+
// dark but its audio keeps going. Stop playback outright.
|
|
618
|
+
getActiveVideo()?.pause()
|
|
619
|
+
setIsPlaying(false)
|
|
620
|
+
syncAudioTracks(currentTime, false)
|
|
601
621
|
}
|
|
602
622
|
}
|
|
603
623
|
return
|
|
@@ -659,21 +679,26 @@ export function useVideoPlayback(
|
|
|
659
679
|
const clipInPoint = effectiveInPoint(clip)
|
|
660
680
|
const outPoint = effectiveOutPoint(clip) ?? clip.end - clip.start + clipInPoint
|
|
661
681
|
|
|
662
|
-
// Preload next clip into inactive slot
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
682
|
+
// Preload the next clip into the inactive slot as early as possible. The
|
|
683
|
+
// source files are large 4K 10-bit HEVC with the moov atom at the END of the
|
|
684
|
+
// file (not web-faststart), so the browser needs a slow tail range-fetch to
|
|
685
|
+
// index and seek before it can decode. The old "~1s before end" lead was far
|
|
686
|
+
// too short: at a cross-source cut the next slot wasn't ready and play()
|
|
687
|
+
// stalled, freezing playback at the boundary. (Same-source cuts hid the bug —
|
|
688
|
+
// the moov was already cached from the active slot.) Give the load the whole
|
|
689
|
+
// current clip as runway instead; the preloadSrcRef guard keeps it idempotent
|
|
690
|
+
// and a scrub clears it.
|
|
691
|
+
const nextIdx = activeIdxRef.current + 1
|
|
692
|
+
if (nextIdx < clips.length && clips[nextIdx].src) {
|
|
693
|
+
const inactiveVideo = slot === 0 ? video1Ref.current : video0Ref.current
|
|
694
|
+
const nextSrc = fileUrlRef.current(playbackSrcFor(clips[nextIdx]))
|
|
695
|
+
if (inactiveVideo && preloadSrcRef.current !== nextSrc) {
|
|
696
|
+
preloadSrcRef.current = nextSrc
|
|
697
|
+
inactiveVideo.src = nextSrc
|
|
698
|
+
inactiveVideo.currentTime = effectiveInPoint(clips[nextIdx])
|
|
699
|
+
const inactiveSlot = (1 - slot) as 0 | 1
|
|
700
|
+
const nextGain = ensureVideoGain(inactiveSlot)
|
|
701
|
+
if (nextGain) nextGain.gain.value = clips[nextIdx].muted ? 0 : (clips[nextIdx].volume ?? 1)
|
|
677
702
|
}
|
|
678
703
|
}
|
|
679
704
|
|
|
@@ -723,7 +748,7 @@ export function useVideoPlayback(
|
|
|
723
748
|
}
|
|
724
749
|
const nextGain = ensureVideoGain(nextSlot)
|
|
725
750
|
if (nextGain) nextGain.gain.value = next.muted ? 0 : (next.volume ?? 1)
|
|
726
|
-
nextVideo
|
|
751
|
+
playSoon(nextVideo)
|
|
727
752
|
}
|
|
728
753
|
|
|
729
754
|
activeSlotRef.current = nextSlot
|