@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.
@@ -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-gray-400'
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-gray-500'
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-gray-600">[render] </span>
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-gray-950 border border-gray-800 rounded-2xl shadow-2xl flex overflow-hidden">
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-gray-800">
151
- <div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
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-white">Render complete</p>
156
- <p className="text-xs text-gray-400">Your video is ready.</p>
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-gray-500 hover:text-white transition-colors text-lg leading-none">×</button>
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-gray-500 break-all leading-relaxed">{outputPath}</p>
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-gray-800 border border-gray-700 text-gray-300 hover:bg-gray-700 transition-colors"
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-gray-900 border border-gray-700 rounded-xl shadow-2xl flex flex-col overflow-hidden">
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-gray-800">
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-white">
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-gray-500 hover:text-white transition-colors text-lg leading-none">×</button>
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-gray-800 border border-gray-700 text-gray-400 hover:text-white hover:border-gray-500 transition-colors"
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-gray-300 bg-gray-950 flex flex-col gap-0.5"
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-gray-600 italic">Starting render engine…</span>
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-gray-800">
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-gray-800 border border-gray-700 text-gray-300 hover:bg-red-900/40 hover:border-red-700 hover:text-red-300 transition-colors"
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-gray-800 border border-gray-700 text-white hover:bg-gray-700 transition-colors"
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-gray-200 dark:border-gray-800 flex flex-col overflow-hidden" style={{ maxHeight: open ? 224 : 0, transition: 'max-height 0.15s ease' }}>
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-gray-200 dark:border-gray-800 hover:bg-gray-100 dark:hover:bg-gray-900 transition-colors w-full text-left"
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-gray-400 uppercase tracking-wide">Versions</span>
51
- <span className="text-gray-600 text-[10px]">{open ? '▲' : '▼'}</span>
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-gray-600 text-center mt-2 px-1 leading-relaxed">No saved versions yet.</p>
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-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-2 flex flex-col gap-1">
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-gray-500 dark:text-gray-600 shrink-0">Run {run}</span>
62
+ <span className="text-[10px] text-[var(--editor-text)]/50 shrink-0">Run {run}</span>
63
63
  {isDefault ? (
64
- <span className="text-[10px] text-gray-500 capitalize">{label}</span>
64
+ <span className="text-[10px] text-[var(--editor-text)]/60 capitalize">{label}</span>
65
65
  ) : (
66
- <span className="text-xs font-medium text-gray-700 dark:text-gray-200 truncate capitalize" title={label}>{label}</span>
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-gray-600">{formatTime(v.timestamp)}</span>
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-blue-500 hover:text-blue-400 text-left transition-colors disabled:opacity-40"
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-white dark:bg-gray-950">
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-gray-950 overflow-hidden p-4">
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-white text-lg font-semibold">Message your agent to start</p>
183
- <p className="text-gray-400 text-sm">Nothing will happen automatically. Copy this and send it to your agent.</p>
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-blue-400/50 bg-gray-900 p-5 flex flex-col gap-3 text-left shadow-lg shadow-blue-400/10">
187
- <p className="text-blue-400 text-xs font-bold uppercase tracking-widest">Send this to your agent</p>
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: &quot;{project.name ?? project.id}&quot;. 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-white/10 text-gray-300 hover:bg-white/20 hover:text-white'
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-gray-600 text-xs font-mono">project id: {project.id}</p>
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-gray-600 hover:text-gray-400 transition-colors underline underline-offset-2"
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-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950">
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-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-950 flex flex-col overflow-hidden">
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
- <PreviewPlayer
399
- project={project}
400
- currentTime={currentTime}
401
- onTimeUpdate={setCurrentTime}
402
- selectedOverlayId={primarySelectedId ?? undefined}
403
- onOverlayChange={handleOverlayChange}
404
- compileOverlay={adapter.compileOverlay}
405
- clearOverlayCache={adapter.clearOverlayCache}
406
- watchFile={adapter.watchFile}
407
- fileUrl={adapter.fileUrl}
408
- resolveCaptionTemplate={adapter.resolveCaptionTemplate}
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-gray-600 text-sm">No clips</p>
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-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950">
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-gray-500 bg-transparent hover:text-gray-400"
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-gray-500 bg-transparent hover:text-gray-400'
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-blue-600 text-white hover:bg-blue-500 transition-colors"
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-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950">
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-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-950 flex flex-col overflow-hidden">
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
- mirroring CarouselEditor's layout (was previously crammed into the
494
- narrow right rail). The host's panel manages its own scroll. */}
495
- {slots?.assetsPanel && (
496
- <div className="shrink-0 border-t border-gray-200 dark:border-gray-800 w-full flex flex-col max-h-[45%] overflow-hidden">
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
+ })