@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,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
|
-
})
|