@devbycrux/editor 0.1.0 → 0.2.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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/video-adapter-contract.test.ts +89 -0
  3. package/src/index.ts +23 -1
  4. package/src/types.ts +141 -0
  5. package/src/video/RenderModal.tsx +252 -0
  6. package/src/video/VersionPanel.tsx +83 -0
  7. package/src/video/VideoEditor.tsx +508 -0
  8. package/src/video/__tests__/VideoEditor.test.tsx +213 -0
  9. package/src/video/__tests__/captionRepair.test.ts +134 -0
  10. package/src/video/__tests__/cuts.test.ts +198 -0
  11. package/src/video/captionRepair.ts +41 -0
  12. package/src/video/cuts.ts +369 -0
  13. package/src/video/design-canvas.ts +11 -0
  14. package/src/video/preview/CaptionPreview.tsx +83 -0
  15. package/src/video/preview/CarouselPreview.tsx +35 -0
  16. package/src/video/preview/OverlayItemsLayer.tsx +603 -0
  17. package/src/video/preview/PreviewPlayer.tsx +178 -0
  18. package/src/video/preview/useDragOverlay.ts +167 -0
  19. package/src/video/preview/useVideoPlayback.ts +761 -0
  20. package/src/video/timeline/AudioTrackRow.tsx +406 -0
  21. package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
  22. package/src/video/timeline/EditableSegment.tsx +30 -0
  23. package/src/video/timeline/Scrubber.tsx +184 -0
  24. package/src/video/timeline/Timeline.tsx +375 -0
  25. package/src/video/timeline/TimelineContext.ts +25 -0
  26. package/src/video/timeline/TranscriptModal.tsx +63 -0
  27. package/src/video/timeline/TranscriptPanel.tsx +86 -0
  28. package/src/video/timeline/VisualTrackRow.tsx +293 -0
  29. package/src/video/timeline/makeCaptionEdit.ts +32 -0
  30. package/src/video/timeline/multiSelectOps.ts +157 -0
  31. package/src/video/timeline/useItemDragDrop.ts +190 -0
  32. package/src/video/timeline/useTimelineZoom.ts +48 -0
  33. package/src/video/timeline/utils.ts +17 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devbycrux/editor",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type {
