@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,252 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import type { EditorAdapter, Project } from '../types'
|
|
3
|
+
|
|
4
|
+
interface RenderModalProps<P extends Project = Project> {
|
|
5
|
+
projectId: string
|
|
6
|
+
/** Adapter driving the render stream + file-URL resolution. */
|
|
7
|
+
adapter: EditorAdapter<P>
|
|
8
|
+
/** Fired when the modal closes from a finished or errored state (post-render).
|
|
9
|
+
* Callers can use this to navigate away or refresh project state. */
|
|
10
|
+
onClose: () => void
|
|
11
|
+
/** Fired when the user cancels an in-progress render via the Cancel button.
|
|
12
|
+
* Distinct from onClose so callers can dismiss the modal without navigating
|
|
13
|
+
* away from the editor — the project is unchanged and the user is likely
|
|
14
|
+
* about to keep editing. Defaults to onClose if not provided (back-compat). */
|
|
15
|
+
onCancel?: () => void
|
|
16
|
+
/** Host-supplied export controls (e.g. a "Download all (.zip)" link) rendered
|
|
17
|
+
* in the done state's action area, mirroring the carousel render modal. */
|
|
18
|
+
exportActions?: ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function basename(p: string) { return p.split('/').pop() ?? p }
|
|
22
|
+
|
|
23
|
+
function LogLine({ text }: { text: string }) {
|
|
24
|
+
const t = text.replace(/^\[montaj render\]\s*/, '')
|
|
25
|
+
let color = 'text-gray-400'
|
|
26
|
+
if (/ready|complete|done|encoded|assembled/i.test(t)) color = 'text-green-400'
|
|
27
|
+
else if (/rendering|bundling|launching|browsers/i.test(t)) color = 'text-sky-400'
|
|
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'
|
|
30
|
+
else if (/error|fail|warn/i.test(t)) color = 'text-red-400'
|
|
31
|
+
|
|
32
|
+
const prefix = text.startsWith('[montaj render]')
|
|
33
|
+
? <span className="text-gray-600">[render] </span>
|
|
34
|
+
: null
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<span className={`leading-relaxed whitespace-pre-wrap break-all ${color}`}>
|
|
38
|
+
{prefix}{t}
|
|
39
|
+
</span>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function RenderModal<P extends Project = Project>({ projectId, adapter, onClose, onCancel, exportActions }: RenderModalProps<P>) {
|
|
44
|
+
const [logs, setLogs] = useState<string[]>([])
|
|
45
|
+
const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
|
|
46
|
+
const [outputPath, setOutput] = useState<string | null>(null)
|
|
47
|
+
const [errorMsg, setError] = useState<string | null>(null)
|
|
48
|
+
const logRef = useRef<HTMLDivElement>(null)
|
|
49
|
+
const cancelledRef = useRef(false)
|
|
50
|
+
const unmountedRef = useRef(false)
|
|
51
|
+
const cleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
// React StrictMode in dev fires mount → cleanup → mount synchronously to
|
|
55
|
+
// catch effects that aren't idempotent. Triggering a render is the textbook
|
|
56
|
+
// non-idempotent effect (spawns a subprocess), so we have to handle it
|
|
57
|
+
// explicitly: defer the teardown in cleanup, and if the next mount fires
|
|
58
|
+
// within the same tick, rescue the pending teardown.
|
|
59
|
+
//
|
|
60
|
+
// Without this, every render in dev would consume two render streams against
|
|
61
|
+
// the same workspace, racing on segment files and producing corrupted output
|
|
62
|
+
// — the bug we tracked down.
|
|
63
|
+
if (cleanupTimerRef.current !== null) {
|
|
64
|
+
clearTimeout(cleanupTimerRef.current)
|
|
65
|
+
cleanupTimerRef.current = null
|
|
66
|
+
unmountedRef.current = false
|
|
67
|
+
cancelledRef.current = false
|
|
68
|
+
return scheduleCleanup
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
unmountedRef.current = false
|
|
72
|
+
cancelledRef.current = false
|
|
73
|
+
|
|
74
|
+
void (async () => {
|
|
75
|
+
try {
|
|
76
|
+
for await (const ev of adapter.render(projectId)) {
|
|
77
|
+
if (unmountedRef.current || cancelledRef.current) break
|
|
78
|
+
if (ev.type === 'log') {
|
|
79
|
+
setLogs(l => [...l, ev.message])
|
|
80
|
+
} else if (ev.type === 'done') {
|
|
81
|
+
setOutput(ev.outputPath)
|
|
82
|
+
setStatus('done')
|
|
83
|
+
} else {
|
|
84
|
+
setError(ev.message)
|
|
85
|
+
setStatus('error')
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
if (!unmountedRef.current && !cancelledRef.current) {
|
|
90
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
91
|
+
setStatus('error')
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
})()
|
|
95
|
+
|
|
96
|
+
return scheduleCleanup
|
|
97
|
+
|
|
98
|
+
function scheduleCleanup() {
|
|
99
|
+
// Defer the actual teardown. StrictMode's transient unmount fires before
|
|
100
|
+
// the next mount; setTimeout(0) puts the teardown after both, giving the
|
|
101
|
+
// next mount a chance to clearTimeout it. On real unmount the timer fires
|
|
102
|
+
// and the render stream is abandoned for real.
|
|
103
|
+
cleanupTimerRef.current = setTimeout(() => {
|
|
104
|
+
cleanupTimerRef.current = null
|
|
105
|
+
unmountedRef.current = true
|
|
106
|
+
}, 0)
|
|
107
|
+
}
|
|
108
|
+
}, [projectId, adapter])
|
|
109
|
+
|
|
110
|
+
// Auto-scroll logs
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
|
113
|
+
}, [logs])
|
|
114
|
+
|
|
115
|
+
// Escape to close only when done/error
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const onKey = (e: KeyboardEvent) => {
|
|
118
|
+
if (e.key === 'Escape' && status !== 'running') onClose()
|
|
119
|
+
}
|
|
120
|
+
document.addEventListener('keydown', onKey)
|
|
121
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
122
|
+
}, [status, onClose])
|
|
123
|
+
|
|
124
|
+
function handleCancel() {
|
|
125
|
+
cancelledRef.current = true
|
|
126
|
+
// Use onCancel when provided so the host can dismiss without navigating
|
|
127
|
+
// (cancelling an in-progress render shouldn't yank the user away from
|
|
128
|
+
// their editor). Falls back to onClose for back-compat with callers that
|
|
129
|
+
// haven't been updated.
|
|
130
|
+
;(onCancel ?? onClose)()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (status === 'done' && outputPath) {
|
|
134
|
+
return (
|
|
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">
|
|
137
|
+
|
|
138
|
+
{/* Left — video */}
|
|
139
|
+
<div className="flex-1 bg-black flex items-center justify-center overflow-hidden">
|
|
140
|
+
<video
|
|
141
|
+
src={adapter.fileUrl(outputPath)}
|
|
142
|
+
controls
|
|
143
|
+
autoPlay
|
|
144
|
+
playsInline
|
|
145
|
+
className="h-full w-full object-contain"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
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">
|
|
152
|
+
<div className="flex items-center gap-2.5">
|
|
153
|
+
<span className="w-2 h-2 rounded-full bg-green-400" />
|
|
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>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
<button onClick={onClose} className="text-gray-500 hover:text-white transition-colors text-lg leading-none">×</button>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
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>
|
|
164
|
+
{/* Host-supplied export controls (e.g. download-all .zip). */}
|
|
165
|
+
{exportActions}
|
|
166
|
+
<a
|
|
167
|
+
href={adapter.fileUrl(outputPath)}
|
|
168
|
+
download={basename(outputPath)}
|
|
169
|
+
className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-green-800/60 border border-green-700 text-green-200 hover:bg-green-700/60 transition-colors font-medium"
|
|
170
|
+
>
|
|
171
|
+
Download
|
|
172
|
+
</a>
|
|
173
|
+
<button
|
|
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"
|
|
176
|
+
>
|
|
177
|
+
Close
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return (
|
|
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">
|
|
189
|
+
|
|
190
|
+
{/* Header */}
|
|
191
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
|
|
192
|
+
<div className="flex items-center gap-2.5">
|
|
193
|
+
{status === 'running' && <span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />}
|
|
194
|
+
{status === 'error' && <span className="w-2 h-2 rounded-full bg-red-400" />}
|
|
195
|
+
<div className="flex flex-col gap-0.5">
|
|
196
|
+
<h2 className="text-sm font-semibold text-white">
|
|
197
|
+
{status === 'running' ? 'Rendering…' : 'Render failed'}
|
|
198
|
+
</h2>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
{status !== 'running' && (
|
|
202
|
+
<button onClick={onClose} className="text-gray-500 hover:text-white transition-colors text-lg leading-none">×</button>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{/* Log output */}
|
|
207
|
+
<div className="relative">
|
|
208
|
+
<button
|
|
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"
|
|
211
|
+
title="Copy logs"
|
|
212
|
+
>
|
|
213
|
+
Copy
|
|
214
|
+
</button>
|
|
215
|
+
<div
|
|
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"
|
|
218
|
+
>
|
|
219
|
+
{logs.length === 0 && status === 'running' && (
|
|
220
|
+
<span className="text-gray-600 italic">Starting render engine…</span>
|
|
221
|
+
)}
|
|
222
|
+
{logs.map((line, i) => (
|
|
223
|
+
<LogLine key={i} text={line} />
|
|
224
|
+
))}
|
|
225
|
+
{status === 'error' && errorMsg && (
|
|
226
|
+
<span className="text-red-400 mt-1">{errorMsg}</span>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{/* Footer */}
|
|
232
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-gray-800">
|
|
233
|
+
{status === 'running' ? (
|
|
234
|
+
<button
|
|
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"
|
|
237
|
+
>
|
|
238
|
+
Cancel
|
|
239
|
+
</button>
|
|
240
|
+
) : (
|
|
241
|
+
<button
|
|
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"
|
|
244
|
+
>
|
|
245
|
+
Close
|
|
246
|
+
</button>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { VersionEntry } from '../types'
|
|
3
|
+
|
|
4
|
+
// VersionPanel reads only the editor-relevant slice of a version — hash,
|
|
5
|
+
// message, timestamp — which is exactly `VersionEntry`. Aliased to the panel's
|
|
6
|
+
// original `ProjectVersion` name so the ported parse/dedup logic is untouched.
|
|
7
|
+
type ProjectVersion = VersionEntry
|
|
8
|
+
|
|
9
|
+
function formatTime(iso: string): string {
|
|
10
|
+
const d = new Date(iso)
|
|
11
|
+
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseVersion(v: ProjectVersion): { run: number; label: string } {
|
|
15
|
+
const m = v.message.match(/run (\d+) — (.+)/)
|
|
16
|
+
return m ? { run: parseInt(m[1]), label: m[2] } : { run: 0, label: v.message }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function dedupeVersions(versions: ProjectVersion[]): ProjectVersion[] {
|
|
20
|
+
const nonInit = versions.filter(v => parseVersion(v).run > 0)
|
|
21
|
+
const byRun = new Map<number, ProjectVersion>()
|
|
22
|
+
for (const v of nonInit) {
|
|
23
|
+
const { run, label } = parseVersion(v)
|
|
24
|
+
const existing = byRun.get(run)
|
|
25
|
+
const isDefault = label === 'draft' || label === 'final' || label === 'pending'
|
|
26
|
+
if (!existing) { byRun.set(run, v); continue }
|
|
27
|
+
const { label: existingLabel } = parseVersion(existing)
|
|
28
|
+
const existingIsDefault = existingLabel === 'draft' || existingLabel === 'final' || existingLabel === 'pending'
|
|
29
|
+
if (existingIsDefault && !isDefault) byRun.set(run, v)
|
|
30
|
+
}
|
|
31
|
+
return [...byRun.values()].sort((a, b) => parseVersion(b).run - parseVersion(a).run)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface VersionPanelProps {
|
|
35
|
+
versions: ProjectVersion[]
|
|
36
|
+
restoring: string | null
|
|
37
|
+
onRestore: (hash: string) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default function VersionPanel({ versions, restoring, onRestore }: VersionPanelProps) {
|
|
41
|
+
const [open, setOpen] = useState(true)
|
|
42
|
+
const deduped = dedupeVersions(versions)
|
|
43
|
+
|
|
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' }}>
|
|
46
|
+
<button
|
|
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"
|
|
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>
|
|
52
|
+
</button>
|
|
53
|
+
<div className="overflow-y-auto p-2 flex flex-col gap-1.5">
|
|
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>
|
|
56
|
+
) : deduped.map(v => {
|
|
57
|
+
const { run, label } = parseVersion(v)
|
|
58
|
+
const isDefault = label === 'draft' || label === 'final' || label === 'pending'
|
|
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">
|
|
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>
|
|
63
|
+
{isDefault ? (
|
|
64
|
+
<span className="text-[10px] text-gray-500 capitalize">{label}</span>
|
|
65
|
+
) : (
|
|
66
|
+
<span className="text-xs font-medium text-gray-700 dark:text-gray-200 truncate capitalize" title={label}>{label}</span>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
<span className="text-[10px] text-gray-600">{formatTime(v.timestamp)}</span>
|
|
70
|
+
<button
|
|
71
|
+
onClick={() => onRestore(v.hash)}
|
|
72
|
+
disabled={restoring === v.hash}
|
|
73
|
+
className="text-[10px] text-blue-500 hover:text-blue-400 text-left transition-colors disabled:opacity-40"
|
|
74
|
+
>
|
|
75
|
+
{restoring === v.hash ? 'Restoring…' : 'Restore →'}
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
})}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|