@bycrux/editor 0.5.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/crop/VideoSourceCropOverlay.tsx +249 -0
- package/src/crop/__tests__/VideoSourceCropOverlay.test.tsx +105 -0
- package/src/crop/__tests__/crop-math.test.ts +15 -0
- package/src/schema.ts +6 -1
- package/src/types.ts +9 -0
- package/src/video/CaptionRegenModal.tsx +11 -11
- package/src/video/RenderModal.tsx +21 -21
- package/src/video/VersionPanel.tsx +11 -11
- package/src/video/VideoEditor.tsx +128 -37
- package/src/video/preview/OverlayItemsLayer.tsx +3 -0
- package/src/video/preview/PreviewPlayer.tsx +49 -4
- package/src/video/preview/__tests__/sourceCropStyle.test.ts +61 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +52 -0
- package/src/video/preview/sourceCropStyle.ts +70 -0
- package/src/video/preview/useVideoPlayback.ts +55 -14
package/package.json
CHANGED
|
@@ -0,0 +1,249 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
})
|
|
@@ -55,6 +55,21 @@ describe('wrapperPxToFraction', () => {
|
|
|
55
55
|
})
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
describe('video-source round-trip (regression lock: helpers are element-agnostic)', () => {
|
|
59
|
+
it('12. round-trips a CropFraction through fractionToWrapperPx → wrapperPxToFraction for a 1920×1080 source in a 1080×1920 wrapper', () => {
|
|
60
|
+
// 1920×1080 landscape video rendered inside a 1080×1920 portrait wrapper.
|
|
61
|
+
// Source is much wider than the wrapper, so it letterboxes left/right.
|
|
62
|
+
const rendered = renderedSourceRect({ wrapperW: 1080, wrapperH: 1920, srcWidth: 1920, srcHeight: 1080 })
|
|
63
|
+
const orig: import('../crop-math').CropFraction = { x: 0.1, y: 0.2, w: 0.6, h: 0.5 }
|
|
64
|
+
const px = fractionToWrapperPx({ crop: orig, rendered })
|
|
65
|
+
const back = wrapperPxToFraction({ px, rendered })
|
|
66
|
+
expect(back.x).toBeCloseTo(orig.x)
|
|
67
|
+
expect(back.y).toBeCloseTo(orig.y)
|
|
68
|
+
expect(back.w).toBeCloseTo(orig.w)
|
|
69
|
+
expect(back.h).toBeCloseTo(orig.h)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
58
73
|
import { applyCropHandleDrag } from '../crop-math'
|
|
59
74
|
|
|
60
75
|
// Standard fixture: 400x500 element, 800x1000 source — aspect-matched so the
|
package/src/schema.ts
CHANGED
|
@@ -76,7 +76,11 @@ export interface VisualItem {
|
|
|
76
76
|
remove_bg?: boolean // video type only
|
|
77
77
|
nobg_src?: string // video type only — ProRes 4444 .mov for final render
|
|
78
78
|
nobg_preview_src?: string // video type only — VP9 WebM with alpha for browser preview
|
|
79
|
+
normalizedSrc?: string // derived per-window normalized cache; render/preview prefer it; src stays original
|
|
79
80
|
muted?: boolean // video type only — suppress audio in preview and render
|
|
81
|
+
sourceCrop?: { x: number; y: number; w: number; h: number } // video type only — non-destructive crop of the source clip (0–1 fractions)
|
|
82
|
+
sourceWidth?: number // video type only — intrinsic width of the source clip in pixels
|
|
83
|
+
sourceHeight?: number // video type only — intrinsic height of the source clip in pixels
|
|
80
84
|
generation?: { // ai_video only — frozen provenance from Kling generation
|
|
81
85
|
// Single-shot fields (present when multiShot is falsy).
|
|
82
86
|
sceneId?: string
|
|
@@ -186,7 +190,7 @@ export interface Slide {
|
|
|
186
190
|
export interface EditorProject {
|
|
187
191
|
id: string
|
|
188
192
|
status: 'pending' | 'storyboard_ready' | 'draft' | 'final'
|
|
189
|
-
settings: { resolution: [number, number]; fps?: number; brandKit?: string }
|
|
193
|
+
settings: { resolution: [number, number]; fps?: number; brandKit?: string; normalize?: 'eager' | 'lazy' }
|
|
190
194
|
name?: string | null
|
|
191
195
|
editingPrompt?: string
|
|
192
196
|
slides?: Slide[]
|
|
@@ -196,6 +200,7 @@ export interface EditorProject {
|
|
|
196
200
|
assets?: Asset[]
|
|
197
201
|
carousel?: { aspect: string }
|
|
198
202
|
profile?: string
|
|
203
|
+
derivedFrom?: string // ID of the source project this was derived from (e.g. clips workflow)
|
|
199
204
|
// Host-only / pipeline fields pass through at the type level.
|
|
200
205
|
[key: string]: unknown
|
|
201
206
|
}
|
package/src/types.ts
CHANGED
|
@@ -489,6 +489,15 @@ export interface VideoEditorProps<P extends Project = Project> {
|
|
|
489
489
|
slots?: EditorSlots
|
|
490
490
|
readOnly?: boolean
|
|
491
491
|
onBackToSetup?: () => void
|
|
492
|
+
/**
|
|
493
|
+
* Where the host's `slots.assetsPanel` is placed in the review layout:
|
|
494
|
+
* - `'right'` (default) — a sidebar column to the right of the preview/timeline.
|
|
495
|
+
* The historical Montaj-local layout; preferred when horizontal space is ample.
|
|
496
|
+
* - `'bottom'` — a full-width region stacked below the editor. Used by hosts with
|
|
497
|
+
* constrained width (e.g. the Hub editor) where vertical stacking reads better.
|
|
498
|
+
* The host chooses per deployment; the package defaults to `'right'`.
|
|
499
|
+
*/
|
|
500
|
+
assetsPlacement?: 'right' | 'bottom'
|
|
492
501
|
|
|
493
502
|
// ── Host-supplied Montaj-specific UI (render-prop seams) ──────────────────
|
|
494
503
|
// The clip/audio inspector and the subcut-regeneration tool read host-only
|
|
@@ -15,7 +15,7 @@ interface CaptionRegenModalProps<P extends Project = Project> {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
function LogLine({ text }: { text: string }) {
|
|
18
|
-
let color = 'text-
|
|
18
|
+
let color = 'text-[var(--editor-text)]/60'
|
|
19
19
|
if (/ready|complete|done|transcribed/i.test(text)) color = 'text-green-400'
|
|
20
20
|
else if (/transcrib|detecting|loading|model/i.test(text)) color = 'text-sky-400'
|
|
21
21
|
else if (/extract|building|composing/i.test(text)) color = 'text-amber-400'
|
|
@@ -107,16 +107,16 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
|
|
|
107
107
|
|
|
108
108
|
return (
|
|
109
109
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
|
110
|
-
<div className="w-full max-w-3xl bg-
|
|
110
|
+
<div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden">
|
|
111
111
|
|
|
112
112
|
{/* Header */}
|
|
113
|
-
<div className="flex items-center justify-between px-5 py-4 border-b border-
|
|
113
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
|
|
114
114
|
<div className="flex items-center gap-2.5">
|
|
115
115
|
{status === 'running' && <span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />}
|
|
116
116
|
{status === 'done' && <span className="w-2 h-2 rounded-full bg-green-400" />}
|
|
117
117
|
{status === 'error' && <span className="w-2 h-2 rounded-full bg-red-400" />}
|
|
118
118
|
<div className="flex flex-col gap-0.5">
|
|
119
|
-
<h2 className="text-sm font-semibold text-
|
|
119
|
+
<h2 className="text-sm font-semibold text-[var(--editor-text)]">
|
|
120
120
|
{status === 'running' ? 'Regenerating captions…'
|
|
121
121
|
: status === 'done' ? 'Captions regenerated'
|
|
122
122
|
: 'Caption regeneration failed'}
|
|
@@ -124,7 +124,7 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
|
|
|
124
124
|
</div>
|
|
125
125
|
</div>
|
|
126
126
|
{status !== 'running' && (
|
|
127
|
-
<button onClick={onClose} className="text-
|
|
127
|
+
<button onClick={onClose} className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
|
|
128
128
|
)}
|
|
129
129
|
</div>
|
|
130
130
|
|
|
@@ -132,17 +132,17 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
|
|
|
132
132
|
<div className="relative">
|
|
133
133
|
<button
|
|
134
134
|
onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
|
|
135
|
-
className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-
|
|
135
|
+
className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] hover:border-[var(--editor-border)] transition-colors"
|
|
136
136
|
title="Copy logs"
|
|
137
137
|
>
|
|
138
138
|
Copy
|
|
139
139
|
</button>
|
|
140
140
|
<div
|
|
141
141
|
ref={logRef}
|
|
142
|
-
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-
|
|
142
|
+
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[var(--editor-text)]/80 bg-[var(--editor-surface)] flex flex-col gap-0.5"
|
|
143
143
|
>
|
|
144
144
|
{logs.length === 0 && status === 'running' && (
|
|
145
|
-
<span className="text-
|
|
145
|
+
<span className="text-[var(--editor-text)]/40 italic">Starting transcription…</span>
|
|
146
146
|
)}
|
|
147
147
|
{logs.map((line, i) => (
|
|
148
148
|
<LogLine key={i} text={line} />
|
|
@@ -154,18 +154,18 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
|
|
|
154
154
|
</div>
|
|
155
155
|
|
|
156
156
|
{/* Footer */}
|
|
157
|
-
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-
|
|
157
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
|
|
158
158
|
{status === 'running' ? (
|
|
159
159
|
<button
|
|
160
160
|
onClick={handleCancel}
|
|
161
|
-
className="text-sm px-4 py-1.5 rounded-md bg-
|
|
161
|
+
className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:bg-red-900/40 hover:border-red-700 hover:text-red-300 transition-colors"
|
|
162
162
|
>
|
|
163
163
|
Cancel
|
|
164
164
|
</button>
|
|
165
165
|
) : (
|
|
166
166
|
<button
|
|
167
167
|
onClick={onClose}
|
|
168
|
-
className="text-sm px-4 py-1.5 rounded-md bg-
|
|
168
|
+
className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)] hover:opacity-90 transition-colors"
|
|
169
169
|
>
|
|
170
170
|
Close
|
|
171
171
|
</button>
|
|
@@ -22,15 +22,15 @@ function basename(p: string) { return p.split('/').pop() ?? p }
|
|
|
22
22
|
|
|
23
23
|
function LogLine({ text }: { text: string }) {
|
|
24
24
|
const t = text.replace(/^\[montaj render\]\s*/, '')
|
|
25
|
-
let color = 'text-
|
|
25
|
+
let color = 'text-[var(--editor-text)]/60'
|
|
26
26
|
if (/ready|complete|done|encoded|assembled/i.test(t)) color = 'text-green-400'
|
|
27
27
|
else if (/rendering|bundling|launching|browsers/i.test(t)) color = 'text-sky-400'
|
|
28
28
|
else if (/trimming|building|composing/i.test(t)) color = 'text-amber-400'
|
|
29
|
-
else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-
|
|
29
|
+
else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-[var(--editor-text)]/55'
|
|
30
30
|
else if (/error|fail|warn/i.test(t)) color = 'text-red-400'
|
|
31
31
|
|
|
32
32
|
const prefix = text.startsWith('[montaj render]')
|
|
33
|
-
? <span className="text-
|
|
33
|
+
? <span className="text-[var(--editor-text)]/40">[render] </span>
|
|
34
34
|
: null
|
|
35
35
|
|
|
36
36
|
return (
|
|
@@ -133,7 +133,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
133
133
|
if (status === 'done' && outputPath) {
|
|
134
134
|
return (
|
|
135
135
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-md">
|
|
136
|
-
<div className="w-[96vw] h-[96vh] bg-
|
|
136
|
+
<div className="w-[96vw] h-[96vh] bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex overflow-hidden">
|
|
137
137
|
|
|
138
138
|
{/* Left — video */}
|
|
139
139
|
<div className="flex-1 bg-black flex items-center justify-center overflow-hidden">
|
|
@@ -147,20 +147,20 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
147
147
|
</div>
|
|
148
148
|
|
|
149
149
|
{/* Right — info panel */}
|
|
150
|
-
<div className="w-72 shrink-0 flex flex-col border-l border-
|
|
151
|
-
<div className="flex items-center justify-between px-5 py-4 border-b border-
|
|
150
|
+
<div className="w-72 shrink-0 flex flex-col border-l border-[var(--editor-border)]">
|
|
151
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
|
|
152
152
|
<div className="flex items-center gap-2.5">
|
|
153
153
|
<span className="w-2 h-2 rounded-full bg-green-400" />
|
|
154
154
|
<div>
|
|
155
|
-
<p className="text-sm font-semibold text-
|
|
156
|
-
<p className="text-xs text-
|
|
155
|
+
<p className="text-sm font-semibold text-[var(--editor-text)]">Render complete</p>
|
|
156
|
+
<p className="text-xs text-[var(--editor-text)]/60">Your video is ready.</p>
|
|
157
157
|
</div>
|
|
158
158
|
</div>
|
|
159
|
-
<button onClick={onClose} className="text-
|
|
159
|
+
<button onClick={onClose} className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
|
|
160
160
|
</div>
|
|
161
161
|
|
|
162
162
|
<div className="flex flex-col gap-3 p-5 flex-1">
|
|
163
|
-
<p className="text-xs font-mono text-
|
|
163
|
+
<p className="text-xs font-mono text-[var(--editor-text)]/55 break-all leading-relaxed">{outputPath}</p>
|
|
164
164
|
{/* Host-supplied export controls (e.g. download-all .zip). */}
|
|
165
165
|
{exportActions}
|
|
166
166
|
<a
|
|
@@ -172,7 +172,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
172
172
|
</a>
|
|
173
173
|
<button
|
|
174
174
|
onClick={onClose}
|
|
175
|
-
className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-
|
|
175
|
+
className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:opacity-90 transition-colors"
|
|
176
176
|
>
|
|
177
177
|
Close
|
|
178
178
|
</button>
|
|
@@ -185,21 +185,21 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
185
185
|
|
|
186
186
|
return (
|
|
187
187
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
|
188
|
-
<div className="w-full max-w-3xl bg-
|
|
188
|
+
<div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden">
|
|
189
189
|
|
|
190
190
|
{/* Header */}
|
|
191
|
-
<div className="flex items-center justify-between px-5 py-4 border-b border-
|
|
191
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
|
|
192
192
|
<div className="flex items-center gap-2.5">
|
|
193
193
|
{status === 'running' && <span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />}
|
|
194
194
|
{status === 'error' && <span className="w-2 h-2 rounded-full bg-red-400" />}
|
|
195
195
|
<div className="flex flex-col gap-0.5">
|
|
196
|
-
<h2 className="text-sm font-semibold text-
|
|
196
|
+
<h2 className="text-sm font-semibold text-[var(--editor-text)]">
|
|
197
197
|
{status === 'running' ? 'Rendering…' : 'Render failed'}
|
|
198
198
|
</h2>
|
|
199
199
|
</div>
|
|
200
200
|
</div>
|
|
201
201
|
{status !== 'running' && (
|
|
202
|
-
<button onClick={onClose} className="text-
|
|
202
|
+
<button onClick={onClose} className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
|
|
203
203
|
)}
|
|
204
204
|
</div>
|
|
205
205
|
|
|
@@ -207,17 +207,17 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
207
207
|
<div className="relative">
|
|
208
208
|
<button
|
|
209
209
|
onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
|
|
210
|
-
className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-
|
|
210
|
+
className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] hover:border-[var(--editor-border)] transition-colors"
|
|
211
211
|
title="Copy logs"
|
|
212
212
|
>
|
|
213
213
|
Copy
|
|
214
214
|
</button>
|
|
215
215
|
<div
|
|
216
216
|
ref={logRef}
|
|
217
|
-
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-
|
|
217
|
+
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[var(--editor-text)]/80 bg-[var(--editor-surface)] flex flex-col gap-0.5"
|
|
218
218
|
>
|
|
219
219
|
{logs.length === 0 && status === 'running' && (
|
|
220
|
-
<span className="text-
|
|
220
|
+
<span className="text-[var(--editor-text)]/40 italic">Starting render engine…</span>
|
|
221
221
|
)}
|
|
222
222
|
{logs.map((line, i) => (
|
|
223
223
|
<LogLine key={i} text={line} />
|
|
@@ -229,18 +229,18 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
229
229
|
</div>
|
|
230
230
|
|
|
231
231
|
{/* Footer */}
|
|
232
|
-
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-
|
|
232
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
|
|
233
233
|
{status === 'running' ? (
|
|
234
234
|
<button
|
|
235
235
|
onClick={handleCancel}
|
|
236
|
-
className="text-sm px-4 py-1.5 rounded-md bg-
|
|
236
|
+
className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:bg-red-900/40 hover:border-red-700 hover:text-red-300 transition-colors"
|
|
237
237
|
>
|
|
238
238
|
Cancel
|
|
239
239
|
</button>
|
|
240
240
|
) : (
|
|
241
241
|
<button
|
|
242
242
|
onClick={onClose}
|
|
243
|
-
className="text-sm px-4 py-1.5 rounded-md bg-
|
|
243
|
+
className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)] hover:opacity-90 transition-colors"
|
|
244
244
|
>
|
|
245
245
|
Close
|
|
246
246
|
</button>
|