3
+ EditorAdapter,
4
+ RenderEvent,
5
+ RenderOptions,
6
+ VersionEntry,
7
+ WaveformChunk,
8
+ } from '../types'
9
+ import type { EditorProject, ImageElement } from '../schema'
10
+
11
+ // ── Video adapter contract ────────────────────────────────────────────────────
12
+ // The video editor adds four OPTIONAL adapter methods: listVersionHistory,
13
+ // restoreVersion, getWaveformChunks, clearOverlayCache. This file fails to
14
+ // compile if those methods are mistyped, and verifies that (a) an adapter
15
+ // implementing them type-checks and (b) one omitting them still type-checks.
16
+
17
+ const project: EditorProject = {
18
+ version: '1',
19
+ id: 'p1',
20
+ status: 'draft' as EditorProject['status'],
21
+ name: 'Fake',
22
+ workflow: 'video',
23
+ editingPrompt: '',
24
+ settings: { resolution: [1080, 1920] },
25
+ assets: [],
26
+ tracks: [[]],
27
+ }
28
+
29
+ const baseRequired = {
30
+ loadProject: async (_id: string): Promise<EditorProject> => project,
31
+ saveProject: async (_id: string, _p: EditorProject): Promise<void> => {},
32
+ subscribe: (_id: string, _onFrame: (p: EditorProject) => void): (() => void) => () => {},
33
+ render: async function* (_id: string, _opts?: RenderOptions): AsyncIterable<RenderEvent> {
34
+ yield { type: 'done', outputPath: '/out/p1.mp4' }
35
+ },
36
+ resolveImageSrc: (el: ImageElement): string => el.src,
37
+ compileOverlay: async (_template: string) => () => null,
38
+ listGlobalOverlays: async () => [],
39
+ listSystemOverlays: async () => [],
40
+ uploadFile: async (_file: File): Promise<string> => '/path',
41
+ fileUrl: (path: string): string => path,
42
+ }
43
+
44
+ // (a) Adapter implementing all four new optional methods.
45
+ function makeVideoAdapter(): EditorAdapter<EditorProject> {
46
+ return {
47
+ ...baseRequired,
48
+ listVersionHistory: async (_id: string): Promise<VersionEntry[]> => [
49
+ { hash: 'abc', message: 'init', timestamp: '2026-01-01T00:00:00Z' },
50
+ ],
51
+ restoreVersion: async (_id: string, _hash: string): Promise<EditorProject> => project,
52
+ getWaveformChunks: async (
53
+ _projectId: string,
54
+ _trackId: string,
55
+ _trackSrc: string,
56
+ _chunkDurationS?: number,
57
+ ): Promise<WaveformChunk[]> => [{ path: '/wf/0.png', start: 0, end: 15 }],
58
+ clearOverlayCache: (_src?: string): void => {},
59
+ }
60
+ }
61
+
62
+ // (b) Adapter omitting the new optional methods. Must still type-check.
63
+ function makeMinimalAdapter(): EditorAdapter<EditorProject> {
64
+ return { ...baseRequired }
65
+ }
66
+
67
+ describe('EditorAdapter video methods', () => {
68
+ it('an adapter implementing the new optional methods type-checks', async () => {
69
+ const a = makeVideoAdapter()
70
+ expect(typeof a.listVersionHistory).toBe('function')
71
+ expect(typeof a.restoreVersion).toBe('function')
72
+ expect(typeof a.getWaveformChunks).toBe('function')
73
+ expect(typeof a.clearOverlayCache).toBe('function')
74
+
75
+ const versions = await a.listVersionHistory!('p1')
76
+ expect(versions[0]).toMatchObject({ hash: 'abc', message: 'init' })
77
+
78
+ const chunks = await a.getWaveformChunks!('p1', 't1', 'a.mp3', 15)
79
+ expect(chunks[0]).toMatchObject({ path: '/wf/0.png', start: 0, end: 15 })
80
+ })
81
+
82
+ it('an adapter omitting the new optional methods still type-checks', () => {
83
+ const a = makeMinimalAdapter()
84
+ expect(a.listVersionHistory).toBeUndefined()
85
+ expect(a.restoreVersion).toBeUndefined()
86
+ expect(a.getWaveformChunks).toBeUndefined()
87
+ expect(a.clearOverlayCache).toBeUndefined()
88
+ })
89
+ })
package/src/index.ts CHANGED
@@ -33,12 +33,25 @@ export type {
33
33
  MediaItem,
34
34
  GlobalOverlay,
35
35
  GlobalOverlayProp,
36
+ VersionEntry,
37
+ WaveformChunk,
36
38
  EditorAdapter,
37
39
  EditorTheme,
38
40
  EditorSlots,
39
41
  CarouselEditorProps,
42
+ VideoEditorProps,
40
43
  } from './types'
41
44
 
45
+ // ── Video editor pure helpers ─────────────────────────────────────────────────
46
+ export {
47
+ applyCutToTracks,
48
+ applyCutToItem,
49
+ collapseGaps,
50
+ splitAtTime,
51
+ } from './video/cuts'
52
+ export type { Cut } from './video/cuts'
53
+ export { getOverlayDesignCanvas } from './video/design-canvas'
54
+
42
55
  // ── Theme ─────────────────────────────────────────────────────────────────────
43
56
  export { defaultMontajTheme, applyTheme } from './theme'
44
57
 
@@ -95,6 +108,14 @@ export type { TextFormattingToolbarProps } from './text/TextFormattingToolbar'
95
108
  export { OverlayPreview } from './preview/OverlayPreview'
96
109
  export type { OverlayPreviewProps } from './preview/OverlayPreview'
97
110
 
111
+ // ── Video preview ─────────────────────────────────────────────────────────────
112
+ export { default as PreviewPlayer } from './video/preview/PreviewPlayer'
113
+ export { default as CarouselPreview } from './video/preview/CarouselPreview'
114
+ export { default as OverlayItemsLayer } from './video/preview/OverlayItemsLayer'
115
+ export { useVideoPlayback } from './video/preview/useVideoPlayback'
116
+ export { useDragOverlay } from './video/preview/useDragOverlay'
117
+ export type { Corner, DragType } from './video/preview/useDragOverlay'
118
+
98
119
  // ── Overlays ──────────────────────────────────────────────────────────────────
