@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.
- package/package.json +1 -1
- package/src/__tests__/video-adapter-contract.test.ts +89 -0
- package/src/carousel/CarouselEditor.tsx +28 -16
- package/src/carousel/SlideCanvas.tsx +12 -0
- package/src/carousel/SlidePropertyPanel.tsx +40 -2
- package/src/carousel/__tests__/CarouselEditor.test.tsx +52 -0
- package/src/index.ts +23 -1
- package/src/types.ts +161 -0
- package/src/video/RenderModal.tsx +252 -0
- package/src/video/VersionPanel.tsx +83 -0
- package/src/video/VideoEditor.tsx +508 -0
- package/src/video/__tests__/VideoEditor.test.tsx +213 -0
- package/src/video/__tests__/captionRepair.test.ts +134 -0
- package/src/video/__tests__/cuts.test.ts +198 -0
- package/src/video/captionRepair.ts +41 -0
- package/src/video/cuts.ts +369 -0
- package/src/video/design-canvas.ts +11 -0
- package/src/video/preview/CaptionPreview.tsx +83 -0
- package/src/video/preview/CarouselPreview.tsx +35 -0
- package/src/video/preview/OverlayItemsLayer.tsx +603 -0
- package/src/video/preview/PreviewPlayer.tsx +178 -0
- package/src/video/preview/useDragOverlay.ts +167 -0
- package/src/video/preview/useVideoPlayback.ts +761 -0
- package/src/video/timeline/AudioTrackRow.tsx +406 -0
- package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
- package/src/video/timeline/EditableSegment.tsx +30 -0
- package/src/video/timeline/Scrubber.tsx +184 -0
- package/src/video/timeline/Timeline.tsx +375 -0
- package/src/video/timeline/TimelineContext.ts +25 -0
- package/src/video/timeline/TranscriptModal.tsx +63 -0
- package/src/video/timeline/TranscriptPanel.tsx +86 -0
- package/src/video/timeline/VisualTrackRow.tsx +293 -0
- package/src/video/timeline/makeCaptionEdit.ts +32 -0
- package/src/video/timeline/multiSelectOps.ts +157 -0
- package/src/video/timeline/useItemDragDrop.ts +190 -0
- package/src/video/timeline/useTimelineZoom.ts +48 -0
- 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: "{project.name ?? project.id}". 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
|
+
}
|