@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,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
+ }