99
120
  export {
100
121
  STANDARD_TEXT_PROPS,
@@ -102,8 +123,9 @@ export {
102
123
  readPropAsString,
103
124
  } from './overlays/contract'
104
125
 
105
- // ── Assembled carousel editor ───────────────────────────────────────────────
126
+ // ── Assembled editors ─────────────────────────────────────────────────────────
106
127
  export { default as CarouselEditor } from './carousel/CarouselEditor'
128
+ export { default as VideoEditor } from './video/VideoEditor'
107
129
 
108
130
  // ── Public carousel sub-components ────────────────────────────────────────────
109
131
  // Hosts consume these beyond the assembled editor — Montaj's preview/caption
package/src/types.ts CHANGED
@@ -99,6 +99,34 @@ export interface GlobalOverlay {
99
99
  empty?: boolean
100
100
  }
101
101
 
102
+ // ── Version history (optional capability) ─────────────────────────────────────
103
+
104
+ /**
105
+ * A single entry in a project's version history. The editor-relevant slice of
106
+ * Montaj's `ProjectVersion` (ui/src/lib/types/schema.ts): a content-addressed
107
+ * `hash` to restore by, a human-readable `message`, and a `timestamp`. The
108
+ * adapter maps the host's richer shape down to this.
109
+ */
110
+ export interface VersionEntry {
111
+ hash: string
112
+ message: string
113
+ timestamp: string
114
+ }
115
+
116
+ // ── Waveform chunks (optional capability) ─────────────────────────────────────
117
+
118
+ /**
119
+ * One rendered waveform-image chunk for an audio track. `path` is a
120
+ * host-resolvable image path (route through `fileUrl` to display); `start`/`end`
121
+ * are source-file seconds the chunk covers. Copied verbatim from Montaj's former
122
+ * `lib/audio-waveform.ts` so the package owns the shape the timeline consumes.
123
+ */
124
+ export interface WaveformChunk {
125
+ path: string
126
+ start: number
127
+ end: number
128
+ }
129
+
102
130
  // ── Media (optional capability) ───────────────────────────────────────────────
103
131
 
104
132
  /**
@@ -244,6 +272,53 @@ export interface EditorAdapter<P extends Project = Project> {
244
272
  * `listSystemOverlays()` + its `static-text` matcher.
245
273
  */
246
274
  getDefaultTextOverlay?(): Promise<GlobalOverlay | null>
275
+
276
+ // ── Video editor capabilities (optional) ────────────────────────────────────
277
+ // Hosts driving the video editor implement these; carousel-only hosts omit
278
+ // them and the editor feature-detects their absence.
279
+
280
+ /**
281
+ * Optional: list the project's version history, newest-first. Maps to
282
+ * Montaj's `GET /api/projects/:id/versions`, mapped down to `VersionEntry`.
283
+ */
284
+ listVersionHistory?(id: string): Promise<VersionEntry[]>
285
+
286
+ /**
287
+ * Optional: restore the project to a prior version by `hash`, returning the
288
+ * restored project. Maps to Montaj's
289
+ * `POST /api/projects/:id/versions/:hash/restore`.
290
+ */
291
+ restoreVersion?(id: string, hash: string): Promise<P>
292
+
293
+ /**
294
+ * Optional: produce rendered waveform-image chunks for an audio track. The
295
+ * editor passes the project id, the track id (used to namespace the output
296
+ * cache), the track's source path, and an optional chunk duration in seconds.
297
+ * The host renders/caches the chunks and returns their resolvable paths. Maps
298
+ * to Montaj's `waveform_image` step.
299
+ */
300
+ getWaveformChunks?(
301
+ projectId: string,
302
+ trackId: string,
303
+ trackSrc: string,
304
+ chunkDurationS?: number,
305
+ ): Promise<WaveformChunk[]>
306
+
307
+ /**
308
+ * Optional: invalidate the host's compiled-overlay cache. When `src` is given,
309
+ * only that entry is dropped; hosts may treat a missing `src` as a no-op or a
310
+ * full clear. Maps to Montaj's `clearOverlayCache` in `lib/overlay-eval`.
311
+ */
312
+ clearOverlayCache?(src?: string): void
313
+
314
+ /**
315
+ * Optional: resolve the template identifier that `compileOverlay` should
316
+ * receive for a given caption style name. The mapping is host-specific —
317
+ * Montaj uses `/api/caption-template/<style>`; other hosts may differ.
318
+ * When absent the editor renders no captions (graceful no-op). Hosts without
319
+ * caption support omit this entirely.
320
+ */
321
+ resolveCaptionTemplate?(style: string): string
247
322
  }
