@devbycrux/editor 0.1.0 → 0.3.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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/video-adapter-contract.test.ts +89 -0
  3. package/src/carousel/CarouselEditor.tsx +28 -16
  4. package/src/carousel/SlideCanvas.tsx +12 -0
  5. package/src/carousel/SlidePropertyPanel.tsx +40 -2
  6. package/src/carousel/__tests__/CarouselEditor.test.tsx +52 -0
  7. package/src/index.ts +23 -1
  8. package/src/types.ts +161 -0
  9. package/src/video/RenderModal.tsx +252 -0
  10. package/src/video/VersionPanel.tsx +83 -0
  11. package/src/video/VideoEditor.tsx +508 -0
  12. package/src/video/__tests__/VideoEditor.test.tsx +213 -0
  13. package/src/video/__tests__/captionRepair.test.ts +134 -0
  14. package/src/video/__tests__/cuts.test.ts +198 -0
  15. package/src/video/captionRepair.ts +41 -0
  16. package/src/video/cuts.ts +369 -0
  17. package/src/video/design-canvas.ts +11 -0
  18. package/src/video/preview/CaptionPreview.tsx +83 -0
  19. package/src/video/preview/CarouselPreview.tsx +35 -0
  20. package/src/video/preview/OverlayItemsLayer.tsx +603 -0
  21. package/src/video/preview/PreviewPlayer.tsx +178 -0
  22. package/src/video/preview/useDragOverlay.ts +167 -0
  23. package/src/video/preview/useVideoPlayback.ts +761 -0
  24. package/src/video/timeline/AudioTrackRow.tsx +406 -0
  25. package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
  26. package/src/video/timeline/EditableSegment.tsx +30 -0
  27. package/src/video/timeline/Scrubber.tsx +184 -0
  28. package/src/video/timeline/Timeline.tsx +375 -0
  29. package/src/video/timeline/TimelineContext.ts +25 -0
  30. package/src/video/timeline/TranscriptModal.tsx +63 -0
  31. package/src/video/timeline/TranscriptPanel.tsx +86 -0
  32. package/src/video/timeline/VisualTrackRow.tsx +293 -0
  33. package/src/video/timeline/makeCaptionEdit.ts +32 -0
  34. package/src/video/timeline/multiSelectOps.ts +157 -0
  35. package/src/video/timeline/useItemDragDrop.ts +190 -0
  36. package/src/video/timeline/useTimelineZoom.ts +48 -0
  37. package/src/video/timeline/utils.ts +17 -0
