@bycrux/editor 0.5.2 → 0.6.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/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/index.ts +2 -0
- package/src/schema.ts +6 -1
- package/src/types.ts +50 -0
- package/src/video/CaptionRegenModal.tsx +177 -0
- package/src/video/RenderModal.tsx +21 -21
- package/src/video/VersionPanel.tsx +11 -11
- package/src/video/VideoEditor.tsx +148 -37
- package/src/video/__tests__/CaptionRegenModal.test.tsx +65 -0
- package/src/video/preview/PreviewPlayer.tsx +42 -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/src/video/timeline/Timeline.tsx +6 -1
- package/src/video/timeline/TranscriptPanel.tsx +12 -1
|
@@ -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>
|
|
@@ -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'
|
|
@@ -8,6 +10,7 @@ import Timeline from './timeline/Timeline'
|
|
|
8
10
|
import PreviewPlayer from './preview/PreviewPlayer'
|
|
9
11
|
import VersionPanel from './VersionPanel'
|
|
10
12
|
import RenderModal from './RenderModal'
|
|
13
|
+
import CaptionRegenModal from './CaptionRegenModal'
|
|
11
14
|
|
|
12
15
|
// Generic over the host's concrete project type `P` (default = the package's
|
|
13
16
|
// own `Project`). Montaj passes its richer Project; the index signature on
|
|
@@ -37,6 +40,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
37
40
|
theme,
|
|
38
41
|
slots,
|
|
39
42
|
onBackToSetup,
|
|
43
|
+
assetsPlacement = 'right',
|
|
40
44
|
renderClipInspector,
|
|
41
45
|
renderSubcutRegen,
|
|
42
46
|
regenEnabled,
|
|
@@ -59,7 +63,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
59
63
|
|
|
60
64
|
if (isPending) {
|
|
61
65
|
return (
|
|
62
|
-
<div ref={containerRef} className="flex flex-col h-full bg-
|
|
66
|
+
<div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
|
|
63
67
|
<PendingSurface
|
|
64
68
|
project={project}
|
|
65
69
|
adapter={adapter}
|
|
@@ -81,6 +85,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
81
85
|
adapter={adapter}
|
|
82
86
|
onProjectChange={emit}
|
|
83
87
|
slots={slots}
|
|
88
|
+
assetsPlacement={assetsPlacement}
|
|
84
89
|
getWaveformChunks={getWaveformChunks}
|
|
85
90
|
resolveFilePath={resolveFilePath}
|
|
86
91
|
save={save}
|
|
@@ -113,6 +118,7 @@ interface SurfaceProps<P extends Project> {
|
|
|
113
118
|
adapter: VideoEditorProps<P>['adapter']
|
|
114
119
|
onProjectChange: (p: P) => void
|
|
115
120
|
slots?: VideoEditorProps<P>['slots']
|
|
121
|
+
assetsPlacement?: VideoEditorProps<P>['assetsPlacement']
|
|
116
122
|
getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
|
|
117
123
|
resolveFilePath: (path: string) => string
|
|
118
124
|
save: (p: P) => void
|
|
@@ -160,7 +166,7 @@ function PendingSurface<P extends Project>({
|
|
|
160
166
|
<div className="flex flex-1 overflow-hidden">
|
|
161
167
|
{/* Main */}
|
|
162
168
|
<div className="flex flex-col flex-1 overflow-hidden">
|
|
163
|
-
<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">
|
|
164
170
|
{hasTrimmedClips ? (
|
|
165
171
|
<PreviewPlayer
|
|
166
172
|
project={project}
|
|
@@ -179,12 +185,12 @@ function PendingSurface<P extends Project>({
|
|
|
179
185
|
{slots?.pendingStatus ?? (
|
|
180
186
|
<>
|
|
181
187
|
<div className="flex flex-col items-center gap-2">
|
|
182
|
-
<p className="text-
|
|
183
|
-
<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>
|
|
184
190
|
</div>
|
|
185
191
|
{skillPath && (
|
|
186
|
-
<div className="w-full rounded-xl border-2 border-
|
|
187
|
-
<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>
|
|
188
194
|
<div className="flex items-start justify-between bg-black/60 border border-transparent rounded-lg px-3 py-3 font-mono gap-3">
|
|
189
195
|
<span className="text-gray-200 text-[12px] leading-relaxed break-all">
|
|
190
196
|
There is a new project pending: "{project.name ?? project.id}". Please see @{skillPath} and start. Talk to me if you run into questions.
|
|
@@ -198,7 +204,7 @@ function PendingSurface<P extends Project>({
|
|
|
198
204
|
setTimeout(() => setCopied(false), 2000)
|
|
199
205
|
}}
|
|
200
206
|
className={`shrink-0 flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md transition-colors ${
|
|
201
|
-
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)]'
|
|
202
208
|
}`}
|
|
203
209
|
title="Copy prompt"
|
|
204
210
|
>
|
|
@@ -209,11 +215,11 @@ function PendingSurface<P extends Project>({
|
|
|
209
215
|
)}
|
|
210
216
|
</>
|
|
211
217
|
)}
|
|
212
|
-
<p className="text-
|
|
218
|
+
<p className="text-[var(--editor-text)]/40 text-xs font-mono">project id: {project.id}</p>
|
|
213
219
|
{canGoBack && (
|
|
214
220
|
<button
|
|
215
221
|
onClick={onBackToSetup}
|
|
216
|
-
className="text-xs text-
|
|
222
|
+
className="text-xs text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] transition-colors underline underline-offset-2"
|
|
217
223
|
>
|
|
218
224
|
← Back to setup
|
|
219
225
|
</button>
|
|
@@ -222,7 +228,7 @@ function PendingSurface<P extends Project>({
|
|
|
222
228
|
)}
|
|
223
229
|
</div>
|
|
224
230
|
|
|
225
|
-
<div className="shrink-0 border-t border-
|
|
231
|
+
<div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
226
232
|
<Timeline
|
|
227
233
|
project={project}
|
|
228
234
|
currentTime={currentTime}
|
|
@@ -236,7 +242,7 @@ function PendingSurface<P extends Project>({
|
|
|
236
242
|
|
|
237
243
|
{/* Right sidebar — version history (hidden when the capability is absent) */}
|
|
238
244
|
{adapter.listVersionHistory && (
|
|
239
|
-
<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">
|
|
240
246
|
<VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
|
|
241
247
|
</div>
|
|
242
248
|
)}
|
|
@@ -251,6 +257,7 @@ function ReviewSurface<P extends Project>({
|
|
|
251
257
|
adapter,
|
|
252
258
|
onProjectChange,
|
|
253
259
|
slots,
|
|
260
|
+
assetsPlacement = 'right',
|
|
254
261
|
getWaveformChunks,
|
|
255
262
|
resolveFilePath,
|
|
256
263
|
save,
|
|
@@ -272,13 +279,32 @@ function ReviewSurface<P extends Project>({
|
|
|
272
279
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
273
280
|
const primarySelectedId = selectedIds[0] ?? null
|
|
274
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)
|
|
275
285
|
const [renderOpen, setRenderOpen] = useState(false)
|
|
286
|
+
const [regenCaptionsOpen, setRegenCaptionsOpen] = useState(false)
|
|
276
287
|
// The clip/audio inspector target — derived from the timeline's inspect
|
|
277
288
|
// callbacks. A Montaj-agnostic { kind, id } selector, not a project entity.
|
|
278
289
|
const [inspecting, setInspecting] = useState<{ kind: 'clip' | 'audio'; id: string } | null>(null)
|
|
279
290
|
|
|
280
291
|
const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
|
|
281
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
|
+
|
|
282
308
|
// Repair caption segments whose words[] text has diverged from edited seg.text.
|
|
283
309
|
// Inline caption edits update seg.text but not seg.words; this normalizes the
|
|
284
310
|
// data so PreviewPlayer's word-level timing is correct. Runs once per project.id.
|
|
@@ -295,6 +321,18 @@ function ReviewSurface<P extends Project>({
|
|
|
295
321
|
const clips = project.tracks?.[0] ?? []
|
|
296
322
|
const hasContent = clips.length > 0 || (project.tracks?.slice(1).flat().length ?? 0) > 0 || (project.captions?.segments?.length ?? 0) > 0
|
|
297
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
|
+
|
|
298
336
|
function pushHistory(prev: P) {
|
|
299
337
|
historyRef.current = [...historyRef.current.slice(-49), prev]
|
|
300
338
|
setCanUndo(true)
|
|
@@ -329,7 +367,7 @@ function ReviewSurface<P extends Project>({
|
|
|
329
367
|
setSelectedIds([])
|
|
330
368
|
}
|
|
331
369
|
|
|
332
|
-
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 }) {
|
|
333
371
|
pushHistory(project)
|
|
334
372
|
const updated = {
|
|
335
373
|
...project,
|
|
@@ -395,29 +433,59 @@ function ReviewSurface<P extends Project>({
|
|
|
395
433
|
<div className="flex flex-col flex-1 overflow-hidden">
|
|
396
434
|
<div className="flex-1 flex items-center justify-center bg-black overflow-hidden p-2">
|
|
397
435
|
{hasContent ? (
|
|
398
|
-
<
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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>
|
|
410
478
|
) : (
|
|
411
|
-
<p className="text-
|
|
479
|
+
<p className="text-[var(--editor-text)]/60 text-sm">No clips</p>
|
|
412
480
|
)}
|
|
413
481
|
</div>
|
|
414
482
|
|
|
415
483
|
{/* Track controls bar — split + ripple + render */}
|
|
416
|
-
<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)]">
|
|
417
485
|
<button
|
|
418
486
|
onClick={() => handleSplit()}
|
|
419
487
|
title="Split at playhead (S) — selected item or all clips"
|
|
420
|
-
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)]"
|
|
421
489
|
>
|
|
422
490
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
423
491
|
<line x1="6" y1="0" x2="6" y2="12" />
|
|
@@ -432,11 +500,28 @@ function ReviewSurface<P extends Project>({
|
|
|
432
500
|
className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
|
|
433
501
|
rippleMode
|
|
434
502
|
? 'text-teal-400 bg-teal-400/15 hover:bg-teal-400/25'
|
|
435
|
-
: 'text-
|
|
503
|
+
: 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
|
|
436
504
|
}`}
|
|
437
505
|
>
|
|
438
506
|
<Magnet size={12} />
|
|
439
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>
|
|
440
525
|
<button
|
|
441
526
|
onClick={() => {
|
|
442
527
|
const final = { ...project, status: 'final' } as P
|
|
@@ -444,13 +529,13 @@ function ReviewSurface<P extends Project>({
|
|
|
444
529
|
save(final)
|
|
445
530
|
setRenderOpen(true)
|
|
446
531
|
}}
|
|
447
|
-
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"
|
|
448
533
|
>
|
|
449
534
|
Render →
|
|
450
535
|
</button>
|
|
451
536
|
</div>
|
|
452
537
|
|
|
453
|
-
<div className="shrink-0 border-t border-
|
|
538
|
+
<div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
454
539
|
<Timeline
|
|
455
540
|
project={project}
|
|
456
541
|
currentTime={currentTime}
|
|
@@ -471,13 +556,22 @@ function ReviewSurface<P extends Project>({
|
|
|
471
556
|
regenEnabled={regenEnabled}
|
|
472
557
|
isClipQueued={isClipQueued}
|
|
473
558
|
renderSubcutRegen={renderSubcutRegen}
|
|
559
|
+
onRegenerateCaptions={adapter.generateCaptions ? () => setRegenCaptionsOpen(true) : undefined}
|
|
474
560
|
/>
|
|
475
561
|
</div>
|
|
476
562
|
</div>
|
|
477
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
|
+
|
|
478
572
|
{/* Right rail — version history + run history slot */}
|
|
479
573
|
{(adapter.listVersionHistory || slots?.runHistory) && (
|
|
480
|
-
<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">
|
|
481
575
|
{adapter.listVersionHistory && (
|
|
482
576
|
<VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
|
|
483
577
|
)}
|
|
@@ -489,11 +583,11 @@ function ReviewSurface<P extends Project>({
|
|
|
489
583
|
)}
|
|
490
584
|
</div>
|
|
491
585
|
|
|
492
|
-
{/* Project media / assets — full-width region stacked BELOW the editor
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
{slots?.assetsPanel && (
|
|
496
|
-
<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">
|
|
497
591
|
{slots.assetsPanel}
|
|
498
592
|
</div>
|
|
499
593
|
)}
|
|
@@ -509,6 +603,23 @@ function ReviewSurface<P extends Project>({
|
|
|
509
603
|
/>
|
|
510
604
|
)}
|
|
511
605
|
|
|
606
|
+
{/* Caption regen modal — adapter.generateCaptions stream. On done we patch
|
|
607
|
+
project.captions via onProjectChange only. We deliberately do NOT call
|
|
608
|
+
save(): montaj persists the regenerated captions server-side and the
|
|
609
|
+
SSE subscribe frame reconciles, so a saveProject here would double-write. */}
|
|
610
|
+
{regenCaptionsOpen && adapter.generateCaptions && (
|
|
611
|
+
<CaptionRegenModal
|
|
612
|
+
adapter={adapter}
|
|
613
|
+
projectId={project.id}
|
|
614
|
+
onClose={() => setRegenCaptionsOpen(false)}
|
|
615
|
+
onDone={(captions) => {
|
|
616
|
+
const next = { ...project, captions } as P
|
|
617
|
+
onProjectChange(next)
|
|
618
|
+
setRegenCaptionsOpen(false)
|
|
619
|
+
}}
|
|
620
|
+
/>
|
|
621
|
+
)}
|
|
622
|
+
|
|
512
623
|
{/* Clip / audio inspector — host-rendered via render-prop seam. */}
|
|
513
624
|
{inspecting && renderClipInspector?.({
|
|
514
625
|
item: inspecting,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, waitFor, cleanup } from '@testing-library/react'
|
|
3
|
+
import type { CaptionEvent, EditorAdapter, ImageElement, Project } from '../../types'
|
|
4
|
+
import type { Captions } from '../../schema'
|
|
5
|
+
import CaptionRegenModal from '../CaptionRegenModal'
|
|
6
|
+
|
|
7
|
+
afterEach(() => cleanup())
|
|
8
|
+
|
|
9
|
+
const doneCaptions: Captions = {
|
|
10
|
+
style: 'pop',
|
|
11
|
+
segments: [{ text: 'hola', start: 0, end: 1, words: [] }],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeAdapter(): EditorAdapter<Project> {
|
|
15
|
+
return {
|
|
16
|
+
loadProject: vi.fn(),
|
|
17
|
+
saveProject: vi.fn(),
|
|
18
|
+
subscribe: () => () => {},
|
|
19
|
+
render: async function* () {},
|
|
20
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
21
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
22
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
23
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
24
|
+
uploadFile: vi.fn(async () => ''),
|
|
25
|
+
fileUrl: (p: string) => p,
|
|
26
|
+
generateCaptions: async function* (): AsyncIterable<CaptionEvent> {
|
|
27
|
+
yield { type: 'log', message: 'transcribing audio…' }
|
|
28
|
+
yield { type: 'done', captions: doneCaptions }
|
|
29
|
+
},
|
|
30
|
+
} as unknown as EditorAdapter<Project>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('CaptionRegenModal', () => {
|
|
34
|
+
it('streams a log line and calls onDone with the final captions', async () => {
|
|
35
|
+
const onDone = vi.fn()
|
|
36
|
+
const onClose = vi.fn()
|
|
37
|
+
render(
|
|
38
|
+
<CaptionRegenModal
|
|
39
|
+
adapter={makeAdapter()}
|
|
40
|
+
projectId="vid-1"
|
|
41
|
+
onDone={onDone}
|
|
42
|
+
onClose={onClose}
|
|
43
|
+
/>,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
await waitFor(() => expect(screen.getByText(/transcribing audio/i)).toBeTruthy())
|
|
47
|
+
await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('shows an error message verbatim on error', async () => {
|
|
51
|
+
const errorAdapter = makeAdapter()
|
|
52
|
+
errorAdapter.generateCaptions = async function* (): AsyncIterable<CaptionEvent> {
|
|
53
|
+
yield { type: 'error', message: 'multi_source' }
|
|
54
|
+
}
|
|
55
|
+
render(
|
|
56
|
+
<CaptionRegenModal
|
|
57
|
+
adapter={errorAdapter}
|
|
58
|
+
projectId="vid-1"
|
|
59
|
+
onDone={vi.fn()}
|
|
60
|
+
onClose={vi.fn()}
|
|
61
|
+
/>,
|
|
62
|
+
)
|
|
63
|
+
await waitFor(() => expect(screen.getByText('multi_source')).toBeTruthy())
|
|
64
|
+
})
|
|
65
|
+
})
|