248
323
 
249
324
  // ── Theme ────────────────────────────────────────────────────────────────────
@@ -306,6 +381,15 @@ export interface EditorSlots {
306
381
  * progress (Montaj feeds its SSE log line here); absent → default copy shows.
307
382
  */
308
383
  pendingStatus?: ReactNode
384
+ /**
385
+ * Rendered in the right sidebar below the version-history panel — in the same
386
+ * position ReviewView showed "Previous runs". The host supplies the concrete
387
+ * Montaj run-snapshot list (reading `project.history: RunSnapshot[]` and
388
+ * offering a "Restore this run" action via `onProjectChange`). The package
389
+ * never reads `project.history` or `RunSnapshot` — those are host-only types.
390
+ * Absent → nothing is rendered in that slot.
391
+ */
392
+ runHistory?: ReactNode
309
393
  }
310
394
 
311
395
  // ── Top-level component props ──────────────────────────────────────────────────
@@ -323,3 +407,60 @@ export interface CarouselEditorProps<P extends Project = Project> {
323
407
  slots?: EditorSlots
324
408
  readOnly?: boolean
325
409
  }
410
+
411
+ /**
412
+ * Props for the video editor component. Mirrors `CarouselEditorProps` —
413
+ * controlled `project` + `onProjectChange`, adapter-driven transport, optional
414
+ * theme/slots/readOnly — and adds `onBackToSetup`, the host-supplied callback
415
+ * the editor invokes when the user leaves the editor for the project's setup
416
+ * view.
417
+ */
418
+ export interface VideoEditorProps<P extends Project = Project> {
419
+ project: P
420
+ adapter: EditorAdapter<P>
421
+ onProjectChange?: (p: P) => void
422
+ theme?: EditorTheme
423
+ slots?: EditorSlots
424
+ readOnly?: boolean
425
+ onBackToSetup?: () => void
426
+
427
+ // ── Host-supplied Montaj-specific UI (render-prop seams) ──────────────────
428
+ // The clip/audio inspector and the subcut-regeneration tool read host-only
429
+ // fields (regenQueue, storyboard, the host's full Project) the package types
430
+ // don't know. The editor surfaces them as render-props it threads/renders so
431
+ // those components can stay host-side; the editor stays Montaj-agnostic.
432
+
433
+ /**
434
+ * Render-prop seam for the host's clip/audio inspector (Montaj's
435
+ * ClipInspectModal). The editor owns the "which item is being inspected"
436
+ * state — it derives `ctx.item` from the timeline's `onInspectClip` /
437
+ * `onInspectAudio` callbacks (a Montaj-agnostic `{ kind, id }` selector, not
438
+ * a project entity) and passes a close callback. Absent → no inspector.
439
+ */
440
+ renderClipInspector?: (ctx: {
441
+ item: { kind: 'clip' | 'audio'; id: string }
442
+ onClose: () => void
443
+ }) => ReactNode
444
+
445
+ /**
446
+ * Render-prop seam for the host's subcut-regeneration tool (Montaj's
447
+ * SubcutRegenTool). Threaded straight through to the timeline, which owns the
448
+ * open/close trigger (the per-clip Scissors button). Called with the clip id
449
+ * and a close callback. Absent → the subcut tool isn't rendered.
450
+ */
451
+ renderSubcutRegen?: (ctx: { clipId: string; onClose: () => void }) => ReactNode
452
+
453
+ /**
454
+ * Host-computed gate for the per-clip subcut-regenerate affordance (Montaj:
455
+ * ai_video projects). Threaded to the timeline. The package never reads
456
+ * `projectType`.
457
+ */
458
+ regenEnabled?: boolean
459
+
460
+ /**
461
+ * Host-computed predicate driving the per-clip "queued" badge (Montaj:
462
+ * project.regenQueue membership). Threaded to the timeline. The package never
463
+ * reads `regenQueue`.
464
+ */
465
+ isClipQueued?: (itemId: string) => boolean
466
+ }
@@ -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
+ }