@@ -0,0 +1,508 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { Magnet } from 'lucide-react'
3
+ import type { Project, VideoEditorProps } from '../types'
4
+ import { applyTheme, defaultMontajTheme } from '../theme'
5
+ import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './cuts'
6
+ import { repairCaptionWords } from './captionRepair'
7
+ import Timeline from './timeline/Timeline'
8
+ import PreviewPlayer from './preview/PreviewPlayer'
9
+ import VersionPanel from './VersionPanel'
10
+ import RenderModal from './RenderModal'
11
+
12
+ // Generic over the host's concrete project type `P` (default = the package's
13
+ // own `Project`). Montaj passes its richer Project; the index signature on
14
+ // EditorProject absorbs host-only pipeline fields so a full host Project
15
+ // round-trips through edit→save (and `onProjectChange`) without casts.
16
+ type Props<P extends Project = Project> = VideoEditorProps<P>
17
+
18
+ /**
19
+ * `<VideoEditor>` — the assembled, host-agnostic video editor.
20
+ *
21
+ * Absorbs Montaj's former LiveView (pending/processing surface) and ReviewView
22
+ * (draft/final surface) into one component driven by the `EditorAdapter`.
23
+ * Controlled like `<CarouselEditor>`: the host owns `project` and is notified of
24
+ * edits via `onProjectChange`; persistence flows through `adapter.saveProject`.
25
+ * It does NOT own a `useProjectState` reducer — it preserves the original
26
+ * Live/Review save model exactly (mutate → onProjectChange → adapter.saveProject
27
+ * fire-and-forget), so the host's pipeline fields survive untouched.
28
+ *
29
+ * ProjectHeader is lifted out (the host renders it in its shell). This component
30
+ * renders: timeline + preview + version panel + render modal + the host-supplied
31
+ * inspector/subcut render-prop seams + an optional back-to-setup affordance.
32
+ */
33
+ export default function VideoEditor<P extends Project = Project>({
34
+ project,
35
+ adapter,
36
+ onProjectChange,
37
+ theme,
38
+ slots,
39
+ onBackToSetup,
40
+ renderClipInspector,
41
+ renderSubcutRegen,
42
+ regenEnabled,
43
+ isClipQueued,
44
+ }: Props<P>) {
45
+ const emit = onProjectChange ?? (() => {})
46
+
47
+ // ── Theme: apply tokens onto the editor container. ──
48
+ const containerRef = useRef<HTMLDivElement>(null)
49
+ useEffect(() => {
50
+ if (containerRef.current) applyTheme(containerRef.current, theme ?? defaultMontajTheme)
51
+ }, [theme])
52
+
53
+ const isPending = project.status === 'pending'
54
+
55
+ // ── Shared injected adapter fns, threaded to Timeline + PreviewPlayer. ──
56
+ const getWaveformChunks = adapter.getWaveformChunks
57
+ const resolveFilePath = adapter.fileUrl
58
+ const save = (p: P) => { void adapter.saveProject(p.id, p) }
59
+
60
+ if (isPending) {
61
+ return (
62
+ <div ref={containerRef} className="flex flex-col h-full bg-white dark:bg-gray-950">
63
+ <PendingSurface
64
+ project={project}
65
+ adapter={adapter}
66
+ onProjectChange={emit}
67
+ slots={slots}
68
+ onBackToSetup={onBackToSetup}
69
+ getWaveformChunks={getWaveformChunks}
70
+ resolveFilePath={resolveFilePath}
71
+ save={save}
72
+ />
73
+ </div>
74
+ )
75
+ }
76
+
77
+ return (
78
+ <div ref={containerRef} className="flex flex-col h-full">
79
+ <ReviewSurface
80
+ project={project}
81
+ adapter={adapter}
82
+ onProjectChange={emit}
83
+ slots={slots}
84
+ getWaveformChunks={getWaveformChunks}
85
+ resolveFilePath={resolveFilePath}
86
+ save={save}
87
+ renderClipInspector={renderClipInspector}
88
+ renderSubcutRegen={renderSubcutRegen}
89
+ regenEnabled={regenEnabled}
90
+ isClipQueued={isClipQueued}
91
+ />
92
+ </div>
93
+ )
94
+ }
95
+
96
+ // ── Version-history hook (shared by both surfaces) ───────────────────────────
97
+
98
+ function useVersionHistory<P extends Project>(adapter: VideoEditorProps<P>['adapter'], project: P) {
99
+ const [versions, setVersions] = useState<{ hash: string; message: string; timestamp: string }[]>([])
100
+ const [restoring, setRestoring] = useState<string | null>(null)
101
+
102
+ useEffect(() => {
103
+ adapter.listVersionHistory?.(project.id).then(setVersions).catch(() => {})
104
+ }, [adapter, project.id, project.status])
105
+
106
+ return { versions, restoring, setRestoring }
107
+ }
108
+
109
+ // ── Pending / processing surface (former LiveView) ───────────────────────────
110
+
111
+ interface SurfaceProps<P extends Project> {
112
+ project: P
113
+ adapter: VideoEditorProps<P>['adapter']
114
+ onProjectChange: (p: P) => void
115
+ slots?: VideoEditorProps<P>['slots']
116
+ getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
117
+ resolveFilePath: (path: string) => string
118
+ save: (p: P) => void
119
+ }
120
+
121
+ function PendingSurface<P extends Project>({
122
+ project,
123
+ adapter,
124
+ onProjectChange,
125
+ slots,
126
+ onBackToSetup,
127
+ getWaveformChunks,
128
+ resolveFilePath,
129
+ }: SurfaceProps<P> & { onBackToSetup?: () => void }) {
130
+ const [currentTime, setCurrentTime] = useState(0)
131
+ const [skillPath, setSkillPath] = useState<string | null>(null)
132
+ const [copied, setCopied] = useState(false)
133
+ const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
134
+
135
+ useEffect(() => {
136
+ adapter.getInfo?.().then(info => setSkillPath(info.root_skill_path ?? null)).catch(() => {})
137
+ }, [adapter])
138
+
139
+ const clips = project.tracks?.[0] ?? []
140
+ const hasTrimmedClips = clips.some(c => c.inPoint !== undefined && c.outPoint !== undefined)
141
+ // The back-to-setup affordance is gated on the host supplying it AND the
142
+ // project being safe to discard (no manual trims yet). Mirrors LiveView's
143
+ // canGoBack rule.
144
+ const canGoBack = !hasTrimmedClips && !!onBackToSetup
145
+
146
+ async function handleRestoreVersion(hash: string) {
147
+ if (!adapter.restoreVersion) return
148
+ setRestoring(hash)
149
+ try {
150
+ const restored = await adapter.restoreVersion(project.id, hash)
151
+ onProjectChange(restored)
152
+ } catch (e) {
153
+ console.error(e)
154
+ } finally {
155
+ setRestoring(null)
156
+ }
157
+ }
158
+
159
+ return (
160
+ <div className="flex flex-1 overflow-hidden">
161
+ {/* Main */}
162
+ <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">
164
+ {hasTrimmedClips ? (
165
+ <PreviewPlayer
166
+ project={project}
167
+ currentTime={currentTime}
168
+ onTimeUpdate={setCurrentTime}
169
+ compileOverlay={adapter.compileOverlay}
170
+ clearOverlayCache={adapter.clearOverlayCache}
171
+ watchFile={adapter.watchFile}
172
+ fileUrl={adapter.fileUrl}
173
+ resolveCaptionTemplate={adapter.resolveCaptionTemplate}
174
+ />
175
+ ) : (
176
+ <div className="flex flex-col items-center gap-6 text-center max-w-lg w-full">
177
+ {/* Host feeds live agent progress through the pendingStatus slot;
178
+ absent → skill-path card (if info available) or a minimal default. */}
179
+ {slots?.pendingStatus ?? (
180
+ <>
181
+ <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>
184
+ </div>
185
+ {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>
188
+ <div className="flex items-start justify-between bg-black/60 border border-transparent rounded-lg px-3 py-3 font-mono gap-3">
189
+ <span className="text-gray-200 text-[12px] leading-relaxed break-all">
190
+ 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.
191
+ </span>
192
+ <button
193
+ onClick={() => {
194
+ navigator.clipboard.writeText(
195
+ `There is a new project pending: "${project.name ?? project.id}". Please see @${skillPath} and start. Talk to me if you run into questions.`
196
+ )
197
+ setCopied(true)
198
+ setTimeout(() => setCopied(false), 2000)
199
+ }}
200
+ 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'
202
+ }`}
203
+ title="Copy prompt"
204
+ >
205
+ {copied ? '✓ Copied' : 'Copy'}
206
+ </button>
207
+ </div>
208
+ </div>
209
+ )}
210
+ </>
211
+ )}
212
+ <p className="text-gray-600 text-xs font-mono">project id: {project.id}</p>
213
+ {canGoBack && (
214
+ <button
215
+ onClick={onBackToSetup}
216
+ className="text-xs text-gray-600 hover:text-gray-400 transition-colors underline underline-offset-2"
217
+ >
218
+ ← Back to setup
219
+ </button>
220
+ )}
221
+ </div>
222
+ )}
223
+ </div>
224
+
225
+ <div className="shrink-0 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950">
226
+ <Timeline
227
+ project={project}
228
+ currentTime={currentTime}
229
+ onTimeUpdate={setCurrentTime}
230
+ getWaveformChunks={getWaveformChunks}
231
+ resolveFilePath={resolveFilePath}
232
+ onSaveProject={(p) => adapter.saveProject(p.id, p as P)}
233
+ />
234
+ </div>
235
+ </div>
236
+
237
+ {/* Right sidebar — version history (hidden when the capability is absent) */}
238
+ {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">
240
+ <VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
241
+ </div>
242
+ )}
243
+ </div>
244
+ )
245
+ }
246
+
247
+ // ── Draft / final surface (former ReviewView) ────────────────────────────────
248
+
249
+ function ReviewSurface<P extends Project>({
250
+ project,
251
+ adapter,
252
+ onProjectChange,
253
+ slots,
254
+ getWaveformChunks,
255
+ resolveFilePath,
256
+ save,
257
+ renderClipInspector,
258
+ renderSubcutRegen,
259
+ regenEnabled,
260
+ isClipQueued,
261
+ }: SurfaceProps<P> & {
262
+ renderClipInspector?: VideoEditorProps<P>['renderClipInspector']
263
+ renderSubcutRegen?: VideoEditorProps<P>['renderSubcutRegen']
264
+ regenEnabled?: boolean
265
+ isClipQueued?: (itemId: string) => boolean
266
+ }) {
267
+ const [currentTime, setCurrentTime] = useState(0)
268
+ const [canUndo, setCanUndo] = useState(false)
269
+ const historyRef = useRef<P[]>([])
270
+ // Multi-select: all currently-selected timeline item ids. Single-select
271
+ // consumers (canvas preview, cut/split) use selectedIds[0] as the primary.
272
+ const [selectedIds, setSelectedIds] = useState<string[]>([])
273
+ const primarySelectedId = selectedIds[0] ?? null
274
+ const [rippleMode, setRippleMode] = useState(false)
275
+ const [renderOpen, setRenderOpen] = useState(false)
276
+ // The clip/audio inspector target — derived from the timeline's inspect
277
+ // callbacks. A Montaj-agnostic { kind, id } selector, not a project entity.
278
+ const [inspecting, setInspecting] = useState<{ kind: 'clip' | 'audio'; id: string } | null>(null)
279
+
280
+ const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
281
+
282
+ // Repair caption segments whose words[] text has diverged from edited seg.text.
283
+ // Inline caption edits update seg.text but not seg.words; this normalizes the
284
+ // data so PreviewPlayer's word-level timing is correct. Runs once per project.id.
285
+ useEffect(() => {
286
+ const captions = project.captions
287
+ if (!captions?.segments?.length) return
288
+ const repaired = repairCaptionWords(captions)
289
+ if (!repaired) return
290
+ const next = { ...project, captions: repaired } as P
291
+ onProjectChange(next)
292
+ void adapter.saveProject(next.id, next)
293
+ }, [project.id]) // intentionally keyed on project.id only — runs once per project load
294
+
295
+ const clips = project.tracks?.[0] ?? []
296
+ const hasContent = clips.length > 0 || (project.tracks?.slice(1).flat().length ?? 0) > 0 || (project.captions?.segments?.length ?? 0) > 0
297
+
298
+ function pushHistory(prev: P) {
299
+ historyRef.current = [...historyRef.current.slice(-49), prev]
300
+ setCanUndo(true)
301
+ }
302
+
303
+ // Edits coming from the timeline (drag/move/track changes): snapshot for undo,
304
+ // notify host, persist.
305
+ function handleProjectChange(p: Project) {
306
+ pushHistory(project)
307
+ onProjectChange(p as P)
308
+ save(p as P)
309
+ }
310
+
311
+ function handleUndo() {
312
+ const hist = historyRef.current
313
+ if (!hist.length) return
314
+ const prev = hist[hist.length - 1]
315
+ historyRef.current = hist.slice(0, -1)
316
+ setCanUndo(hist.length > 1)
317
+ onProjectChange(prev)
318
+ save(prev)
319
+ }
320
+
321
+ function handleCut(cut: { start: number; end: number }) {
322
+ pushHistory(project)
323
+ let updated = primarySelectedId
324
+ ? applyCutToItem(project, primarySelectedId, cut)
325
+ : applyCutToTracks(project, cut)
326
+ if (rippleMode) updated = collapseGaps(updated)
327
+ onProjectChange(updated as P)
328
+ save(updated as P)
329
+ setSelectedIds([])
330
+ }
331
+
332
+ function handleOverlayChange(id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) {
333
+ pushHistory(project)
334
+ const updated = {
335
+ ...project,
336
+ tracks: (project.tracks ?? []).map(track =>
337
+ track.map(item => item.id !== id ? item : { ...item, ...changes })
338
+ ),
339
+ } as P
340
+ onProjectChange(updated)
341
+ save(updated)
342
+ }
343
+
344
+ function handleSplit(at?: number) {
345
+ const updated = splitAtTime(project, at ?? currentTime, primarySelectedId ?? null)
346
+ if (updated === project) return
347
+ pushHistory(project)
348
+ onProjectChange(updated as P)
349
+ save(updated as P)
350
+ }
351
+
352
+ function handleRippleToggle() {
353
+ const next = !rippleMode
354
+ setRippleMode(next)
355
+ if (next) {
356
+ const collapsed = collapseGaps(project)
357
+ if (collapsed !== project) {
358
+ pushHistory(project)
359
+ onProjectChange(collapsed as P)
360
+ save(collapsed as P)
361
+ }
362
+ }
363
+ }
364
+
365
+ // Keyboard: split (S) and undo (cmd/ctrl-Z). Guarded against text inputs.
366
+ useEffect(() => {
367
+ const onKey = (e: KeyboardEvent) => {
368
+ const el = e.target as HTMLElement
369
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable) return
370
+ if (e.key === 's' || e.key === 'S') { e.preventDefault(); handleSplit() }
371
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z') { e.preventDefault(); handleUndo() }
372
+ }
373
+ document.addEventListener('keydown', onKey)
374
+ return () => document.removeEventListener('keydown', onKey)
375
+ }, [project, currentTime, primarySelectedId, canUndo])
376
+
377
+ async function handleRestoreVersion(hash: string) {
378
+ if (!adapter.restoreVersion) return
379
+ setRestoring(hash)
380
+ try {
381
+ const restored = await adapter.restoreVersion(project.id, hash)
382
+ onProjectChange(restored)
383
+ } catch (e) {
384
+ console.error(e)
385
+ } finally {
386
+ setRestoring(null)
387
+ }
388
+ }
389
+
390
+ return (
391
+ <div className="flex flex-1 overflow-hidden">
392
+ {/* Main: preview + timeline */}
393
+ <div className="flex flex-col flex-1 overflow-hidden">
394
+ <div className="flex-1 flex items-center justify-center bg-black overflow-hidden p-2">
395
+ {hasContent ? (
396
+ <PreviewPlayer
397
+ project={project}
398
+ currentTime={currentTime}
399
+ onTimeUpdate={setCurrentTime}
400
+ selectedOverlayId={primarySelectedId ?? undefined}
401
+ onOverlayChange={handleOverlayChange}
402
+ compileOverlay={adapter.compileOverlay}
403
+ clearOverlayCache={adapter.clearOverlayCache}
404
+ watchFile={adapter.watchFile}
405
+ fileUrl={adapter.fileUrl}
406
+ resolveCaptionTemplate={adapter.resolveCaptionTemplate}
407
+ />
408
+ ) : (
409
+ <p className="text-gray-600 text-sm">No clips</p>
410
+ )}
411
+ </div>
412
+
413
+ {/* Track controls bar — split + ripple + render */}
414
+ <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">
415
+ <button
416
+ onClick={() => handleSplit()}
417
+ title="Split at playhead (S) — selected item or all clips"
418
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-gray-500 bg-transparent hover:text-gray-400"
419
+ >
420
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
421
+ <line x1="6" y1="0" x2="6" y2="12" />
422
+ <polyline points="3,3 6,6 9,3" />
423
+ <polyline points="3,9 6,6 9,9" />
424
+ </svg>
425
+ </button>
426
+ <button
427
+ onClick={handleRippleToggle}
428
+ title={rippleMode ? 'Ripple mode on — edits close the gap' : 'Ripple mode off — edits leave a gap'}
429
+ aria-pressed={rippleMode}
430
+ className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
431
+ rippleMode
432
+ ? 'text-teal-400 bg-teal-400/15 hover:bg-teal-400/25'
433
+ : 'text-gray-500 bg-transparent hover:text-gray-400'
434
+ }`}
435
+ >
436
+ <Magnet size={12} />
437
+ </button>
438
+ <button
439
+ onClick={() => {
440
+ const final = { ...project, status: 'final' } as P
441
+ onProjectChange(final)
442
+ save(final)
443
+ setRenderOpen(true)
444
+ }}
445
+ className="text-xs px-2.5 py-1 rounded-md bg-blue-600 text-white hover:bg-blue-500 transition-colors"
446
+ >
447
+ Render →
448
+ </button>
449
+ </div>
450
+
451
+ <div className="shrink-0 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950">
452
+ <Timeline
453
+ project={project}
454
+ currentTime={currentTime}
455
+ onTimeUpdate={setCurrentTime}
456
+ onProjectChange={handleProjectChange}
457
+ onCaptionEdit={(p) => { onProjectChange(p as P); save(p as P) }}
458
+ onOverlayEdit={(p) => { onProjectChange(p as P); save(p as P) }}
459
+ selectedIds={selectedIds}
460
+ onSelectIds={setSelectedIds}
461
+ onSplit={handleSplit}
462
+ onCut={handleCut}
463
+ onInspectClip={(id) => setInspecting({ kind: 'clip', id })}
464
+ onInspectAudio={(id) => setInspecting({ kind: 'audio', id })}
465
+ onSaveProject={(p) => adapter.saveProject(p.id, p as P)}
466
+ rippleMode={rippleMode}
467
+ getWaveformChunks={getWaveformChunks}
468
+ resolveFilePath={resolveFilePath}
469
+ regenEnabled={regenEnabled}
470
+ isClipQueued={isClipQueued}
471
+ renderSubcutRegen={renderSubcutRegen}
472
+ />
473
+ </div>
474
+ </div>
475
+
476
+ {/* Right sidebar — version history + run history slot + host-supplied assets panel */}
477
+ {(adapter.listVersionHistory || slots?.assetsPanel || slots?.runHistory) && (
478
+ <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">
479
+ {adapter.listVersionHistory && (
480
+ <VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
481
+ )}
482
+ {/* Host injects the Montaj-flavored "Previous runs" snapshot list here.
483
+ RunSnapshot / project.history are host-only types — the package never
484
+ reads them. When absent nothing is rendered. */}
485
+ {slots?.runHistory}
486
+ {slots?.assetsPanel}
487
+ </div>
488
+ )}
489
+
490
+ {/* Render modal — adapter.render stream + host export controls */}
491
+ {renderOpen && (
492
+ <RenderModal
493
+ projectId={project.id}
494
+ adapter={adapter}
495
+ exportActions={slots?.exportActions}
496
+ onClose={() => setRenderOpen(false)}
497
+ onCancel={() => setRenderOpen(false)}
498
+ />
499
+ )}
500
+
501
+ {/* Clip / audio inspector — host-rendered via render-prop seam. */}
502
+ {inspecting && renderClipInspector?.({
503
+ item: inspecting,
504
+ onClose: () => setInspecting(null),
505
+ })}
506
+ </div>
507
+ )
508
+ }