@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
|
@@ -42,35 +42,35 @@ export default function VersionPanel({ versions, restoring, onRestore }: Version
|
|
|
42
42
|
const deduped = dedupeVersions(versions)
|
|
43
43
|
|
|
44
44
|
return (
|
|
45
|
-
<div className="shrink-0 border-b border-
|
|
45
|
+
<div className="shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden" style={{ maxHeight: open ? 224 : 0, transition: 'max-height 0.15s ease' }}>
|
|
46
46
|
<button
|
|
47
47
|
onClick={() => setOpen(o => !o)}
|
|
48
|
-
className="flex items-center justify-between px-3 py-2 border-b border-
|
|
48
|
+
className="flex items-center justify-between px-3 py-2 border-b border-[var(--editor-border)] hover:bg-[var(--editor-surface)] transition-colors w-full text-left"
|
|
49
49
|
>
|
|
50
|
-
<span className="text-xs font-medium text-
|
|
51
|
-
<span className="text-
|
|
50
|
+
<span className="text-xs font-medium text-[var(--editor-text)]/60 uppercase tracking-wide">Versions</span>
|
|
51
|
+
<span className="text-[var(--editor-text)]/50 text-[10px]">{open ? '▲' : '▼'}</span>
|
|
52
52
|
</button>
|
|
53
53
|
<div className="overflow-y-auto p-2 flex flex-col gap-1.5">
|
|
54
54
|
{deduped.length === 0 ? (
|
|
55
|
-
<p className="text-xs text-
|
|
55
|
+
<p className="text-xs text-[var(--editor-text)]/55 text-center mt-2 px-1 leading-relaxed">No saved versions yet.</p>
|
|
56
56
|
) : deduped.map(v => {
|
|
57
57
|
const { run, label } = parseVersion(v)
|
|
58
58
|
const isDefault = label === 'draft' || label === 'final' || label === 'pending'
|
|
59
59
|
return (
|
|
60
|
-
<div key={v.hash} className="rounded border border-
|
|
60
|
+
<div key={v.hash} className="rounded border border-[var(--editor-border)] bg-[var(--editor-surface)] p-2 flex flex-col gap-1">
|
|
61
61
|
<div className="flex items-center gap-1.5">
|
|
62
|
-
<span className="text-[10px] text-
|
|
62
|
+
<span className="text-[10px] text-[var(--editor-text)]/50 shrink-0">Run {run}</span>
|
|
63
63
|
{isDefault ? (
|
|
64
|
-
<span className="text-[10px] text-
|
|
64
|
+
<span className="text-[10px] text-[var(--editor-text)]/60 capitalize">{label}</span>
|
|
65
65
|
) : (
|
|
66
|
-
<span className="text-xs font-medium text-
|
|
66
|
+
<span className="text-xs font-medium text-[var(--editor-text)] truncate capitalize" title={label}>{label}</span>
|
|
67
67
|
)}
|
|
68
68
|
</div>
|
|
69
|
-
<span className="text-[10px] text-
|
|
69
|
+
<span className="text-[10px] text-[var(--editor-text)]/55">{formatTime(v.timestamp)}</span>
|
|
70
70
|
<button
|
|
71
71
|
onClick={() => onRestore(v.hash)}
|
|
72
72
|
disabled={restoring === v.hash}
|
|
73
|
-
className="text-[10px] text-
|
|
73
|
+
className="text-[10px] text-[var(--editor-accent)] hover:opacity-80 text-left transition-colors disabled:opacity-40"
|
|
74
74
|
>
|
|
75
75
|
{restoring === v.hash ? 'Restoring…' : 'Restore →'}
|
|
76
76
|
</button>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { Magnet } from 'lucide-react'
|
|
2
|
+
import { Crop, Magnet } from 'lucide-react'
|
|
3
3
|
import type { Project, VideoEditorProps } from '../types'
|
|
4
|
+
import { VideoSourceCropOverlay } from '../crop/VideoSourceCropOverlay'
|
|
5
|
+
import { getOverlayDesignCanvas } from './design-canvas'
|
|
4
6
|
import { applyTheme, defaultMontajTheme } from '../theme'
|
|
5
7
|
import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './cuts'
|
|
6
8
|
import { repairCaptionWords } from './captionRepair'
|
|
@@ -38,6 +40,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
38
40
|
theme,
|
|
39
41
|
slots,
|
|
40
42
|
onBackToSetup,
|
|
43
|
+
assetsPlacement = 'right',
|
|
41
44
|
renderClipInspector,
|
|
42
45
|
renderSubcutRegen,
|
|
43
46
|
regenEnabled,
|
|
@@ -60,7 +63,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
60
63
|
|
|
61
64
|
if (isPending) {
|
|
62
65
|
return (
|
|
63
|
-
<div ref={containerRef} className="flex flex-col h-full bg-
|
|
66
|
+
<div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
|
|
64
67
|
<PendingSurface
|
|
65
68
|
project={project}
|
|
66
69
|
adapter={adapter}
|
|
@@ -82,6 +85,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
82
85
|
adapter={adapter}
|
|
83
86
|
onProjectChange={emit}
|
|
84
87
|
slots={slots}
|
|
88
|
+
assetsPlacement={assetsPlacement}
|
|
85
89
|
getWaveformChunks={getWaveformChunks}
|
|
86
90
|
resolveFilePath={resolveFilePath}
|
|
87
91
|
save={save}
|
|
@@ -114,6 +118,7 @@ interface SurfaceProps<P extends Project> {
|
|
|
114
118
|
adapter: VideoEditorProps<P>['adapter']
|
|
115
119
|
onProjectChange: (p: P) => void
|
|
116
120
|
slots?: VideoEditorProps<P>['slots']
|
|
121
|
+
assetsPlacement?: VideoEditorProps<P>['assetsPlacement']
|
|
117
122
|
getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
|
|
118
123
|
resolveFilePath: (path: string) => string
|
|
119
124
|
save: (p: P) => void
|
|
@@ -161,7 +166,7 @@ function PendingSurface<P extends Project>({
|
|
|
161
166
|
<div className="flex flex-1 overflow-hidden">
|
|
162
167
|
{/* Main */}
|
|
163
168
|
<div className="flex flex-col flex-1 overflow-hidden">
|
|
164
|
-
<div className="flex-1 flex items-center justify-center bg-
|
|
169
|
+
<div className="flex-1 flex items-center justify-center bg-black overflow-hidden p-4">
|
|
165
170
|
{hasTrimmedClips ? (
|
|
166
171
|
<PreviewPlayer
|
|
167
172
|
project={project}
|
|
@@ -180,12 +185,12 @@ function PendingSurface<P extends Project>({
|
|
|
180
185
|
{slots?.pendingStatus ?? (
|
|
181
186
|
<>
|
|
182
187
|
<div className="flex flex-col items-center gap-2">
|
|
183
|
-
<p className="text-
|
|
184
|
-
<p className="text-
|
|
188
|
+
<p className="text-[var(--editor-text)] text-lg font-semibold">Message your agent to start</p>
|
|
189
|
+
<p className="text-[var(--editor-text)]/60 text-sm">Nothing will happen automatically. Copy this and send it to your agent.</p>
|
|
185
190
|
</div>
|
|
186
191
|
{skillPath && (
|
|
187
|
-
<div className="w-full rounded-xl border-2 border-
|
|
188
|
-
<p className="text-
|
|
192
|
+
<div className="w-full rounded-xl border-2 border-[var(--editor-accent)]/50 bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[var(--editor-accent)]/10">
|
|
193
|
+
<p className="text-[var(--editor-accent)] text-xs font-bold uppercase tracking-widest">Send this to your agent</p>
|
|
189
194
|
<div className="flex items-start justify-between bg-black/60 border border-transparent rounded-lg px-3 py-3 font-mono gap-3">
|
|
190
195
|
<span className="text-gray-200 text-[12px] leading-relaxed break-all">
|
|
191
196
|
There is a new project pending: "{project.name ?? project.id}". Please see @{skillPath} and start. Talk to me if you run into questions.
|
|
@@ -199,7 +204,7 @@ function PendingSurface<P extends Project>({
|
|
|
199
204
|
setTimeout(() => setCopied(false), 2000)
|
|
200
205
|
}}
|
|
201
206
|
className={`shrink-0 flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md transition-colors ${
|
|
202
|
-
copied ? 'bg-green-700 text-green-200' : 'bg-
|
|
207
|
+
copied ? 'bg-green-700 text-green-200' : 'bg-[var(--editor-text)]/10 text-[var(--editor-text)]/80 hover:bg-[var(--editor-text)]/20 hover:text-[var(--editor-text)]'
|
|
203
208
|
}`}
|
|
204
209
|
title="Copy prompt"
|
|
205
210
|
>
|
|
@@ -210,11 +215,11 @@ function PendingSurface<P extends Project>({
|
|
|
210
215
|
)}
|
|
211
216
|
</>
|
|
212
217
|
)}
|
|
213
|
-
<p className="text-
|
|
218
|
+
<p className="text-[var(--editor-text)]/40 text-xs font-mono">project id: {project.id}</p>
|
|
214
219
|
{canGoBack && (
|
|
215
220
|
<button
|
|
216
221
|
onClick={onBackToSetup}
|
|
217
|
-
className="text-xs text-
|
|
222
|
+
className="text-xs text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] transition-colors underline underline-offset-2"
|
|
218
223
|
>
|
|
219
224
|
← Back to setup
|
|
220
225
|
</button>
|
|
@@ -223,7 +228,7 @@ function PendingSurface<P extends Project>({
|
|
|
223
228
|
)}
|
|
224
229
|
</div>
|
|
225
230
|
|
|
226
|
-
<div className="shrink-0 border-t border-
|
|
231
|
+
<div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
227
232
|
<Timeline
|
|
228
233
|
project={project}
|
|
229
234
|
currentTime={currentTime}
|
|
@@ -237,7 +242,7 @@ function PendingSurface<P extends Project>({
|
|
|
237
242
|
|
|
238
243
|
{/* Right sidebar — version history (hidden when the capability is absent) */}
|
|
239
244
|
{adapter.listVersionHistory && (
|
|
240
|
-
<div className="w-48 shrink-0 border-l border-
|
|
245
|
+
<div className="w-48 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
|
|
241
246
|
<VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
|
|
242
247
|
</div>
|
|
243
248
|
)}
|
|
@@ -252,6 +257,7 @@ function ReviewSurface<P extends Project>({
|
|
|
252
257
|
adapter,
|
|
253
258
|
onProjectChange,
|
|
254
259
|
slots,
|
|
260
|
+
assetsPlacement = 'right',
|
|
255
261
|
getWaveformChunks,
|
|
256
262
|
resolveFilePath,
|
|
257
263
|
save,
|
|
@@ -273,6 +279,9 @@ function ReviewSurface<P extends Project>({
|
|
|
273
279
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
274
280
|
const primarySelectedId = selectedIds[0] ?? null
|
|
275
281
|
const [rippleMode, setRippleMode] = useState(false)
|
|
282
|
+
// Source-crop mode: when on, a VideoSourceCropOverlay is mounted over the
|
|
283
|
+
// preview for the selected tracks[0] video item. Cleared when selection changes.
|
|
284
|
+
const [cropMode, setCropMode] = useState(false)
|
|
276
285
|
const [renderOpen, setRenderOpen] = useState(false)
|
|
277
286
|
const [regenCaptionsOpen, setRegenCaptionsOpen] = useState(false)
|
|
278
287
|
// The clip/audio inspector target — derived from the timeline's inspect
|
|
@@ -281,6 +290,21 @@ function ReviewSurface<P extends Project>({
|
|
|
281
290
|
|
|
282
291
|
const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
|
|
283
292
|
|
|
293
|
+
// Measured pixel size of the preview's rendered video rect — fed to the crop
|
|
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])
|
|
307
|
+
|
|
284
308
|
// Repair caption segments whose words[] text has diverged from edited seg.text.
|
|
285
309
|
// Inline caption edits update seg.text but not seg.words; this normalizes the
|
|
286
310
|
// data so PreviewPlayer's word-level timing is correct. Runs once per project.id.
|
|
@@ -297,6 +321,18 @@ function ReviewSurface<P extends Project>({
|
|
|
297
321
|
const clips = project.tracks?.[0] ?? []
|
|
298
322
|
const hasContent = clips.length > 0 || (project.tracks?.slice(1).flat().length ?? 0) > 0 || (project.captions?.segments?.length ?? 0) > 0
|
|
299
323
|
|
|
324
|
+
// The selected tracks[0] video item, if any — the only thing source-crop mode
|
|
325
|
+
// can target. Source crop is a tracks[0]-video primitive (the renderer applies
|
|
326
|
+
// it to the original clip before compositing).
|
|
327
|
+
const cropTarget = primarySelectedId
|
|
328
|
+
? clips.find(c => c.id === primarySelectedId && c.type === 'video' && !!c.src) ?? null
|
|
329
|
+
: null
|
|
330
|
+
|
|
331
|
+
// Selecting a different item (or nothing croppable) exits crop mode.
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!cropTarget && cropMode) setCropMode(false)
|
|
334
|
+
}, [cropTarget, cropMode])
|
|
335
|
+
|
|
300
336
|
function pushHistory(prev: P) {
|
|
301
337
|
historyRef.current = [...historyRef.current.slice(-49), prev]
|
|
302
338
|
setCanUndo(true)
|
|
@@ -331,7 +367,7 @@ function ReviewSurface<P extends Project>({
|
|
|
331
367
|
setSelectedIds([])
|
|
332
368
|
}
|
|
333
369
|
|
|
334
|
-
function handleOverlayChange(id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) {
|
|
370
|
+
function handleOverlayChange(id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill'; sourceCrop?: { x: number; y: number; w: number; h: number }; sourceWidth?: number; sourceHeight?: number }) {
|
|
335
371
|
pushHistory(project)
|
|
336
372
|
const updated = {
|
|
337
373
|
...project,
|
|
@@ -397,29 +433,59 @@ function ReviewSurface<P extends Project>({
|
|
|
397
433
|
<div className="flex flex-col flex-1 overflow-hidden">
|
|
398
434
|
<div className="flex-1 flex items-center justify-center bg-black overflow-hidden p-2">
|
|
399
435
|
{hasContent ? (
|
|
400
|
-
<
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
436
|
+
<div
|
|
437
|
+
ref={previewBoxRef}
|
|
438
|
+
className="relative h-full max-w-full"
|
|
439
|
+
style={{ aspectRatio: (() => { const [w, h] = getOverlayDesignCanvas(project.settings?.resolution); return `${w} / ${h}` })() }}
|
|
440
|
+
>
|
|
441
|
+
<PreviewPlayer
|
|
442
|
+
project={project}
|
|
443
|
+
currentTime={currentTime}
|
|
444
|
+
onTimeUpdate={setCurrentTime}
|
|
445
|
+
selectedOverlayId={primarySelectedId ?? undefined}
|
|
446
|
+
onOverlayChange={handleOverlayChange}
|
|
447
|
+
compileOverlay={adapter.compileOverlay}
|
|
448
|
+
clearOverlayCache={adapter.clearOverlayCache}
|
|
449
|
+
watchFile={adapter.watchFile}
|
|
450
|
+
fileUrl={adapter.fileUrl}
|
|
451
|
+
resolveCaptionTemplate={adapter.resolveCaptionTemplate}
|
|
452
|
+
/>
|
|
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
|
+
</div>
|
|
412
478
|
) : (
|
|
413
|
-
<p className="text-
|
|
479
|
+
<p className="text-[var(--editor-text)]/60 text-sm">No clips</p>
|
|
414
480
|
)}
|
|
415
481
|
</div>
|
|
416
482
|
|
|
417
483
|
{/* Track controls bar — split + ripple + render */}
|
|
418
|
-
<div className="shrink-0 flex items-center justify-end gap-1.5 px-3 py-1 border-t border-
|
|
484
|
+
<div className="shrink-0 flex items-center justify-end gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
419
485
|
<button
|
|
420
486
|
onClick={() => handleSplit()}
|
|
421
487
|
title="Split at playhead (S) — selected item or all clips"
|
|
422
|
-
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-
|
|
488
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
|
|
423
489
|
>
|
|
424
490
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
425
491
|
<line x1="6" y1="0" x2="6" y2="12" />
|
|
@@ -434,11 +500,28 @@ function ReviewSurface<P extends Project>({
|
|
|
434
500
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
435
501
|
rippleMode
|
|
436
502
|
? 'text-teal-400 bg-teal-400/15 hover:bg-teal-400/25'
|
|
437
|
-
: 'text-
|
|
503
|
+
: 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
|
|
438
504
|
}`}
|
|
439
505
|
>
|
|
440
506
|
<Magnet size={12} />
|
|
441
507
|
</button>
|
|
508
|
+
<button
|
|
509
|
+
onClick={() => setCropMode(m => !m)}
|
|
510
|
+
disabled={!cropTarget}
|
|
511
|
+
title={
|
|
512
|
+
!cropTarget
|
|
513
|
+
? 'Select a video clip to crop its source'
|
|
514
|
+
: cropMode ? 'Exit source crop' : 'Crop source — non-destructively crop the selected clip'
|
|
515
|
+
}
|
|
516
|
+
aria-pressed={cropMode}
|
|
517
|
+
className={`flex items-center justify-center w-5 h-5 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed ${
|
|
518
|
+
cropMode
|
|
519
|
+
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
|
520
|
+
: 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
|
|
521
|
+
}`}
|
|
522
|
+
>
|
|
523
|
+
<Crop size={12} />
|
|
524
|
+
</button>
|
|
442
525
|
<button
|
|
443
526
|
onClick={() => {
|
|
444
527
|
const final = { ...project, status: 'final' } as P
|
|
@@ -446,13 +529,13 @@ function ReviewSurface<P extends Project>({
|
|
|
446
529
|
save(final)
|
|
447
530
|
setRenderOpen(true)
|
|
448
531
|
}}
|
|
449
|
-
className="text-xs px-2.5 py-1 rounded-md bg-
|
|
532
|
+
className="text-xs px-2.5 py-1 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors"
|
|
450
533
|
>
|
|
451
534
|
Render →
|
|
452
535
|
</button>
|
|
453
536
|
</div>
|
|
454
537
|
|
|
455
|
-
<div className="shrink-0 border-t border-
|
|
538
|
+
<div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
456
539
|
<Timeline
|
|
457
540
|
project={project}
|
|
458
541
|
currentTime={currentTime}
|
|
@@ -478,9 +561,17 @@ function ReviewSurface<P extends Project>({
|
|
|
478
561
|
</div>
|
|
479
562
|
</div>
|
|
480
563
|
|
|
564
|
+
{/* Assets — right sidebar column (assetsPlacement: 'right', the default /
|
|
565
|
+
Montaj-local layout). The host's panel manages its own scroll. */}
|
|
566
|
+
{assetsPlacement === 'right' && slots?.assetsPanel && (
|
|
567
|
+
<div className="w-72 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
|
|
568
|
+
{slots.assetsPanel}
|
|
569
|
+
</div>
|
|
570
|
+
)}
|
|
571
|
+
|
|
481
572
|
{/* Right rail — version history + run history slot */}
|
|
482
573
|
{(adapter.listVersionHistory || slots?.runHistory) && (
|
|
483
|
-
<div className="w-48 shrink-0 border-l border-
|
|
574
|
+
<div className="w-48 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
|
|
484
575
|
{adapter.listVersionHistory && (
|
|
485
576
|
<VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
|
|
486
577
|
)}
|
|
@@ -492,11 +583,11 @@ function ReviewSurface<P extends Project>({
|
|
|
492
583
|
)}
|
|
493
584
|
</div>
|
|
494
585
|
|
|
495
|
-
{/* Project media / assets — full-width region stacked BELOW the editor
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
{slots?.assetsPanel && (
|
|
499
|
-
<div className="shrink-0 border-t border-
|
|
586
|
+
{/* Project media / assets — full-width region stacked BELOW the editor
|
|
587
|
+
(assetsPlacement: 'bottom'). Preferred by width-constrained hosts (Hub).
|
|
588
|
+
The host's panel manages its own scroll. */}
|
|
589
|
+
{assetsPlacement === 'bottom' && slots?.assetsPanel && (
|
|
590
|
+
<div className="shrink-0 border-t border-[var(--editor-border)] w-full flex flex-col max-h-[45%] overflow-hidden">
|
|
500
591
|
{slots.assetsPanel}
|
|
501
592
|
</div>
|
|
502
593
|
)}
|
|
@@ -62,6 +62,9 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted,
|
|
|
62
62
|
return (
|
|
63
63
|
<video
|
|
64
64
|
ref={ref}
|
|
65
|
+
// Anonymous CORS so cross-origin R2 clips aren't tainted (would mute the
|
|
66
|
+
// Web Audio graph). crossOrigin must be set before src. R2 sends ACAO.
|
|
67
|
+
crossOrigin="anonymous"
|
|
65
68
|
src={src}
|
|
66
69
|
muted={muted}
|
|
67
70
|
preload="auto"
|
|
@@ -6,6 +6,7 @@ import { getOverlayDesignCanvas } from '../design-canvas'
|
|
|
6
6
|
import { useDragOverlay } from './useDragOverlay'
|
|
7
7
|
import OverlayItemsLayer from './OverlayItemsLayer'
|
|
8
8
|
import { useVideoPlayback } from './useVideoPlayback'
|
|
9
|
+
import { sourceCropVideoStyle } from './sourceCropStyle'
|
|
9
10
|
import CarouselPreview from './CarouselPreview'
|
|
10
11
|
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
@@ -42,6 +43,12 @@ export default function PreviewPlayer({
|
|
|
42
43
|
|
|
43
44
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
44
45
|
const [renderScale, setRenderScale] = useState<number>(1)
|
|
46
|
+
// Frame pixel size — used to compute the sourceCrop CSS transform that mirrors
|
|
47
|
+
// render's crop→contain. Tracked alongside renderScale from the same observer.
|
|
48
|
+
const [frameSize, setFrameSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 })
|
|
49
|
+
// Intrinsic dims of the loaded source <video>, captured on loadedmetadata.
|
|
50
|
+
// Falls back to a clip's own sourceWidth/sourceHeight when present.
|
|
51
|
+
const [videoDims, setVideoDims] = useState<{ w: number; h: number } | null>(null)
|
|
45
52
|
|
|
46
53
|
// Track container size to scale overlay components from 1080×1920 → preview size
|
|
47
54
|
useEffect(() => {
|
|
@@ -49,6 +56,7 @@ export default function PreviewPlayer({
|
|
|
49
56
|
if (!el) return
|
|
50
57
|
const obs = new ResizeObserver(([entry]) => {
|
|
51
58
|
setRenderScale(entry.contentRect.width / RENDER_W)
|
|
59
|
+
setFrameSize({ w: entry.contentRect.width, h: entry.contentRect.height })
|
|
52
60
|
})
|
|
53
61
|
obs.observe(el)
|
|
54
62
|
return () => obs.disconnect()
|
|
@@ -81,6 +89,36 @@ export default function PreviewPlayer({
|
|
|
81
89
|
|
|
82
90
|
const captionTrack = useMemo(() => project.captions, [project])
|
|
83
91
|
|
|
92
|
+
// ── sourceCrop reflection ───────────────────────────────────────────────────
|
|
93
|
+
// Mirror render's crop→contain so the preview frames the clip the way the
|
|
94
|
+
// final output will. The active clip is the one whose [start, end) contains the
|
|
95
|
+
// playhead (same selection the playback hook uses internally). Only the active
|
|
96
|
+
// <video> slot is opaque, so applying the active clip's crop to both slots is
|
|
97
|
+
// safe — the inactive slot is invisible.
|
|
98
|
+
const activeClip = useMemo(
|
|
99
|
+
() => clips.find(c => currentTime >= c.start && currentTime < c.end) ?? clips[clips.length - 1],
|
|
100
|
+
[clips, currentTime],
|
|
101
|
+
)
|
|
102
|
+
const cropStyle = useMemo(() => {
|
|
103
|
+
const crop = activeClip?.sourceCrop
|
|
104
|
+
if (!crop) return null
|
|
105
|
+
const sw = activeClip?.sourceWidth ?? videoDims?.w
|
|
106
|
+
const sh = activeClip?.sourceHeight ?? videoDims?.h
|
|
107
|
+
if (!sw || !sh || !frameSize.w || !frameSize.h) return null
|
|
108
|
+
return sourceCropVideoStyle({
|
|
109
|
+
crop,
|
|
110
|
+
sourceWidth: sw,
|
|
111
|
+
sourceHeight: sh,
|
|
112
|
+
frameWidth: frameSize.w,
|
|
113
|
+
frameHeight: frameSize.h,
|
|
114
|
+
})
|
|
115
|
+
}, [activeClip, videoDims, frameSize])
|
|
116
|
+
|
|
117
|
+
// The default full-frame style (no crop). object-contain letterboxes the source.
|
|
118
|
+
const baseVideoStyle = cropStyle
|
|
119
|
+
? { ...cropStyle }
|
|
120
|
+
: { position: 'absolute' as const, inset: 0, width: '100%', height: '100%', objectFit: 'contain' as const }
|
|
121
|
+
|
|
84
122
|
return (
|
|
85
123
|
<div ref={containerRef} className="relative bg-black h-full max-w-full overflow-hidden rounded" style={{ aspectRatio: `${RENDER_W} / ${RENDER_H}`, isolation: 'isolate' }}>
|
|
86
124
|
{isCanvasProject ? (
|
|
@@ -94,24 +132,31 @@ export default function PreviewPlayer({
|
|
|
94
132
|
{/* Slot 0 */}
|
|
95
133
|
<video
|
|
96
134
|
ref={video0Ref}
|
|
97
|
-
|
|
135
|
+
// Clips load cross-origin from R2; without this the media is CORS-tainted
|
|
136
|
+
// and the Web Audio createMediaElementSource graph outputs silence. R2
|
|
137
|
+
// sends Access-Control-Allow-Origin, so anonymous CORS keeps it audible.
|
|
138
|
+
crossOrigin="anonymous"
|
|
139
|
+
onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
|
|
98
140
|
onTimeUpdate={() => { if (activeSlotRef.current === 0) handleTimeUpdate() }}
|
|
99
141
|
onEnded={() => { if (activeSlotRef.current === 0) handleEnded() }}
|
|
100
142
|
onPlay={() => { if (activeSlotRef.current === 0) setIsPlaying(true) }}
|
|
101
143
|
onPause={() => { if (activeSlotRef.current === 0) handlePause() }}
|
|
102
144
|
playsInline
|
|
103
|
-
style={{ opacity: showVideo && activeSlot === 0 ? 1 : 0, pointerEvents: activeSlot === 0 ? 'auto' : 'none', zIndex: activeSlot === 0 ? 1 : 0 }}
|
|
145
|
+
style={{ ...baseVideoStyle, opacity: showVideo && activeSlot === 0 ? 1 : 0, pointerEvents: activeSlot === 0 ? 'auto' : 'none', zIndex: activeSlot === 0 ? 1 : 0 }}
|
|
104
146
|
/>
|
|
105
147
|
{/* Slot 1 */}
|
|
106
148
|
<video
|
|
107
149
|
ref={video1Ref}
|
|
108
|
-
|
|
150
|
+
// See slot 0: anonymous CORS so R2 cross-origin clips aren't tainted
|
|
151
|
+
// (which would mute the Web Audio graph).
|
|
152
|
+
crossOrigin="anonymous"
|
|
153
|
+
onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
|
|
109
154
|
onTimeUpdate={() => { if (activeSlotRef.current === 1) handleTimeUpdate() }}
|
|
110
155
|
onEnded={() => { if (activeSlotRef.current === 1) handleEnded() }}
|
|
111
156
|
onPlay={() => { if (activeSlotRef.current === 1) setIsPlaying(true) }}
|
|
112
157
|
onPause={() => { if (activeSlotRef.current === 1) handlePause() }}
|
|
113
158
|
playsInline
|
|
114
|
-
style={{ opacity: showVideo && activeSlot === 1 ? 1 : 0, pointerEvents: activeSlot === 1 ? 'auto' : 'none', zIndex: activeSlot === 1 ? 1 : 0 }}
|
|
159
|
+
style={{ ...baseVideoStyle, opacity: showVideo && activeSlot === 1 ? 1 : 0, pointerEvents: activeSlot === 1 ? 'auto' : 'none', zIndex: activeSlot === 1 ? 1 : 0 }}
|
|
115
160
|
/>
|
|
116
161
|
</>
|
|
117
162
|
)}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { sourceCropVideoStyle } from '../sourceCropStyle'
|
|
3
|
+
|
|
4
|
+
describe('sourceCropVideoStyle', () => {
|
|
5
|
+
it('returns null for a full-frame (default) crop', () => {
|
|
6
|
+
expect(
|
|
7
|
+
sourceCropVideoStyle({
|
|
8
|
+
crop: { x: 0, y: 0, w: 1, h: 1 },
|
|
9
|
+
sourceWidth: 1920, sourceHeight: 1080,
|
|
10
|
+
frameWidth: 1080, frameHeight: 1920,
|
|
11
|
+
}),
|
|
12
|
+
).toBeNull()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns null without source dims', () => {
|
|
16
|
+
expect(
|
|
17
|
+
sourceCropVideoStyle({
|
|
18
|
+
crop: { x: 0.25, y: 0, w: 0.5, h: 1 },
|
|
19
|
+
sourceWidth: 0, sourceHeight: 0,
|
|
20
|
+
frameWidth: 1080, frameHeight: 1920,
|
|
21
|
+
}),
|
|
22
|
+
).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('center vertical-strip crop of a landscape source fills a portrait frame', () => {
|
|
26
|
+
// 1920x1080 source, crop the centre 50% width / full height → 960x1080 region
|
|
27
|
+
// (aspect 0.888...). Portrait frame 1080x1920 (aspect 0.5625). Crop aspect >
|
|
28
|
+
// frame aspect → crop fills frame WIDTH, letterboxed vertically.
|
|
29
|
+
const style = sourceCropVideoStyle({
|
|
30
|
+
crop: { x: 0.25, y: 0, w: 0.5, h: 1 },
|
|
31
|
+
sourceWidth: 1920, sourceHeight: 1080,
|
|
32
|
+
frameWidth: 1080, frameHeight: 1920,
|
|
33
|
+
})!
|
|
34
|
+
expect(style).not.toBeNull()
|
|
35
|
+
// videoWRatio = cropWRatio(1) / crop.w(0.5) = 2 → 200% wide
|
|
36
|
+
expect(style.width).toBe('200%')
|
|
37
|
+
// cropAspect = (1920*0.5)/(1080*1) = 0.8889; frameAspect = 0.5625
|
|
38
|
+
// cropHRatio = frameAspect/cropAspect = 0.6328; videoHRatio = /h(1) = 0.6328
|
|
39
|
+
expect(parseFloat(style.height as string)).toBeCloseTo(63.28, 1)
|
|
40
|
+
// leftRatio = (1-1)/2 - 0.25*2 = -0.5 → -50%
|
|
41
|
+
expect(style.left).toBe('-50%')
|
|
42
|
+
// topRatio = (1-0.6328)/2 - 0*... = 0.1836 → ~18.36%
|
|
43
|
+
expect(parseFloat(style.top as string)).toBeCloseTo(18.36, 1)
|
|
44
|
+
expect(style.objectFit).toBe('fill')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('crop region matching frame aspect fills the frame exactly (no letterbox)', () => {
|
|
48
|
+
// Portrait source 1080x1920, crop a centred 9:16 sub-rect → same aspect as a
|
|
49
|
+
// 1080x1920 frame. Should fill edge-to-edge.
|
|
50
|
+
const style = sourceCropVideoStyle({
|
|
51
|
+
crop: { x: 0.1, y: 0.1, w: 0.8, h: 0.8 },
|
|
52
|
+
sourceWidth: 1080, sourceHeight: 1920,
|
|
53
|
+
frameWidth: 1080, frameHeight: 1920,
|
|
54
|
+
})!
|
|
55
|
+
expect(style.width).toBe('125%') // 1/0.8
|
|
56
|
+
expect(style.height).toBe('125%') // 1/0.8
|
|
57
|
+
// left/top = (1-1)/2 - 0.1*1.25 = -0.125 → -12.5%
|
|
58
|
+
expect(style.left).toBe('-12.5%')
|
|
59
|
+
expect(style.top).toBe('-12.5%')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { effectiveInPoint, effectiveOutPoint } from '../useVideoPlayback'
|
|
3
|
+
|
|
4
|
+
// These helpers mirror render's collectAllItems rebase (render.js): when the
|
|
5
|
+
// preview loads a normalizedSrc window cache (which starts at 0 and covers
|
|
6
|
+
// [inPoint, outPoint] of the original), the effective seek inPoint is 0 and the
|
|
7
|
+
// outPoint is rebased to the window length. nobg_preview_src is the full source
|
|
8
|
+
// and must NOT rebase.
|
|
9
|
+
|
|
10
|
+
describe('effectiveInPoint', () => {
|
|
11
|
+
it('rebases to 0 when normalizedSrc is the chosen src', () => {
|
|
12
|
+
expect(effectiveInPoint({ inPoint: 496.92, normalizedSrc: '/cache/window.mp4' })).toBe(0)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns clip.inPoint when there is no normalizedSrc', () => {
|
|
16
|
+
expect(effectiveInPoint({ inPoint: 496.92, src: '/orig.mov' })).toBe(496.92)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('does NOT rebase when nobg_preview_src takes precedence over normalizedSrc', () => {
|
|
20
|
+
// nobg_preview_src wins in playbackSrcFor and covers the full source.
|
|
21
|
+
expect(
|
|
22
|
+
effectiveInPoint({ inPoint: 496.92, nobg_preview_src: '/nobg.webm', normalizedSrc: '/cache/window.mp4' }),
|
|
23
|
+
).toBe(496.92)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('defaults to 0 when inPoint is absent and no cache', () => {
|
|
27
|
+
expect(effectiveInPoint({ src: '/orig.mov' })).toBe(0)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('effectiveOutPoint', () => {
|
|
32
|
+
it('rebases to the window length (outPoint - inPoint) for a normalizedSrc cache', () => {
|
|
33
|
+
// original inPoint 496.92, outPoint 514.92 → 18s window cache
|
|
34
|
+
expect(
|
|
35
|
+
effectiveOutPoint({ inPoint: 496.92, outPoint: 514.92, normalizedSrc: '/cache/window.mp4' }),
|
|
36
|
+
).toBeCloseTo(18, 5)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns the stored outPoint when there is no cache', () => {
|
|
40
|
+
expect(effectiveOutPoint({ inPoint: 496.92, outPoint: 514.92, src: '/orig.mov' })).toBe(514.92)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('does NOT rebase when nobg_preview_src takes precedence', () => {
|
|
44
|
+
expect(
|
|
45
|
+
effectiveOutPoint({ inPoint: 496.92, outPoint: 514.92, nobg_preview_src: '/nobg.webm', normalizedSrc: '/c.mp4' }),
|
|
46
|
+
).toBe(514.92)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns undefined when no outPoint is stored', () => {
|
|
50
|
+
expect(effectiveOutPoint({ inPoint: 496.92, normalizedSrc: '/cache/window.mp4' })).toBeUndefined()
|
|
51
|
+
})
|
|
52
|
+
})
|