@bycrux/editor 0.6.4 → 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 +58 -55
- 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 +8 -0
- package/src/crop/VideoSourceCropOverlay.tsx +0 -249
- package/src/crop/__tests__/VideoSourceCropOverlay.test.tsx +0 -105
package/package.json
CHANGED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Crop, RectangleHorizontal, RectangleVertical, Square } from 'lucide-react'
|
|
3
|
+
import type { VisualItem } from '../schema'
|
|
4
|
+
import {
|
|
5
|
+
renderedSourceRect,
|
|
6
|
+
fractionToWrapperPx,
|
|
7
|
+
applyCropHandleDrag,
|
|
8
|
+
aspectLockedCornerResize,
|
|
9
|
+
translateCropPx,
|
|
10
|
+
type CropFraction,
|
|
11
|
+
type CropHandle,
|
|
12
|
+
} from './crop-math'
|
|
13
|
+
|
|
14
|
+
// Crop-proper modal for a tracks[0] video item. Its ONLY job is choosing which
|
|
15
|
+
// rectangle of the SOURCE footage to keep (`sourceCrop`). Positioning and zoom of
|
|
16
|
+
// the clip on the output canvas are NOT here — those are direct-manipulation
|
|
17
|
+
// transforms on the preview (drag + resize handles), the same as overlays.
|
|
18
|
+
//
|
|
19
|
+
// Controls: drag the crop window to move it; drag handles to resize. "Free" gives
|
|
20
|
+
// 8-handle free-form resize; the aspect presets lock the crop's shape (corner
|
|
21
|
+
// handles only, aspect maintained). Commits `sourceCrop` on Apply; Cancel discards.
|
|
22
|
+
|
|
23
|
+
const FREE_HANDLES: CropHandle[] = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']
|
|
24
|
+
const CORNER_HANDLES: CropHandle[] = ['nw', 'ne', 'sw', 'se']
|
|
25
|
+
const HANDLE_OFFSET: Record<CropHandle, { dx: 0 | 0.5 | 1; dy: 0 | 0.5 | 1; cursor: string }> = {
|
|
26
|
+
nw: { dx: 0, dy: 0, cursor: 'nw-resize' },
|
|
27
|
+
n: { dx: 0.5, dy: 0, cursor: 'n-resize' },
|
|
28
|
+
ne: { dx: 1, dy: 0, cursor: 'ne-resize' },
|
|
29
|
+
w: { dx: 0, dy: 0.5, cursor: 'w-resize' },
|
|
30
|
+
e: { dx: 1, dy: 0.5, cursor: 'e-resize' },
|
|
31
|
+
sw: { dx: 0, dy: 1, cursor: 'sw-resize' },
|
|
32
|
+
s: { dx: 0.5, dy: 1, cursor: 's-resize' },
|
|
33
|
+
se: { dx: 1, dy: 1, cursor: 'se-resize' },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_CROP: CropFraction = { x: 0, y: 0, w: 1, h: 1 }
|
|
37
|
+
|
|
38
|
+
// Shape locks for the crop rectangle (not output framing). `aspect` is the crop's
|
|
39
|
+
// pixel width/height; null = free-form.
|
|
40
|
+
type ShapeKey = 'free' | '1:1' | '16:9' | '9:16'
|
|
41
|
+
const SHAPES: { key: ShapeKey; label: string; aspect: number | null; Icon: typeof Crop }[] = [
|
|
42
|
+
{ key: 'free', label: 'Free', aspect: null, Icon: Crop },
|
|
43
|
+
{ key: '1:1', label: '1:1', aspect: 1, Icon: Square },
|
|
44
|
+
{ key: '16:9', label: '16:9', aspect: 16 / 9, Icon: RectangleHorizontal },
|
|
45
|
+
{ key: '9:16', label: '9:16', aspect: 9 / 16, Icon: RectangleVertical },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
|
|
49
|
+
function clampFraction(c: CropFraction): CropFraction {
|
|
50
|
+
const x = clamp01(c.x)
|
|
51
|
+
const y = clamp01(c.y)
|
|
52
|
+
return { x, y, w: Math.min(1 - x, Math.max(0, c.w)), h: Math.min(1 - y, Math.max(0, c.h)) }
|
|
53
|
+
}
|
|
54
|
+
const cropCenter = (c: CropFraction) => ({ x: c.x + c.w / 2, y: c.y + c.h / 2 })
|
|
55
|
+
|
|
56
|
+
export type VideoSourceCropModalProps = {
|
|
57
|
+
item: VisualItem
|
|
58
|
+
/** Resolve the clip's preview URL — `(item) => adapter.fileUrl(item.nobg_preview_src ?? item.normalizedSrc ?? item.src)`. */
|
|
59
|
+
resolveSrc: (item: VisualItem) => string
|
|
60
|
+
/** Commit the final crop (source fractions). */
|
|
61
|
+
onApply: (next: CropFraction) => void
|
|
62
|
+
/** Persist source intrinsic dims once known (when the item doesn't carry them). */
|
|
63
|
+
onSrcDimsLoaded: (dims: { width: number; height: number }) => void
|
|
64
|
+
/** Close without committing. */
|
|
65
|
+
onClose: () => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function VideoSourceCropModal({
|
|
69
|
+
item,
|
|
70
|
+
resolveSrc,
|
|
71
|
+
onApply,
|
|
72
|
+
onSrcDimsLoaded,
|
|
73
|
+
onClose,
|
|
74
|
+
}: VideoSourceCropModalProps) {
|
|
75
|
+
const srcUrl = resolveSrc(item)
|
|
76
|
+
|
|
77
|
+
const [loadedDims, setLoadedDims] = React.useState<{ width: number; height: number } | null>(null)
|
|
78
|
+
const srcDims =
|
|
79
|
+
item.sourceWidth && item.sourceHeight
|
|
80
|
+
? { width: item.sourceWidth, height: item.sourceHeight }
|
|
81
|
+
: loadedDims
|
|
82
|
+
|
|
83
|
+
const [crop, setCrop] = React.useState<CropFraction>(item.sourceCrop ?? DEFAULT_CROP)
|
|
84
|
+
const [shape, setShape] = React.useState<ShapeKey>('free')
|
|
85
|
+
|
|
86
|
+
const frameRef = React.useRef<HTMLDivElement>(null)
|
|
87
|
+
const [frameBox, setFrameBox] = React.useState<{ w: number; h: number }>({ w: 0, h: 0 })
|
|
88
|
+
React.useEffect(() => {
|
|
89
|
+
const el = frameRef.current
|
|
90
|
+
if (!el) return
|
|
91
|
+
const obs = new ResizeObserver(([entry]) => setFrameBox({ w: entry.contentRect.width, h: entry.contentRect.height }))
|
|
92
|
+
obs.observe(el)
|
|
93
|
+
return () => obs.disconnect()
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
96
|
+
const rendered =
|
|
97
|
+
srcDims && frameBox.w > 0 && frameBox.h > 0
|
|
98
|
+
? renderedSourceRect({ wrapperW: frameBox.w, wrapperH: frameBox.h, srcWidth: srcDims.width, srcHeight: srcDims.height })
|
|
99
|
+
: null
|
|
100
|
+
const windowPx = rendered ? fractionToWrapperPx({ crop, rendered }) : null
|
|
101
|
+
|
|
102
|
+
const aspectOf = (k: ShapeKey) => SHAPES.find(s => s.key === k)?.aspect ?? null
|
|
103
|
+
const lockedAspect = aspectOf(shape)
|
|
104
|
+
|
|
105
|
+
function selectShape(key: ShapeKey) {
|
|
106
|
+
const aspect = aspectOf(key)
|
|
107
|
+
setShape(key)
|
|
108
|
+
if (aspect == null || !srcDims) return // free: keep current crop
|
|
109
|
+
// Snap the current crop to the locked aspect, keeping its center; derive height
|
|
110
|
+
// from width so the crop's pixel aspect matches, then clamp into bounds.
|
|
111
|
+
const srcAspect = srcDims.width / srcDims.height
|
|
112
|
+
const center = cropCenter(crop)
|
|
113
|
+
let w = crop.w
|
|
114
|
+
let h = (w * srcAspect) / aspect
|
|
115
|
+
if (h > 1) { h = 1; w = (aspect * h) / srcAspect }
|
|
116
|
+
if (w > 1) { w = 1; h = (w * srcAspect) / aspect }
|
|
117
|
+
const x = Math.min(1 - w, Math.max(0, center.x - w / 2))
|
|
118
|
+
const y = Math.min(1 - h, Math.max(0, center.y - h / 2))
|
|
119
|
+
setCrop({ x, y, w, h })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Pointer drag: pan (inside window) + resize (handles) ──
|
|
123
|
+
const drag = React.useRef<
|
|
124
|
+
| { kind: 'pan'; startClient: { x: number; y: number }; startCrop: CropFraction }
|
|
125
|
+
| { kind: 'handle'; handle: CropHandle; startClient: { x: number; y: number }; startCrop: CropFraction }
|
|
126
|
+
| null
|
|
127
|
+
>(null)
|
|
128
|
+
|
|
129
|
+
const onPanDown = (e: React.PointerEvent) => {
|
|
130
|
+
e.stopPropagation()
|
|
131
|
+
e.currentTarget.setPointerCapture?.(e.pointerId)
|
|
132
|
+
drag.current = { kind: 'pan', startClient: { x: e.clientX, y: e.clientY }, startCrop: crop }
|
|
133
|
+
}
|
|
134
|
+
const onHandleDown = (handle: CropHandle) => (e: React.PointerEvent) => {
|
|
135
|
+
e.stopPropagation()
|
|
136
|
+
e.currentTarget.setPointerCapture?.(e.pointerId)
|
|
137
|
+
drag.current = { kind: 'handle', handle, startClient: { x: e.clientX, y: e.clientY }, startCrop: crop }
|
|
138
|
+
}
|
|
139
|
+
const onPointerMove = (e: React.PointerEvent) => {
|
|
140
|
+
const d = drag.current
|
|
141
|
+
if (!d || !srcDims) return
|
|
142
|
+
const deltaPx = { x: e.clientX - d.startClient.x, y: e.clientY - d.startClient.y }
|
|
143
|
+
const common = { wrapperW: frameBox.w, wrapperH: frameBox.h, srcWidth: srcDims.width, srcHeight: srcDims.height }
|
|
144
|
+
if (d.kind === 'pan') {
|
|
145
|
+
setCrop(translateCropPx({ crop: d.startCrop, deltaPx, ...common }))
|
|
146
|
+
} else if (lockedAspect != null && (d.handle === 'nw' || d.handle === 'ne' || d.handle === 'sw' || d.handle === 'se')) {
|
|
147
|
+
setCrop(aspectLockedCornerResize({ handle: d.handle, initialCrop: d.startCrop, deltaPx, aspect: lockedAspect, ...common }))
|
|
148
|
+
} else {
|
|
149
|
+
setCrop(clampFraction(applyCropHandleDrag({ handle: d.handle, initialCrop: d.startCrop, deltaPx, ...common })))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const onPointerUp = () => { drag.current = null }
|
|
153
|
+
|
|
154
|
+
const handles = lockedAspect != null ? CORNER_HANDLES : FREE_HANDLES
|
|
155
|
+
const ready = !!rendered && !!windowPx
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/70 p-6"
|
|
160
|
+
onPointerDown={onClose}
|
|
161
|
+
>
|
|
162
|
+
<div
|
|
163
|
+
className="flex w-full max-w-3xl flex-col gap-4 rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] p-5 shadow-2xl"
|
|
164
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
165
|
+
>
|
|
166
|
+
<div className="flex items-center justify-between">
|
|
167
|
+
<h2 className="text-sm font-semibold text-[var(--editor-text)]">Crop source</h2>
|
|
168
|
+
<span className="text-xs text-[var(--editor-text)]/50">Pick the part of the footage to keep — position & zoom live on the canvas</span>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Source frame + crop window */}
|
|
172
|
+
<div
|
|
173
|
+
ref={frameRef}
|
|
174
|
+
className="relative w-full overflow-hidden rounded-lg bg-black"
|
|
175
|
+
style={{ height: '56vh' }}
|
|
176
|
+
onPointerMove={onPointerMove}
|
|
177
|
+
onPointerUp={onPointerUp}
|
|
178
|
+
>
|
|
179
|
+
<video
|
|
180
|
+
src={srcUrl}
|
|
181
|
+
muted
|
|
182
|
+
playsInline
|
|
183
|
+
preload="auto"
|
|
184
|
+
onLoadedMetadata={(e) => {
|
|
185
|
+
const v = e.currentTarget
|
|
186
|
+
const dims = { width: v.videoWidth, height: v.videoHeight }
|
|
187
|
+
setLoadedDims(dims)
|
|
188
|
+
onSrcDimsLoaded(dims)
|
|
189
|
+
}}
|
|
190
|
+
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }}
|
|
191
|
+
/>
|
|
192
|
+
|
|
193
|
+
{ready && windowPx && (
|
|
194
|
+
<>
|
|
195
|
+
<div
|
|
196
|
+
style={{
|
|
197
|
+
position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.55)', pointerEvents: 'none',
|
|
198
|
+
clipPath: `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 0,
|
|
199
|
+
${windowPx.x}px ${windowPx.y}px,
|
|
200
|
+
${windowPx.x}px ${windowPx.y + windowPx.h}px,
|
|
201
|
+
${windowPx.x + windowPx.w}px ${windowPx.y + windowPx.h}px,
|
|
202
|
+
${windowPx.x + windowPx.w}px ${windowPx.y}px,
|
|
203
|
+
${windowPx.x}px ${windowPx.y}px)`,
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
<div
|
|
207
|
+
onPointerDown={onPanDown}
|
|
208
|
+
style={{
|
|
209
|
+
position: 'absolute', left: windowPx.x, top: windowPx.y, width: windowPx.w, height: windowPx.h,
|
|
210
|
+
outline: '2px solid var(--editor-selection)', cursor: 'move', touchAction: 'none',
|
|
211
|
+
}}
|
|
212
|
+
/>
|
|
213
|
+
{handles.map((handle) => {
|
|
214
|
+
const o = HANDLE_OFFSET[handle]
|
|
215
|
+
return (
|
|
216
|
+
<div
|
|
217
|
+
key={handle}
|
|
218
|
+
data-testid={`crop-handle-${handle}`}
|
|
219
|
+
onPointerDown={onHandleDown(handle)}
|
|
220
|
+
style={{
|
|
221
|
+
position: 'absolute',
|
|
222
|
+
left: windowPx.x + o.dx * windowPx.w - 6,
|
|
223
|
+
top: windowPx.y + o.dy * windowPx.h - 6,
|
|
224
|
+
width: 12, height: 12, backgroundColor: '#fff',
|
|
225
|
+
border: '1.5px solid var(--editor-selection)', borderRadius: 2,
|
|
226
|
+
cursor: o.cursor, zIndex: 10, touchAction: 'none',
|
|
227
|
+
}}
|
|
228
|
+
/>
|
|
229
|
+
)
|
|
230
|
+
})}
|
|
231
|
+
</>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
{/* Shape locks (constrain the crop rectangle; not output framing) */}
|
|
236
|
+
<div className="flex items-center gap-2">
|
|
237
|
+
{SHAPES.map(({ key, label, Icon }) => {
|
|
238
|
+
const active = shape === key
|
|
239
|
+
return (
|
|
240
|
+
<button
|
|
241
|
+
key={key}
|
|
242
|
+
onClick={() => selectShape(key)}
|
|
243
|
+
title={key === 'free' ? 'Free-form crop' : `Lock crop to ${label}`}
|
|
244
|
+
className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
245
|
+
active
|
|
246
|
+
? 'border-[var(--editor-selection)] bg-[var(--editor-selection)]/15 text-[var(--editor-text)]'
|
|
247
|
+
: 'border-[var(--editor-border)] text-[var(--editor-text)]/70 hover:text-[var(--editor-text)]'
|
|
248
|
+
}`}
|
|
249
|
+
>
|
|
250
|
+
<Icon size={14} />
|
|
251
|
+
{label}
|
|
252
|
+
</button>
|
|
253
|
+
)
|
|
254
|
+
})}
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
{/* Actions */}
|
|
258
|
+
<div className="flex items-center justify-end gap-2 pt-1">
|
|
259
|
+
<button
|
|
260
|
+
onClick={onClose}
|
|
261
|
+
className="rounded-md px-3 py-1.5 text-xs font-medium text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] transition-colors"
|
|
262
|
+
>
|
|
263
|
+
Cancel
|
|
264
|
+
</button>
|
|
265
|
+
<button
|
|
266
|
+
onClick={() => { onApply(clampFraction(crop)); onClose() }}
|
|
267
|
+
disabled={!ready}
|
|
268
|
+
className="rounded-md bg-[var(--editor-accent)] px-3.5 py-1.5 text-xs font-medium text-[var(--editor-accent-foreground)] hover:opacity-90 disabled:opacity-40 transition-colors"
|
|
269
|
+
>
|
|
270
|
+
Apply crop
|
|
271
|
+
</button>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/// <reference types="vitest/globals" />
|
|
2
|
-
import { renderedSourceRect } from '../crop-math'
|
|
2
|
+
import { renderedSourceRect, translateCropPx, cropForAspect, maxZoomForAspect, aspectLockedCornerResize } from '../crop-math'
|
|
3
3
|
import { fractionToWrapperPx, wrapperPxToFraction } from '../crop-math'
|
|
4
4
|
|
|
5
5
|
describe('renderedSourceRect', () => {
|
|
@@ -187,3 +187,96 @@ describe('applyCropHandleDrag', () => {
|
|
|
187
187
|
expect(next.h).toBeCloseTo(0.5)
|
|
188
188
|
})
|
|
189
189
|
})
|
|
190
|
+
|
|
191
|
+
describe('translateCropPx (pan)', () => {
|
|
192
|
+
const SRC = { wrapperW: 400, wrapperH: 400, srcWidth: 1000, srcHeight: 1000 } // square, no letterbox
|
|
193
|
+
|
|
194
|
+
it('moves the crop by the pixel delta in fractions', () => {
|
|
195
|
+
const next = translateCropPx({
|
|
196
|
+
crop: { x: 0.2, y: 0.2, w: 0.4, h: 0.4 },
|
|
197
|
+
deltaPx: { x: 40, y: -20 }, // 40/400 = +0.1 x, -20/400 = -0.05 y
|
|
198
|
+
...SRC,
|
|
199
|
+
})
|
|
200
|
+
expect(next.x).toBeCloseTo(0.3)
|
|
201
|
+
expect(next.y).toBeCloseTo(0.15)
|
|
202
|
+
expect(next.w).toBeCloseTo(0.4)
|
|
203
|
+
expect(next.h).toBeCloseTo(0.4)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('clamps so the crop stays fully inside the source (no size change)', () => {
|
|
207
|
+
const next = translateCropPx({
|
|
208
|
+
crop: { x: 0.7, y: 0.7, w: 0.4, h: 0.4 }, // right/bottom edge at 1.1 — already overflowing intent
|
|
209
|
+
deltaPx: { x: 1000, y: 1000 },
|
|
210
|
+
...SRC,
|
|
211
|
+
})
|
|
212
|
+
// pinned to the far corner: x = 1 - w, y = 1 - h
|
|
213
|
+
expect(next.x).toBeCloseTo(0.6)
|
|
214
|
+
expect(next.y).toBeCloseTo(0.6)
|
|
215
|
+
expect(next.w).toBeCloseTo(0.4)
|
|
216
|
+
expect(next.h).toBeCloseTo(0.4)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('cropForAspect + maxZoomForAspect', () => {
|
|
221
|
+
it('9:16 crop from a 16:9 source → full height, narrow centered width', () => {
|
|
222
|
+
// source 1920x1080 (aspect 1.778), target 9/16=0.5625 < srcAspect → full height
|
|
223
|
+
const c = cropForAspect({ aspect: 9 / 16, zoom: 1, srcWidth: 1920, srcHeight: 1080 })
|
|
224
|
+
expect(c.h).toBeCloseTo(1)
|
|
225
|
+
// w = aspect/srcAspect = 0.5625 / 1.7778 = 0.3164
|
|
226
|
+
expect(c.w).toBeCloseTo((9 / 16) / (1920 / 1080), 3)
|
|
227
|
+
expect(c.x).toBeCloseTo((1 - c.w) / 2) // centered
|
|
228
|
+
expect(c.y).toBeCloseTo(0)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('16:9 crop from a 16:9 source → the whole frame at zoom 1', () => {
|
|
232
|
+
const c = cropForAspect({ aspect: 16 / 9, zoom: 1, srcWidth: 1920, srcHeight: 1080 })
|
|
233
|
+
expect(c.w).toBeCloseTo(1)
|
|
234
|
+
expect(c.h).toBeCloseTo(1)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('zoom shrinks the crop around its center', () => {
|
|
238
|
+
const c1 = cropForAspect({ aspect: 1, zoom: 1, srcWidth: 1000, srcHeight: 1000 })
|
|
239
|
+
const c2 = cropForAspect({ aspect: 1, zoom: 2, srcWidth: 1000, srcHeight: 1000 })
|
|
240
|
+
expect(c1.w).toBeCloseTo(1)
|
|
241
|
+
expect(c2.w).toBeCloseTo(0.5) // half size at 2×
|
|
242
|
+
expect(c2.x).toBeCloseTo(0.25) // still centered
|
|
243
|
+
expect(c2.y).toBeCloseTo(0.25)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('maxZoomForAspect keeps the crop at/above the 2% floor', () => {
|
|
247
|
+
// 1:1 on a square source: base w=h=1, floor 0.02 → max zoom 50
|
|
248
|
+
const z = maxZoomForAspect({ aspect: 1, srcWidth: 1000, srcHeight: 1000 })
|
|
249
|
+
expect(z).toBeCloseTo(50)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe('aspectLockedCornerResize', () => {
|
|
254
|
+
it('keeps the locked pixel aspect and anchors the opposite corner (se drag)', () => {
|
|
255
|
+
// square source 1000x1000 in a 400x400 frame → rendered fills the frame.
|
|
256
|
+
const next = aspectLockedCornerResize({
|
|
257
|
+
handle: 'se',
|
|
258
|
+
initialCrop: { x: 0.2, y: 0.2, w: 0.2, h: 0.2 }, // nw corner anchored at (0.2,0.2)
|
|
259
|
+
deltaPx: { x: 100, y: 0 }, // drag se corner right
|
|
260
|
+
wrapperW: 400, wrapperH: 400, srcWidth: 1000, srcHeight: 1000,
|
|
261
|
+
aspect: 16 / 9,
|
|
262
|
+
})
|
|
263
|
+
expect(next.x).toBeCloseTo(0.2) // anchor held
|
|
264
|
+
expect(next.y).toBeCloseTo(0.2)
|
|
265
|
+
const pixelAspect = (next.w * 1000) / (next.h * 1000)
|
|
266
|
+
expect(pixelAspect).toBeCloseTo(16 / 9, 2)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('shrinks to fit when the locked box would overflow the source bounds', () => {
|
|
270
|
+
const next = aspectLockedCornerResize({
|
|
271
|
+
handle: 'se',
|
|
272
|
+
initialCrop: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 },
|
|
273
|
+
deltaPx: { x: 100000, y: 0 }, // absurd outward drag
|
|
274
|
+
wrapperW: 400, wrapperH: 400, srcWidth: 1000, srcHeight: 1000,
|
|
275
|
+
aspect: 16 / 9,
|
|
276
|
+
})
|
|
277
|
+
// stays within [0,1] and keeps the aspect
|
|
278
|
+
expect(next.x + next.w).toBeLessThanOrEqual(1.0001)
|
|
279
|
+
expect(next.y + next.h).toBeLessThanOrEqual(1.0001)
|
|
280
|
+
expect((next.w * 1000) / (next.h * 1000)).toBeCloseTo(16 / 9, 2)
|
|
281
|
+
})
|
|
282
|
+
})
|
package/src/crop/crop-math.ts
CHANGED
|
@@ -123,3 +123,128 @@ export function applyCropHandleDrag(args: {
|
|
|
123
123
|
rendered,
|
|
124
124
|
})
|
|
125
125
|
}
|
|
126
|
+
|
|
127
|
+
// Move (pan) the crop window by a pixel delta, keeping its size and clamping so
|
|
128
|
+
// it stays fully inside the rendered source rect. Used by the crop modal's
|
|
129
|
+
// drag-to-reposition.
|
|
130
|
+
export function translateCropPx(args: {
|
|
131
|
+
crop: CropFraction
|
|
132
|
+
deltaPx: { x: number; y: number }
|
|
133
|
+
wrapperW: number
|
|
134
|
+
wrapperH: number
|
|
135
|
+
srcWidth: number
|
|
136
|
+
srcHeight: number
|
|
137
|
+
}): CropFraction {
|
|
138
|
+
const { crop, deltaPx, wrapperW, wrapperH, srcWidth, srcHeight } = args
|
|
139
|
+
const rendered = renderedSourceRect({ wrapperW, wrapperH, srcWidth, srcHeight })
|
|
140
|
+
const px = fractionToWrapperPx({ crop, rendered })
|
|
141
|
+
|
|
142
|
+
const minLeft = rendered.offsetX
|
|
143
|
+
const minTop = rendered.offsetY
|
|
144
|
+
const maxLeft = rendered.offsetX + rendered.width - px.w
|
|
145
|
+
const maxTop = rendered.offsetY + rendered.height - px.h
|
|
146
|
+
|
|
147
|
+
const left = Math.min(maxLeft, Math.max(minLeft, px.x + deltaPx.x))
|
|
148
|
+
const top = Math.min(maxTop, Math.max(minTop, px.y + deltaPx.y))
|
|
149
|
+
|
|
150
|
+
return wrapperPxToFraction({ px: { x: left, y: top, w: px.w, h: px.h }, rendered })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Aspect-locked CORNER resize for the crop modal's shape-lock presets. Because
|
|
154
|
+
// `renderedSourceRect` scales x and y uniformly, a crop's wrapper-px aspect equals
|
|
155
|
+
// its source-pixel aspect — so we can lock the aspect directly in wrapper px. The
|
|
156
|
+
// corner opposite the dragged handle stays fixed; the dragged corner follows the
|
|
157
|
+
// pointer's dominant axis, and the other dimension is derived from `aspect`. Edge
|
|
158
|
+
// handles aren't supported here (the modal hides them when an aspect is locked).
|
|
159
|
+
export function aspectLockedCornerResize(args: {
|
|
160
|
+
handle: 'nw' | 'ne' | 'sw' | 'se'
|
|
161
|
+
initialCrop: CropFraction
|
|
162
|
+
deltaPx: { x: number; y: number }
|
|
163
|
+
wrapperW: number
|
|
164
|
+
wrapperH: number
|
|
165
|
+
srcWidth: number
|
|
166
|
+
srcHeight: number
|
|
167
|
+
aspect: number // target pixel aspect w/h
|
|
168
|
+
}): CropFraction {
|
|
169
|
+
const { handle, initialCrop, deltaPx, wrapperW, wrapperH, srcWidth, srcHeight, aspect } = args
|
|
170
|
+
const rendered = renderedSourceRect({ wrapperW, wrapperH, srcWidth, srcHeight })
|
|
171
|
+
const px = fractionToWrapperPx({ crop: initialCrop, rendered })
|
|
172
|
+
|
|
173
|
+
const isW = handle === 'nw' || handle === 'sw'
|
|
174
|
+
const isN = handle === 'nw' || handle === 'ne'
|
|
175
|
+
// Opposite corner stays fixed.
|
|
176
|
+
const fixedX = isW ? px.x + px.w : px.x
|
|
177
|
+
const fixedY = isN ? px.y + px.h : px.y
|
|
178
|
+
// Dragged corner after the pointer delta.
|
|
179
|
+
const dragX = (isW ? px.x : px.x + px.w) + deltaPx.x
|
|
180
|
+
|
|
181
|
+
// Width is driven by the horizontal drag; height derived from the locked aspect.
|
|
182
|
+
const minW = MIN_FRACTION * rendered.width
|
|
183
|
+
let cw = Math.max(minW, Math.abs(dragX - fixedX))
|
|
184
|
+
let ch = cw / aspect
|
|
185
|
+
|
|
186
|
+
// Clamp the box to the rendered source bounds, preserving aspect (shrink to fit).
|
|
187
|
+
const dirX = isW ? -1 : 1
|
|
188
|
+
const dirY = isN ? -1 : 1
|
|
189
|
+
const maxW = dirX < 0 ? fixedX - rendered.offsetX : rendered.offsetX + rendered.width - fixedX
|
|
190
|
+
const maxH = dirY < 0 ? fixedY - rendered.offsetY : rendered.offsetY + rendered.height - fixedY
|
|
191
|
+
if (cw > maxW) { cw = maxW; ch = cw / aspect }
|
|
192
|
+
if (ch > maxH) { ch = maxH; cw = ch * aspect }
|
|
193
|
+
|
|
194
|
+
const left = dirX < 0 ? fixedX - cw : fixedX
|
|
195
|
+
const top = dirY < 0 ? fixedY - ch : fixedY
|
|
196
|
+
return wrapperPxToFraction({ px: { x: left, y: top, w: cw, h: ch }, rendered })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Compute a centered crop with a fixed OUTPUT aspect (width/height in *pixels*,
|
|
200
|
+
// e.g. 9/16) at a given zoom. `zoom` >= 1: 1 is the largest crop with that aspect
|
|
201
|
+
// that fits the source; higher zooms shrink the crop (magnifying the output).
|
|
202
|
+
// `center` (source fractions) is preserved where possible, then clamped so the
|
|
203
|
+
// crop stays inside [0, 1]. Used by the crop modal's preset + zoom controls.
|
|
204
|
+
export function cropForAspect(args: {
|
|
205
|
+
aspect: number
|
|
206
|
+
zoom: number
|
|
207
|
+
srcWidth: number
|
|
208
|
+
srcHeight: number
|
|
209
|
+
center?: { x: number; y: number }
|
|
210
|
+
}): CropFraction {
|
|
211
|
+
const { aspect, srcWidth, srcHeight } = args
|
|
212
|
+
const srcAspect = srcWidth / srcHeight
|
|
213
|
+
|
|
214
|
+
// Largest crop fractions whose pixel aspect (w·srcW)/(h·srcH) equals `aspect`.
|
|
215
|
+
let w: number
|
|
216
|
+
let h: number
|
|
217
|
+
if (aspect >= srcAspect) {
|
|
218
|
+
// Crop is wider than the source → full source width, letterbox vertically.
|
|
219
|
+
w = 1
|
|
220
|
+
h = srcAspect / aspect
|
|
221
|
+
} else {
|
|
222
|
+
// Crop is taller than the source → full source height, pillarbox horizontally.
|
|
223
|
+
h = 1
|
|
224
|
+
w = aspect / srcAspect
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const z = Math.max(1, args.zoom)
|
|
228
|
+
w = Math.min(1, w / z)
|
|
229
|
+
h = Math.min(1, h / z)
|
|
230
|
+
|
|
231
|
+
const cx = args.center?.x ?? 0.5
|
|
232
|
+
const cy = args.center?.y ?? 0.5
|
|
233
|
+
const x = Math.min(1 - w, Math.max(0, cx - w / 2))
|
|
234
|
+
const y = Math.min(1 - h, Math.max(0, cy - h / 2))
|
|
235
|
+
return { x, y, w, h }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The smallest zoom (>= 1) that keeps a crop of the given aspect at or above the
|
|
239
|
+
// 2% min-fraction floor on both axes — i.e. the max useful zoom for the slider.
|
|
240
|
+
export function maxZoomForAspect(args: {
|
|
241
|
+
aspect: number
|
|
242
|
+
srcWidth: number
|
|
243
|
+
srcHeight: number
|
|
244
|
+
}): number {
|
|
245
|
+
const base = cropForAspect({ ...args, zoom: 1 })
|
|
246
|
+
// base.w / z >= MIN_FRACTION → z <= base.w / MIN_FRACTION (same for h)
|
|
247
|
+
const zByW = base.w / MIN_FRACTION
|
|
248
|
+
const zByH = base.h / MIN_FRACTION
|
|
249
|
+
return Math.max(1, Math.min(zByW, zByH))
|
|
250
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -503,6 +503,20 @@ export interface VideoEditorProps<P extends Project = Project> {
|
|
|
503
503
|
*/
|
|
504
504
|
assetsPlacement?: 'sidebar' | 'right' | 'bottom'
|
|
505
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Opt a host OUT of the package's built-in toolbar Render button so it can
|
|
508
|
+
* place Render in its own chrome (e.g. the desktop OS editor's top header).
|
|
509
|
+
* When provided, the package: (a) hides the toolbar Render button, and (b)
|
|
510
|
+
* calls this callback once with a stable `openRender()` trigger that marks the
|
|
511
|
+
* project final, saves, and opens the package's RenderModal. The host stores
|
|
512
|
+
* that trigger and wires it to its own Render button.
|
|
513
|
+
*
|
|
514
|
+
* Default (prop omitted): the package renders Render in its toolbar — so Hub
|
|
515
|
+
* clients, which deliberately keep no render button in their own headers, are
|
|
516
|
+
* unaffected.
|
|
517
|
+
*/
|
|
518
|
+
onProvideRenderTrigger?: (openRender: () => void) => void
|
|
519
|
+
|
|
506
520
|
// ── Host-supplied Montaj-specific UI (render-prop seams) ──────────────────
|
|
507
521
|
// The clip/audio inspector and the subcut-regeneration tool read host-only
|
|
508
522
|
// fields (regenQueue, storyboard, the host's full Project) the package types
|
|
@@ -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)]">
|
|
@@ -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
|
|
@@ -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
|
+
}
|
|
@@ -610,6 +610,14 @@ export function useVideoPlayback(
|
|
|
610
610
|
gapNextIdxRef.current = nextIdx
|
|
611
611
|
getActiveVideo()?.pause()
|
|
612
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)
|
|
613
621
|
}
|
|
614
622
|
}
|
|
615
623
|
return
|
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import type { VisualItem } from '../schema'
|
|
3
|
-
import {
|
|
4
|
-
renderedSourceRect,
|
|
5
|
-
fractionToWrapperPx,
|
|
6
|
-
applyCropHandleDrag,
|
|
7
|
-
type CropFraction,
|
|
8
|
-
type CropHandle,
|
|
9
|
-
} from './crop-math'
|
|
10
|
-
|
|
11
|
-
// Sibling of CanvasCropOverlay, typed to a tracks[0] VisualItem (video) instead
|
|
12
|
-
// of an ImageElement. The full source clip is letterboxed into the preview via a
|
|
13
|
-
// muted <video>, an 8-handle free-form crop window is drawn on top, and handle
|
|
14
|
-
// drags emit the updated `sourceCrop` as source fractions clamped to [0, 1].
|
|
15
|
-
//
|
|
16
|
-
// Source dims come either from the item (sourceWidth/sourceHeight, set on a prior
|
|
17
|
-
// crop) or from the <video>'s videoWidth/videoHeight on loadedmetadata — exactly
|
|
18
|
-
// analogous to CanvasCropOverlay reading naturalWidth/Height from <img> onLoad.
|
|
19
|
-
|
|
20
|
-
const CROP_HANDLES: CropHandle[] = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']
|
|
21
|
-
|
|
22
|
-
const HANDLE_OFFSET: Record<CropHandle, { dx: 0 | 0.5 | 1; dy: 0 | 0.5 | 1; cursor: string }> = {
|
|
23
|
-
nw: { dx: 0, dy: 0, cursor: 'nw-resize' },
|
|
24
|
-
n: { dx: 0.5, dy: 0, cursor: 'n-resize' },
|
|
25
|
-
ne: { dx: 1, dy: 0, cursor: 'ne-resize' },
|
|
26
|
-
w: { dx: 0, dy: 0.5, cursor: 'w-resize' },
|
|
27
|
-
e: { dx: 1, dy: 0.5, cursor: 'e-resize' },
|
|
28
|
-
sw: { dx: 0, dy: 1, cursor: 'sw-resize' },
|
|
29
|
-
s: { dx: 0.5, dy: 1, cursor: 's-resize' },
|
|
30
|
-
se: { dx: 1, dy: 1, cursor: 'se-resize' },
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const DEFAULT_CROP: CropFraction = { x: 0, y: 0, w: 1, h: 1 }
|
|
34
|
-
|
|
35
|
-
function clampFraction(c: CropFraction): CropFraction {
|
|
36
|
-
const x = Math.min(1, Math.max(0, c.x))
|
|
37
|
-
const y = Math.min(1, Math.max(0, c.y))
|
|
38
|
-
return {
|
|
39
|
-
x,
|
|
40
|
-
y,
|
|
41
|
-
w: Math.min(1 - x, Math.max(0, c.w)),
|
|
42
|
-
h: Math.min(1 - y, Math.max(0, c.h)),
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export type VideoSourceCropOverlayProps = {
|
|
47
|
-
/** The tracks[0] video item being cropped. */
|
|
48
|
-
item: VisualItem
|
|
49
|
-
/**
|
|
50
|
-
* Host-supplied resolver for the clip's preview URL. Implement on the caller
|
|
51
|
-
* side as `(item) => adapter.fileUrl(item.nobg_preview_src ?? item.src)`.
|
|
52
|
-
*/
|
|
53
|
-
resolveSrc: (item: VisualItem) => string
|
|
54
|
-
/** Wrapper width in CSS px (the rect the overlay fills — the preview area). */
|
|
55
|
-
wrapperWidth: number
|
|
56
|
-
/** Wrapper height in CSS px. */
|
|
57
|
-
wrapperHeight: number
|
|
58
|
-
/** Called once on pointer-up (drag end) with the final clamped [0,1] fractions. */
|
|
59
|
-
onChange: (next: CropFraction) => void
|
|
60
|
-
/** Called once when the <video> fires loadedmetadata with the source's intrinsic dims. */
|
|
61
|
-
onSrcDimsLoaded: (dims: { width: number; height: number }) => void
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function VideoSourceCropOverlay({
|
|
65
|
-
item,
|
|
66
|
-
resolveSrc,
|
|
67
|
-
wrapperWidth,
|
|
68
|
-
wrapperHeight,
|
|
69
|
-
onChange,
|
|
70
|
-
onSrcDimsLoaded,
|
|
71
|
-
}: VideoSourceCropOverlayProps) {
|
|
72
|
-
const srcUrl = resolveSrc(item)
|
|
73
|
-
|
|
74
|
-
// Local mirror of dims loaded from the <video> when the item doesn't already
|
|
75
|
-
// carry them, so the crop window renders as soon as metadata arrives.
|
|
76
|
-
const [loadedDims, setLoadedDims] = React.useState<{ width: number; height: number } | null>(null)
|
|
77
|
-
const srcDims =
|
|
78
|
-
item.sourceWidth && item.sourceHeight
|
|
79
|
-
? { width: item.sourceWidth, height: item.sourceHeight }
|
|
80
|
-
: loadedDims
|
|
81
|
-
|
|
82
|
-
// Prop-driven crop (source of truth when not dragging)
|
|
83
|
-
const propCrop = item.sourceCrop ?? DEFAULT_CROP
|
|
84
|
-
|
|
85
|
-
// In-flight drag crop — non-null only during an active handle drag.
|
|
86
|
-
// While dragging, the rendered window follows this local state for smoothness.
|
|
87
|
-
// On pointer-up it's cleared after calling onChange once with the final value.
|
|
88
|
-
const [liveCrop, setLiveCrop] = React.useState<CropFraction | null>(null)
|
|
89
|
-
// Ref mirror so onHandlePointerUp always reads the latest value regardless of closure age.
|
|
90
|
-
const liveCropRef = React.useRef<CropFraction | null>(null)
|
|
91
|
-
|
|
92
|
-
// The crop we actually render: live state during drag, props otherwise.
|
|
93
|
-
const localCrop = liveCrop ?? propCrop
|
|
94
|
-
|
|
95
|
-
const rendered =
|
|
96
|
-
srcDims && wrapperWidth > 0 && wrapperHeight > 0
|
|
97
|
-
? renderedSourceRect({
|
|
98
|
-
wrapperW: wrapperWidth,
|
|
99
|
-
wrapperH: wrapperHeight,
|
|
100
|
-
srcWidth: srcDims.width,
|
|
101
|
-
srcHeight: srcDims.height,
|
|
102
|
-
})
|
|
103
|
-
: null
|
|
104
|
-
const windowPx = rendered ? fractionToWrapperPx({ crop: localCrop, rendered }) : null
|
|
105
|
-
|
|
106
|
-
const dragStateRef = React.useRef<{
|
|
107
|
-
handle: CropHandle
|
|
108
|
-
startClient: { x: number; y: number }
|
|
109
|
-
startCrop: CropFraction
|
|
110
|
-
} | null>(null)
|
|
111
|
-
|
|
112
|
-
const onHandlePointerDown = (handle: CropHandle, e: React.PointerEvent<HTMLDivElement>) => {
|
|
113
|
-
e.stopPropagation()
|
|
114
|
-
e.currentTarget.setPointerCapture?.(e.pointerId)
|
|
115
|
-
dragStateRef.current = {
|
|
116
|
-
handle,
|
|
117
|
-
startClient: { x: e.clientX, y: e.clientY },
|
|
118
|
-
startCrop: propCrop,
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const onHandlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
123
|
-
const drag = dragStateRef.current
|
|
124
|
-
if (!drag || !srcDims) return
|
|
125
|
-
const dx = e.clientX - drag.startClient.x
|
|
126
|
-
const dy = e.clientY - drag.startClient.y
|
|
127
|
-
const next = applyCropHandleDrag({
|
|
128
|
-
handle: drag.handle,
|
|
129
|
-
initialCrop: drag.startCrop,
|
|
130
|
-
deltaPx: { x: dx, y: dy },
|
|
131
|
-
wrapperW: wrapperWidth,
|
|
132
|
-
wrapperH: wrapperHeight,
|
|
133
|
-
srcWidth: srcDims.width,
|
|
134
|
-
srcHeight: srcDims.height,
|
|
135
|
-
})
|
|
136
|
-
// Accumulate in local state for smooth visual feedback — do NOT call onChange yet.
|
|
137
|
-
const clamped = clampFraction(next)
|
|
138
|
-
liveCropRef.current = clamped
|
|
139
|
-
setLiveCrop(clamped)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const onHandlePointerUp = () => {
|
|
143
|
-
const drag = dragStateRef.current
|
|
144
|
-
dragStateRef.current = null
|
|
145
|
-
const final = liveCropRef.current
|
|
146
|
-
liveCropRef.current = null
|
|
147
|
-
setLiveCrop(null)
|
|
148
|
-
if (drag && final !== null) {
|
|
149
|
-
// Call onChange exactly once with the final value.
|
|
150
|
-
onChange(final)
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return (
|
|
155
|
-
<div
|
|
156
|
-
style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'auto' }}
|
|
157
|
-
onPointerDown={(e) => {
|
|
158
|
-
e.stopPropagation()
|
|
159
|
-
}}
|
|
160
|
-
>
|
|
161
|
-
{/* Letterbox preview of the full source clip */}
|
|
162
|
-
<video
|
|
163
|
-
src={srcUrl}
|
|
164
|
-
muted
|
|
165
|
-
playsInline
|
|
166
|
-
preload="metadata"
|
|
167
|
-
onLoadedMetadata={(e) => {
|
|
168
|
-
const v = e.currentTarget
|
|
169
|
-
const dims = { width: v.videoWidth, height: v.videoHeight }
|
|
170
|
-
setLoadedDims(dims)
|
|
171
|
-
onSrcDimsLoaded(dims)
|
|
172
|
-
}}
|
|
173
|
-
style={{
|
|
174
|
-
position: 'absolute',
|
|
175
|
-
inset: 0,
|
|
176
|
-
width: '100%',
|
|
177
|
-
height: '100%',
|
|
178
|
-
objectFit: 'contain',
|
|
179
|
-
pointerEvents: 'none',
|
|
180
|
-
}}
|
|
181
|
-
/>
|
|
182
|
-
|
|
183
|
-
{windowPx && (
|
|
184
|
-
<>
|
|
185
|
-
{/* Dark dimming mask outside the crop window */}
|
|
186
|
-
<div
|
|
187
|
-
data-testid="crop-dim"
|
|
188
|
-
style={{
|
|
189
|
-
position: 'absolute',
|
|
190
|
-
inset: 0,
|
|
191
|
-
background: 'rgba(0, 0, 0, 0.55)',
|
|
192
|
-
clipPath: `polygon(
|
|
193
|
-
0 0, 100% 0, 100% 100%, 0 100%, 0 0,
|
|
194
|
-
${windowPx.x}px ${windowPx.y}px,
|
|
195
|
-
${windowPx.x}px ${windowPx.y + windowPx.h}px,
|
|
196
|
-
${windowPx.x + windowPx.w}px ${windowPx.y + windowPx.h}px,
|
|
197
|
-
${windowPx.x + windowPx.w}px ${windowPx.y}px,
|
|
198
|
-
${windowPx.x}px ${windowPx.y}px
|
|
199
|
-
)`,
|
|
200
|
-
pointerEvents: 'none',
|
|
201
|
-
}}
|
|
202
|
-
/>
|
|
203
|
-
|
|
204
|
-
{/* Crop window border */}
|
|
205
|
-
<div
|
|
206
|
-
data-testid="crop-window"
|
|
207
|
-
style={{
|
|
208
|
-
position: 'absolute',
|
|
209
|
-
left: windowPx.x,
|
|
210
|
-
top: windowPx.y,
|
|
211
|
-
width: windowPx.w,
|
|
212
|
-
height: windowPx.h,
|
|
213
|
-
outline: '2px solid var(--editor-selection)',
|
|
214
|
-
pointerEvents: 'none',
|
|
215
|
-
}}
|
|
216
|
-
/>
|
|
217
|
-
|
|
218
|
-
{/* 8 resize handles */}
|
|
219
|
-
{CROP_HANDLES.map((handle) => {
|
|
220
|
-
const o = HANDLE_OFFSET[handle]
|
|
221
|
-
return (
|
|
222
|
-
<div
|
|
223
|
-
key={handle}
|
|
224
|
-
data-testid={`crop-handle-${handle}`}
|
|
225
|
-
onPointerDown={(e) => onHandlePointerDown(handle, e)}
|
|
226
|
-
onPointerMove={onHandlePointerMove}
|
|
227
|
-
onPointerUp={onHandlePointerUp}
|
|
228
|
-
style={{
|
|
229
|
-
position: 'absolute',
|
|
230
|
-
left: windowPx.x + o.dx * windowPx.w - 6,
|
|
231
|
-
top: windowPx.y + o.dy * windowPx.h - 6,
|
|
232
|
-
width: 12,
|
|
233
|
-
height: 12,
|
|
234
|
-
backgroundColor: '#fff',
|
|
235
|
-
border: '1.5px solid var(--editor-selection)',
|
|
236
|
-
borderRadius: 2,
|
|
237
|
-
cursor: o.cursor,
|
|
238
|
-
zIndex: 10,
|
|
239
|
-
pointerEvents: 'auto',
|
|
240
|
-
touchAction: 'none',
|
|
241
|
-
}}
|
|
242
|
-
/>
|
|
243
|
-
)
|
|
244
|
-
})}
|
|
245
|
-
</>
|
|
246
|
-
)}
|
|
247
|
-
</div>
|
|
248
|
-
)
|
|
249
|
-
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
-
import { fireEvent, render, cleanup } from '@testing-library/react'
|
|
3
|
-
import type { VisualItem } from '../../schema'
|
|
4
|
-
import { VideoSourceCropOverlay } from '../VideoSourceCropOverlay'
|
|
5
|
-
|
|
6
|
-
afterEach(() => cleanup())
|
|
7
|
-
|
|
8
|
-
// Aspect-matched fixture: wrapper 400×500, source 800×1000. The rendered source
|
|
9
|
-
// fills the wrapper with no letterbox, so crop fractions map 1:1 onto wrapper px
|
|
10
|
-
// (x over 400, y over 500). Initial crop (0.2,0.2,0.6,0.6) → px rect (80,100)–(320,400).
|
|
11
|
-
function makeItem(overrides: Partial<VisualItem> = {}): VisualItem {
|
|
12
|
-
return {
|
|
13
|
-
id: 'v1',
|
|
14
|
-
type: 'video',
|
|
15
|
-
src: '/clip.mov',
|
|
16
|
-
start: 0,
|
|
17
|
-
end: 5,
|
|
18
|
-
sourceWidth: 800,
|
|
19
|
-
sourceHeight: 1000,
|
|
20
|
-
sourceCrop: { x: 0.2, y: 0.2, w: 0.6, h: 0.6 },
|
|
21
|
-
...overrides,
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe('VideoSourceCropOverlay', () => {
|
|
26
|
-
it('renders the 8 crop handles for a video item with known source dims', () => {
|
|
27
|
-
const { getByTestId } = render(
|
|
28
|
-
<VideoSourceCropOverlay
|
|
29
|
-
item={makeItem()}
|
|
30
|
-
resolveSrc={() => '/clip.mov'}
|
|
31
|
-
wrapperWidth={400}
|
|
32
|
-
wrapperHeight={500}
|
|
33
|
-
onChange={vi.fn()}
|
|
34
|
-
onSrcDimsLoaded={vi.fn()}
|
|
35
|
-
/>,
|
|
36
|
-
)
|
|
37
|
-
for (const h of ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']) {
|
|
38
|
-
expect(getByTestId(`crop-handle-${h}`)).toBeTruthy()
|
|
39
|
-
}
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('emits onChange exactly once on pointer-up (not per move) with the final clamped [0,1] fractions', () => {
|
|
43
|
-
const onChange = vi.fn()
|
|
44
|
-
const { getByTestId } = render(
|
|
45
|
-
<VideoSourceCropOverlay
|
|
46
|
-
item={makeItem()}
|
|
47
|
-
resolveSrc={() => '/clip.mov'}
|
|
48
|
-
wrapperWidth={400}
|
|
49
|
-
wrapperHeight={500}
|
|
50
|
-
onChange={onChange}
|
|
51
|
-
onSrcDimsLoaded={vi.fn()}
|
|
52
|
-
/>,
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
const se = getByTestId('crop-handle-se')
|
|
56
|
-
// Simulate a full drag sequence: pointerdown → multiple pointermoves → pointerup.
|
|
57
|
-
// jsdom has no PointerEvent constructor, so fireEvent.pointer* drops
|
|
58
|
-
// clientX/Y. Dispatch MouseEvents under the pointer* type names instead —
|
|
59
|
-
// MouseEvent carries clientX/Y and React listens on the event type string.
|
|
60
|
-
se.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 320, clientY: 400 }))
|
|
61
|
-
// Multiple intermediate moves — onChange must NOT be called during these.
|
|
62
|
-
se.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 290, clientY: 400 }))
|
|
63
|
-
se.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 270, clientY: 400 }))
|
|
64
|
-
|
|
65
|
-
// onChange must not fire during move — only after release.
|
|
66
|
-
expect(onChange).not.toHaveBeenCalled()
|
|
67
|
-
|
|
68
|
-
// Release — final position: SE handle moved left 50 px from start (320 → 270).
|
|
69
|
-
se.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 270, clientY: 400 }))
|
|
70
|
-
|
|
71
|
-
// onChange called exactly once on pointer-up.
|
|
72
|
-
expect(onChange).toHaveBeenCalledTimes(1)
|
|
73
|
-
|
|
74
|
-
const next = onChange.mock.calls[0][0]
|
|
75
|
-
// dx = -50 px over a 400-px-wide rendered source = -0.125 fractions.
|
|
76
|
-
expect(next.x).toBeCloseTo(0.2)
|
|
77
|
-
expect(next.y).toBeCloseTo(0.2)
|
|
78
|
-
expect(next.w).toBeCloseTo(0.6 - 50 / 400) // 0.475
|
|
79
|
-
expect(next.h).toBeCloseTo(0.6)
|
|
80
|
-
// All emitted fractions stay within [0, 1].
|
|
81
|
-
for (const v of [next.x, next.y, next.w, next.h]) {
|
|
82
|
-
expect(v).toBeGreaterThanOrEqual(0)
|
|
83
|
-
expect(v).toBeLessThanOrEqual(1)
|
|
84
|
-
}
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('reads source dims from the <video> loadedmetadata when not supplied on the item', () => {
|
|
88
|
-
const onSrcDimsLoaded = vi.fn()
|
|
89
|
-
const { container } = render(
|
|
90
|
-
<VideoSourceCropOverlay
|
|
91
|
-
item={makeItem({ sourceWidth: undefined, sourceHeight: undefined })}
|
|
92
|
-
resolveSrc={() => '/clip.mov'}
|
|
93
|
-
wrapperWidth={400}
|
|
94
|
-
wrapperHeight={500}
|
|
95
|
-
onChange={vi.fn()}
|
|
96
|
-
onSrcDimsLoaded={onSrcDimsLoaded}
|
|
97
|
-
/>,
|
|
98
|
-
)
|
|
99
|
-
const video = container.querySelector('video') as HTMLVideoElement
|
|
100
|
-
Object.defineProperty(video, 'videoWidth', { value: 800, configurable: true })
|
|
101
|
-
Object.defineProperty(video, 'videoHeight', { value: 1000, configurable: true })
|
|
102
|
-
fireEvent.loadedMetadata(video)
|
|
103
|
-
expect(onSrcDimsLoaded).toHaveBeenCalledWith({ width: 800, height: 1000 })
|
|
104
|
-
})
|
|
105
|
-
})
|