@bycrux/editor 0.8.6 → 0.8.8
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
CHANGED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { X } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
/** A single control/shortcut row. `keys` renders as <kbd> chips; omit for a pure gesture. */
|
|
5
|
+
export interface ControlEntry {
|
|
6
|
+
keys?: string[]
|
|
7
|
+
label: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ControlSection {
|
|
11
|
+
heading: string
|
|
12
|
+
entries: ControlEntry[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ControlsInfoModalProps {
|
|
16
|
+
title: string
|
|
17
|
+
sections: ControlSection[]
|
|
18
|
+
onClose: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read-only "how to drive this editor" modal. Content is passed in per editor
|
|
23
|
+
* (video vs carousel) so this component stays a dumb, themed renderer. Matches
|
|
24
|
+
* the backdrop/Esc/close conventions of the other editor modals (TranscriptModal
|
|
25
|
+
* et al.) and uses the shared `--editor-*` theme tokens.
|
|
26
|
+
*/
|
|
27
|
+
export default function ControlsInfoModal({ title, sections, onClose }: ControlsInfoModalProps) {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const onKey = (e: globalThis.KeyboardEvent) => {
|
|
30
|
+
if (e.key === 'Escape') onClose()
|
|
31
|
+
}
|
|
32
|
+
window.addEventListener('keydown', onKey)
|
|
33
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
34
|
+
}, [onClose])
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
role="dialog"
|
|
39
|
+
aria-modal="true"
|
|
40
|
+
aria-label={title}
|
|
41
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
|
42
|
+
onClick={onClose}
|
|
43
|
+
>
|
|
44
|
+
<div
|
|
45
|
+
className="relative mx-4 flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-lg border shadow-2xl"
|
|
46
|
+
style={{
|
|
47
|
+
background: 'var(--editor-surface)',
|
|
48
|
+
borderColor: 'var(--editor-border)',
|
|
49
|
+
color: 'var(--editor-text)',
|
|
50
|
+
}}
|
|
51
|
+
onClick={(e) => e.stopPropagation()}
|
|
52
|
+
>
|
|
53
|
+
<div
|
|
54
|
+
className="flex shrink-0 items-center justify-between border-b px-4 py-3"
|
|
55
|
+
style={{ borderColor: 'var(--editor-border)' }}
|
|
56
|
+
>
|
|
57
|
+
<h2 className="text-sm font-semibold">{title}</h2>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={onClose}
|
|
61
|
+
aria-label="Close"
|
|
62
|
+
className="cursor-pointer opacity-60 transition-opacity hover:opacity-100"
|
|
63
|
+
>
|
|
64
|
+
<X size={16} />
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div className="space-y-4 overflow-y-auto px-4 py-3">
|
|
69
|
+
{sections.map((section) => (
|
|
70
|
+
<div key={section.heading}>
|
|
71
|
+
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide opacity-50">
|
|
72
|
+
{section.heading}
|
|
73
|
+
</p>
|
|
74
|
+
<ul className="space-y-1.5">
|
|
75
|
+
{section.entries.map((entry, i) => (
|
|
76
|
+
<li key={i} className="flex items-start justify-between gap-3 text-xs">
|
|
77
|
+
<span className="opacity-80">{entry.label}</span>
|
|
78
|
+
{entry.keys && entry.keys.length > 0 && (
|
|
79
|
+
<span className="flex shrink-0 items-center gap-1">
|
|
80
|
+
{entry.keys.map((k, j) => (
|
|
81
|
+
<kbd
|
|
82
|
+
key={j}
|
|
83
|
+
className="rounded border px-1.5 py-0.5 font-mono text-[10px] leading-none"
|
|
84
|
+
style={{ borderColor: 'var(--editor-border)', background: 'var(--editor-bg)' }}
|
|
85
|
+
>
|
|
86
|
+
{k}
|
|
87
|
+
</kbd>
|
|
88
|
+
))}
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Controls reference for the video/timeline editor. Sourced from VideoEditor's
|
|
103
|
+
* key handler (S / ⌘Z), the track-controls toolbar, and PreviewPlayer's
|
|
104
|
+
* on-canvas transform (drag / corner-drag / scroll / rotate). */
|
|
105
|
+
export const VIDEO_CONTROLS: ControlSection[] = [
|
|
106
|
+
{
|
|
107
|
+
heading: 'Preview',
|
|
108
|
+
entries: [
|
|
109
|
+
{ label: 'Drag to move the selected clip or overlay' },
|
|
110
|
+
{ label: 'Corner-drag or scroll to scale it' },
|
|
111
|
+
{ label: 'Drag the rotate handle to turn an overlay' },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
heading: 'Timeline',
|
|
116
|
+
entries: [
|
|
117
|
+
{ label: 'Drag a clip to reposition it' },
|
|
118
|
+
{ label: "Drag a clip's edge to trim it" },
|
|
119
|
+
{ label: 'Select a clip, then click ⓘ to inspect it' },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
heading: 'Toolbar',
|
|
124
|
+
entries: [
|
|
125
|
+
{ label: 'Ripple: edits close the gap instead of leaving it' },
|
|
126
|
+
{ label: 'Crop source: non-destructively crop the selected clip' },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
heading: 'Keyboard',
|
|
131
|
+
entries: [
|
|
132
|
+
{ keys: ['S'], label: 'Split at the playhead' },
|
|
133
|
+
{ keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
/** Controls reference for the carousel editor. Sourced from CarouselEditor's key
|
|
139
|
+
* handlers (undo/redo, Delete) and the canvas gesture hints. */
|
|
140
|
+
export const CAROUSEL_CONTROLS: ControlSection[] = [
|
|
141
|
+
{
|
|
142
|
+
heading: 'Canvas',
|
|
143
|
+
entries: [
|
|
144
|
+
{ label: 'Drag an element to reposition it' },
|
|
145
|
+
{ label: 'Resize or rotate it via the handles' },
|
|
146
|
+
{ label: 'Double-click text to edit it' },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
heading: 'Keyboard',
|
|
151
|
+
entries: [
|
|
152
|
+
{ keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
|
|
153
|
+
{ keys: ['⌘/Ctrl', '⇧', 'Z'], label: 'Redo' },
|
|
154
|
+
{ keys: ['Delete'], label: 'Remove the selected element' },
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
|
3
|
+
import ControlsInfoModal, {
|
|
4
|
+
VIDEO_CONTROLS,
|
|
5
|
+
CAROUSEL_CONTROLS,
|
|
6
|
+
type ControlSection,
|
|
7
|
+
} from '../ControlsInfoModal'
|
|
8
|
+
|
|
9
|
+
afterEach(() => cleanup())
|
|
10
|
+
|
|
11
|
+
const SECTIONS: ControlSection[] = [
|
|
12
|
+
{
|
|
13
|
+
heading: 'Canvas',
|
|
14
|
+
entries: [
|
|
15
|
+
{ label: 'Drag an element to reposition it' },
|
|
16
|
+
{ keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
describe('ControlsInfoModal', () => {
|
|
22
|
+
it('renders the title, section headings, entry labels, and kbd chips', () => {
|
|
23
|
+
render(<ControlsInfoModal title="Editor controls" sections={SECTIONS} onClose={vi.fn()} />)
|
|
24
|
+
|
|
25
|
+
expect(screen.getByRole('dialog', { name: 'Editor controls' })).toBeTruthy()
|
|
26
|
+
expect(screen.getByText('Canvas')).toBeTruthy()
|
|
27
|
+
expect(screen.getByText('Drag an element to reposition it')).toBeTruthy()
|
|
28
|
+
// Keys render as individual <kbd> chips.
|
|
29
|
+
expect(screen.getByText('⌘/Ctrl').tagName).toBe('KBD')
|
|
30
|
+
expect(screen.getByText('Z').tagName).toBe('KBD')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('closes on Escape', () => {
|
|
34
|
+
const onClose = vi.fn()
|
|
35
|
+
render(<ControlsInfoModal title="Editor controls" sections={SECTIONS} onClose={onClose} />)
|
|
36
|
+
fireEvent.keyDown(window, { key: 'Escape' })
|
|
37
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('closes on backdrop click but not on panel click', () => {
|
|
41
|
+
const onClose = vi.fn()
|
|
42
|
+
render(<ControlsInfoModal title="Editor controls" sections={SECTIONS} onClose={onClose} />)
|
|
43
|
+
|
|
44
|
+
// Click inside the panel — should NOT close.
|
|
45
|
+
fireEvent.click(screen.getByText('Canvas'))
|
|
46
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
47
|
+
|
|
48
|
+
// Click the backdrop (the dialog root) — should close.
|
|
49
|
+
fireEvent.click(screen.getByRole('dialog'))
|
|
50
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('ships real control content for both editors', () => {
|
|
54
|
+
// Guards against an empty/placeholder content regression.
|
|
55
|
+
const videoLabels = VIDEO_CONTROLS.flatMap((s) => s.entries.map((e) => e.label))
|
|
56
|
+
const carouselLabels = CAROUSEL_CONTROLS.flatMap((s) => s.entries.map((e) => e.label))
|
|
57
|
+
expect(videoLabels).toEqual(expect.arrayContaining(['Split at the playhead']))
|
|
58
|
+
expect(carouselLabels).toEqual(expect.arrayContaining(['Double-click text to edit it']))
|
|
59
|
+
expect(VIDEO_CONTROLS.length).toBeGreaterThan(0)
|
|
60
|
+
expect(CAROUSEL_CONTROLS.length).toBeGreaterThan(0)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { RefreshCw, AlertCircle, Download } from 'lucide-react'
|
|
2
|
+
import { RefreshCw, AlertCircle, Download, Info } from 'lucide-react'
|
|
3
3
|
import type { Project, Slide, CarouselElement, ImageElement, CarouselEditorProps, OverlayFactory } from '../types'
|
|
4
4
|
import { applyTheme, defaultMontajTheme } from '../theme'
|
|
5
5
|
import { useProjectState } from '../state/use-project-state'
|
|
@@ -7,6 +7,7 @@ import SlideCanvas from './SlideCanvas'
|
|
|
7
7
|
import SlidePropertyPanel from './SlidePropertyPanel'
|
|
8
8
|
import AddElementMenu from './AddElementMenu'
|
|
9
9
|
import CarouselRenderModal from './CarouselRenderModal'
|
|
10
|
+
import ControlsInfoModal, { CAROUSEL_CONTROLS } from '../ControlsInfoModal'
|
|
10
11
|
import { Button } from '../ui'
|
|
11
12
|
|
|
12
13
|
// Generic over the host's concrete project type `P` (default = the package's
|
|
@@ -167,6 +168,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
167
168
|
|
|
168
169
|
const [skillPath, setSkillPath] = useState<string | null>(null)
|
|
169
170
|
const [copied, setCopied] = useState(false)
|
|
171
|
+
const [showControls, setShowControls] = useState(false)
|
|
170
172
|
const [refreshing, setRefreshing] = useState(false)
|
|
171
173
|
const [refreshState, setRefreshState] = useState<'idle' | 'err'>('idle')
|
|
172
174
|
const [rendering, setRendering] = useState(false)
|
|
@@ -505,9 +507,20 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
505
507
|
hiddenElementIds={hiddenElementIds}
|
|
506
508
|
/>
|
|
507
509
|
</div>
|
|
508
|
-
<
|
|
509
|
-
|
|
510
|
-
|
|
510
|
+
<div className="flex-shrink-0 flex items-center justify-center gap-1.5 text-xs text-[var(--editor-text)]/60 max-w-md">
|
|
511
|
+
<span className="text-center">
|
|
512
|
+
Drag to reposition, resize/rotate via handles, double-click text to edit. Cmd/Ctrl+Z to undo.
|
|
513
|
+
</span>
|
|
514
|
+
<button
|
|
515
|
+
type="button"
|
|
516
|
+
onClick={() => setShowControls(true)}
|
|
517
|
+
title="Editor controls & shortcuts"
|
|
518
|
+
aria-label="Editor controls & shortcuts"
|
|
519
|
+
className="shrink-0 cursor-pointer opacity-60 transition-opacity hover:opacity-100"
|
|
520
|
+
>
|
|
521
|
+
<Info size={13} />
|
|
522
|
+
</button>
|
|
523
|
+
</div>
|
|
511
524
|
</>
|
|
512
525
|
) : (
|
|
513
526
|
<div className="text-[var(--editor-text)]/40 text-sm">No slides yet. Add one in the left panel.</div>
|
|
@@ -566,6 +579,14 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
566
579
|
</div>
|
|
567
580
|
)}
|
|
568
581
|
|
|
582
|
+
{showControls && (
|
|
583
|
+
<ControlsInfoModal
|
|
584
|
+
title="Editor controls"
|
|
585
|
+
sections={CAROUSEL_CONTROLS}
|
|
586
|
+
onClose={() => setShowControls(false)}
|
|
587
|
+
/>
|
|
588
|
+
)}
|
|
589
|
+
|
|
569
590
|
{renderOpen && (
|
|
570
591
|
<CarouselRenderModal
|
|
571
592
|
projectId={project.id}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import type { EditorAdapter, Project } from '../types'
|
|
3
|
+
import type { EditorAdapter, Project, RenderStatus } from '../types'
|
|
4
4
|
|
|
5
5
|
interface CarouselRenderModalProps {
|
|
6
6
|
projectId: string
|
|
@@ -21,10 +21,42 @@ interface CarouselRenderModalProps {
|
|
|
21
21
|
exportActions?: ReactNode
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Promoted render output (R2-presigned), as carried by `RenderStatus.media`. */
|
|
25
|
+
type RenderMedia = NonNullable<RenderStatus['media']>[number]
|
|
26
|
+
|
|
27
|
+
/** A rendered slide ready to display: a direct URL + its filename caption. */
|
|
28
|
+
interface SlideView { url: string; filename: string }
|
|
29
|
+
|
|
30
|
+
const POLL_INTERVAL_MS = 2500
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Consecutive status-poll failures tolerated before the modal declares the
|
|
34
|
+
* render failed. A transient network blip (a Cloudflare-tunnel hiccup, a backend
|
|
35
|
+
* redeploy, a flaky hop) must not be mistaken for a render failure — the render
|
|
36
|
+
* keeps running server-side. ~12 × 2.5s ≈ 30s of continuous unreachability
|
|
37
|
+
* before giving up. An explicit backend `error` status is still terminal.
|
|
38
|
+
* Mirrors `video/RenderModal.tsx`.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_POLL_FAILURES = 12
|
|
41
|
+
|
|
24
42
|
function slideFile(index: number): string {
|
|
25
43
|
return `slide_${String(index + 1).padStart(2, '0')}.png`
|
|
26
44
|
}
|
|
27
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Order promoted media into slide order for the gallery. Hub's
|
|
48
|
+
* `promoteRenderOutputs` returns media unordered and may include non-slide
|
|
49
|
+
* outputs (e.g. `manifest.json`), so keep only `slide_NN.png` and sort by the
|
|
50
|
+
* numeric index.
|
|
51
|
+
*/
|
|
52
|
+
function slidesFromMedia(media: RenderMedia[]): SlideView[] {
|
|
53
|
+
return media
|
|
54
|
+
.map((m) => ({ m, match: /^slide_0*(\d+)\.png$/i.exec(m.filename) }))
|
|
55
|
+
.filter((x): x is { m: RenderMedia; match: RegExpExecArray } => x.match !== null)
|
|
56
|
+
.sort((a, b) => Number(a.match[1]) - Number(b.match[1]))
|
|
57
|
+
.map(({ m }) => ({ url: m.url, filename: m.filename }))
|
|
58
|
+
}
|
|
59
|
+
|
|
28
60
|
function LogLine({ text }: { text: string }) {
|
|
29
61
|
const t = text.replace(/^\[render\]\s*/, '')
|
|
30
62
|
let color = 'text-[var(--editor-text)]/60'
|
|
@@ -46,46 +78,126 @@ function LogLine({ text }: { text: string }) {
|
|
|
46
78
|
export default function CarouselRenderModal({ projectId, adapter, slidesCount, resolution, onClose, onCancel, exportActions }: CarouselRenderModalProps) {
|
|
47
79
|
const [logs, setLogs] = useState<string[]>([])
|
|
48
80
|
const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
|
|
81
|
+
const [media, setMedia] = useState<RenderMedia[] | null>(null)
|
|
49
82
|
const [outputDir, setOutDir] = useState<string | null>(null)
|
|
50
83
|
const [errorMsg, setError] = useState<string | null>(null)
|
|
51
84
|
const logRef = useRef<HTMLDivElement>(null)
|
|
52
85
|
const cancelledRef = useRef(false)
|
|
53
86
|
const unmountedRef = useRef(false)
|
|
54
87
|
const cleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
88
|
+
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
89
|
+
|
|
90
|
+
// Data-transport decision: does this adapter expose the poll-based render API?
|
|
91
|
+
// Hub clients (async-kick backend) do; the montaj-native desktop host doesn't
|
|
92
|
+
// and falls back to the SSE `adapter.render()` stream below.
|
|
93
|
+
const usePolling = !!(adapter.renderAsync && adapter.getRenderStatus)
|
|
55
94
|
|
|
56
95
|
useEffect(() => {
|
|
57
96
|
// StrictMode-safe render trigger — see RenderModal.tsx for the long-form
|
|
58
|
-
// comment.
|
|
97
|
+
// comment. Triggering a render is non-idempotent (spawns a subprocess), so
|
|
98
|
+
// defer teardown and rescue it if the next mount fires within the same tick.
|
|
59
99
|
if (cleanupTimerRef.current !== null) {
|
|
60
100
|
clearTimeout(cleanupTimerRef.current)
|
|
61
101
|
cleanupTimerRef.current = null
|
|
62
102
|
unmountedRef.current = false
|
|
103
|
+
cancelledRef.current = false
|
|
63
104
|
return scheduleCleanup
|
|
64
105
|
}
|
|
65
106
|
|
|
66
107
|
unmountedRef.current = false
|
|
67
108
|
cancelledRef.current = false
|
|
68
|
-
|
|
109
|
+
|
|
110
|
+
void (async () => {
|
|
69
111
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
112
|
+
if (usePolling) {
|
|
113
|
+
// Async, poll-based render: kick once, then poll status until terminal.
|
|
114
|
+
// The backend's POST render returns immediately ({status:'running'})
|
|
115
|
+
// and the render runs detached — so we follow it via getRenderStatus
|
|
116
|
+
// instead of a (now non-existent) SSE stream. This is the fix for the
|
|
117
|
+
// modal hanging forever on "Starting render engine…".
|
|
118
|
+
await adapter.renderAsync!(projectId)
|
|
119
|
+
if (unmountedRef.current || cancelledRef.current) return
|
|
120
|
+
|
|
121
|
+
let pollFailures = 0
|
|
122
|
+
const tick = async () => {
|
|
123
|
+
if (unmountedRef.current || cancelledRef.current) return
|
|
124
|
+
let snap: RenderStatus
|
|
125
|
+
try {
|
|
126
|
+
snap = await adapter.getRenderStatus!(projectId)
|
|
127
|
+
pollFailures = 0
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (unmountedRef.current || cancelledRef.current) return
|
|
130
|
+
// Transient unreachability must NOT read as a render failure — the
|
|
131
|
+
// render is still running server-side. Keep polling; only give up
|
|
132
|
+
// after MAX_POLL_FAILURES consecutive failures. An explicit
|
|
133
|
+
// { status: 'error' } reply is still terminal (handled below).
|
|
134
|
+
pollFailures += 1
|
|
135
|
+
if (pollFailures >= MAX_POLL_FAILURES) {
|
|
136
|
+
stopPolling()
|
|
137
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
138
|
+
setStatus('error')
|
|
139
|
+
}
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
if (unmountedRef.current || cancelledRef.current) return
|
|
143
|
+
|
|
144
|
+
if (snap.media) setMedia(snap.media)
|
|
145
|
+
|
|
146
|
+
if (snap.status === 'done') {
|
|
147
|
+
stopPolling()
|
|
148
|
+
setMedia(snap.media ?? null)
|
|
149
|
+
setStatus('done')
|
|
150
|
+
} else if (snap.status === 'error') {
|
|
151
|
+
stopPolling()
|
|
152
|
+
setError(snap.error ?? 'Render failed.')
|
|
153
|
+
setStatus('error')
|
|
154
|
+
} else if (snap.status === 'idle') {
|
|
155
|
+
// We just kicked the render, so a job exists server-side; an 'idle'
|
|
156
|
+
// reply means the sidecar lost it (e.g. restarted mid-render).
|
|
157
|
+
// Without this we'd poll forever with no progress.
|
|
158
|
+
stopPolling()
|
|
159
|
+
setError('The render was interrupted on the server. Please try again.')
|
|
160
|
+
setStatus('error')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// First poll immediately, then on the interval.
|
|
165
|
+
await tick()
|
|
166
|
+
if (unmountedRef.current || cancelledRef.current) return
|
|
167
|
+
pollTimerRef.current = setInterval(() => { void tick() }, POLL_INTERVAL_MS)
|
|
168
|
+
} else {
|
|
169
|
+
// Fallback for hosts without the poll API (montaj-native desktop):
|
|
170
|
+
// consume the SSE render stream and accumulate log lines.
|
|
171
|
+
for await (const ev of adapter.render(projectId)) {
|
|
172
|
+
if (cancelledRef.current || unmountedRef.current) break
|
|
173
|
+
if (ev.type === 'log') setLogs(l => [...l, ev.message])
|
|
174
|
+
else if (ev.type === 'done') { setOutDir(ev.outputPath); setStatus('done') }
|
|
175
|
+
else if (ev.type === 'error') { setError(ev.message); setStatus('error') }
|
|
176
|
+
}
|
|
75
177
|
}
|
|
76
178
|
} catch (e) {
|
|
77
179
|
if (!cancelledRef.current && !unmountedRef.current) {
|
|
78
|
-
setError(String(e))
|
|
180
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
181
|
+
setStatus('error')
|
|
79
182
|
}
|
|
80
183
|
}
|
|
81
184
|
})()
|
|
185
|
+
|
|
82
186
|
return scheduleCleanup
|
|
83
187
|
|
|
188
|
+
function stopPolling() {
|
|
189
|
+
if (pollTimerRef.current !== null) {
|
|
190
|
+
clearInterval(pollTimerRef.current)
|
|
191
|
+
pollTimerRef.current = null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
84
195
|
function scheduleCleanup() {
|
|
85
196
|
cleanupTimerRef.current = setTimeout(() => {
|
|
86
197
|
cleanupTimerRef.current = null
|
|
87
198
|
unmountedRef.current = true
|
|
88
199
|
cancelledRef.current = true
|
|
200
|
+
stopPolling()
|
|
89
201
|
}, 0)
|
|
90
202
|
}
|
|
91
203
|
}, [projectId, adapter])
|
|
@@ -108,7 +220,18 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
108
220
|
}
|
|
109
221
|
|
|
110
222
|
// ── Done state — gallery + zip download ─────────────────────────────────
|
|
111
|
-
if (status === 'done'
|
|
223
|
+
if (status === 'done') {
|
|
224
|
+
// Prefer the promoted R2 media (poll path); fall back to workspace paths
|
|
225
|
+
// resolved via fileUrl (SSE path).
|
|
226
|
+
const slides: SlideView[] = media
|
|
227
|
+
? slidesFromMedia(media)
|
|
228
|
+
: outputDir
|
|
229
|
+
? Array.from({ length: slidesCount }).map((_, i) => {
|
|
230
|
+
const file = slideFile(i)
|
|
231
|
+
return { url: adapter.fileUrl(`${outputDir}/${file}`), filename: file }
|
|
232
|
+
})
|
|
233
|
+
: []
|
|
234
|
+
|
|
112
235
|
// Portal to document.body (see RenderModal): a transformed host ancestor
|
|
113
236
|
// would otherwise trap this `fixed` overlay and center the panel off-screen.
|
|
114
237
|
return createPortal(
|
|
@@ -117,35 +240,35 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
117
240
|
|
|
118
241
|
{/* Left — slide gallery */}
|
|
119
242
|
<div className="flex-1 bg-black flex items-center justify-center overflow-auto p-8">
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const url = adapter.fileUrl(`${outputDir}/${file}`)
|
|
127
|
-
return (
|
|
243
|
+
{slides.length > 0 ? (
|
|
244
|
+
<div
|
|
245
|
+
className="grid gap-4 w-full max-w-6xl"
|
|
246
|
+
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}
|
|
247
|
+
>
|
|
248
|
+
{slides.map((slide, i) => (
|
|
128
249
|
<a
|
|
129
|
-
key={
|
|
130
|
-
href={url}
|
|
250
|
+
key={slide.filename}
|
|
251
|
+
href={slide.url}
|
|
131
252
|
target="_blank"
|
|
132
253
|
rel="noreferrer"
|
|
133
254
|
className="group relative block rounded-lg overflow-hidden border border-[var(--editor-border)] hover:border-[var(--editor-accent)] transition-colors bg-[var(--editor-surface)]"
|
|
134
255
|
>
|
|
135
256
|
<img
|
|
136
|
-
src={url}
|
|
137
|
-
alt={
|
|
257
|
+
src={slide.url}
|
|
258
|
+
alt={slide.filename}
|
|
138
259
|
className="block w-full h-auto"
|
|
139
260
|
style={{ aspectRatio: `${resolution[0]} / ${resolution[1]}` }}
|
|
140
261
|
/>
|
|
141
262
|
<div className="absolute bottom-0 left-0 right-0 px-2 py-1.5 bg-black text-[11px] text-[var(--editor-text)] font-mono flex justify-between">
|
|
142
263
|
<span>#{String(i + 1).padStart(2, '0')}</span>
|
|
143
|
-
<span className="text-[var(--editor-text)]/60">{
|
|
264
|
+
<span className="text-[var(--editor-text)]/60">{slide.filename}</span>
|
|
144
265
|
</div>
|
|
145
266
|
</a>
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
|
|
267
|
+
))}
|
|
268
|
+
</div>
|
|
269
|
+
) : (
|
|
270
|
+
<p className="text-sm text-[var(--editor-text)]/60">Render complete.</p>
|
|
271
|
+
)}
|
|
149
272
|
</div>
|
|
150
273
|
|
|
151
274
|
{/* Right — info panel */}
|
|
@@ -156,7 +279,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
156
279
|
<div>
|
|
157
280
|
<p className="text-sm font-semibold text-[var(--editor-text)]">Render complete</p>
|
|
158
281
|
<p className="text-xs text-[var(--editor-text)]/60">
|
|
159
|
-
{slidesCount} slide{slidesCount === 1 ? '' : 's'} ready.
|
|
282
|
+
{slides.length || slidesCount} slide{(slides.length || slidesCount) === 1 ? '' : 's'} ready.
|
|
160
283
|
</p>
|
|
161
284
|
</div>
|
|
162
285
|
</div>
|
|
@@ -164,7 +287,6 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
164
287
|
</div>
|
|
165
288
|
|
|
166
289
|
<div className="flex flex-col gap-3 p-5 flex-1">
|
|
167
|
-
<p className="text-xs font-mono text-[var(--editor-text)]/60 break-all leading-relaxed">{outputDir}</p>
|
|
168
290
|
{exportActions}
|
|
169
291
|
<button
|
|
170
292
|
onClick={onClose}
|
|
@@ -180,7 +302,9 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
180
302
|
)
|
|
181
303
|
}
|
|
182
304
|
|
|
183
|
-
// ── Running / error state
|
|
305
|
+
// ── Running / error state ───────────────────────────────────────────────
|
|
306
|
+
// Poll transport carries no per-line logs, so show a spinner + status text;
|
|
307
|
+
// the SSE transport (montaj-native) streams logs into the log panel.
|
|
184
308
|
return createPortal(
|
|
185
309
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
|
|
186
310
|
<div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden">
|
|
@@ -200,29 +324,45 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
200
324
|
)}
|
|
201
325
|
</div>
|
|
202
326
|
|
|
203
|
-
|
|
204
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
{logs.length === 0 && status === 'running' && (
|
|
216
|
-
<span className="text-[var(--editor-text)]/40 italic">Starting render engine…</span>
|
|
217
|
-
)}
|
|
218
|
-
{logs.map((line, i) => (
|
|
219
|
-
<LogLine key={i} text={line} />
|
|
220
|
-
))}
|
|
221
|
-
{status === 'error' && errorMsg && (
|
|
222
|
-
<span className="text-red-400 mt-1">{errorMsg}</span>
|
|
327
|
+
{usePolling ? (
|
|
328
|
+
<div className="px-5 py-8 flex flex-col items-center justify-center gap-4 min-h-[12rem]">
|
|
329
|
+
{status === 'running' ? (
|
|
330
|
+
<>
|
|
331
|
+
<span className="w-6 h-6 rounded-full border-2 border-amber-400 border-t-transparent animate-spin" />
|
|
332
|
+
<p className="text-sm text-[var(--editor-text)]/70">Rendering slides…</p>
|
|
333
|
+
<p className="text-xs text-[var(--editor-text)]/50">This can take a moment.</p>
|
|
334
|
+
</>
|
|
335
|
+
) : (
|
|
336
|
+
<p className="text-sm text-red-400 whitespace-pre-wrap break-words text-center">
|
|
337
|
+
{errorMsg ?? 'Render failed.'}
|
|
338
|
+
</p>
|
|
223
339
|
)}
|
|
224
340
|
</div>
|
|
225
|
-
|
|
341
|
+
) : (
|
|
342
|
+
<div className="relative">
|
|
343
|
+
<button
|
|
344
|
+
onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
|
|
345
|
+
className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] transition-colors"
|
|
346
|
+
title="Copy logs"
|
|
347
|
+
>
|
|
348
|
+
Copy
|
|
349
|
+
</button>
|
|
350
|
+
<div
|
|
351
|
+
ref={logRef}
|
|
352
|
+
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[var(--editor-text)] bg-[var(--editor-bg)] flex flex-col gap-0.5"
|
|
353
|
+
>
|
|
354
|
+
{logs.length === 0 && status === 'running' && (
|
|
355
|
+
<span className="text-[var(--editor-text)]/40 italic">Starting render engine…</span>
|
|
356
|
+
)}
|
|
357
|
+
{logs.map((line, i) => (
|
|
358
|
+
<LogLine key={i} text={line} />
|
|
359
|
+
))}
|
|
360
|
+
{status === 'error' && errorMsg && (
|
|
361
|
+
<span className="text-red-400 mt-1">{errorMsg}</span>
|
|
362
|
+
)}
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
226
366
|
|
|
227
367
|
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
|
|
228
368
|
{status === 'running' ? (
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { Crop, Magnet } from 'lucide-react'
|
|
2
|
+
import { Crop, Info, Magnet } from 'lucide-react'
|
|
3
3
|
import type { Project, VideoEditorProps } from '../types'
|
|
4
4
|
import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
|
|
5
|
+
import ControlsInfoModal, { VIDEO_CONTROLS } from '../ControlsInfoModal'
|
|
5
6
|
import { getOverlayDesignCanvas } from './design-canvas'
|
|
6
7
|
import { applyTheme, defaultMontajTheme } from '../theme'
|
|
7
8
|
import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './cuts'
|
|
@@ -287,6 +288,7 @@ function ReviewSurface<P extends Project>({
|
|
|
287
288
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
288
289
|
const primarySelectedId = selectedIds[0] ?? null
|
|
289
290
|
const [rippleMode, setRippleMode] = useState(false)
|
|
291
|
+
const [showControls, setShowControls] = useState(false)
|
|
290
292
|
// Source-crop mode: when on, the VideoSourceCropModal opens for the selected
|
|
291
293
|
// tracks[0] video item. Cleared when selection changes.
|
|
292
294
|
const [cropMode, setCropMode] = useState(false)
|
|
@@ -463,8 +465,16 @@ function ReviewSurface<P extends Project>({
|
|
|
463
465
|
)}
|
|
464
466
|
</div>
|
|
465
467
|
|
|
466
|
-
{/* Track controls bar — split + ripple + render */}
|
|
468
|
+
{/* Track controls bar — info + split + ripple + render */}
|
|
467
469
|
<div className="shrink-0 flex items-center justify-end gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
|
|
470
|
+
<button
|
|
471
|
+
onClick={() => setShowControls(true)}
|
|
472
|
+
title="Editor controls & shortcuts"
|
|
473
|
+
aria-label="Editor controls & shortcuts"
|
|
474
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] mr-auto"
|
|
475
|
+
>
|
|
476
|
+
<Info size={12} />
|
|
477
|
+
</button>
|
|
468
478
|
<button
|
|
469
479
|
onClick={() => handleSplit()}
|
|
470
480
|
title="Split at playhead (S) — selected item or all clips"
|
|
@@ -613,6 +623,15 @@ function ReviewSurface<P extends Project>({
|
|
|
613
623
|
/>
|
|
614
624
|
)}
|
|
615
625
|
|
|
626
|
+
{/* Controls & shortcuts reference */}
|
|
627
|
+
{showControls && (
|
|
628
|
+
<ControlsInfoModal
|
|
629
|
+
title="Editor controls"
|
|
630
|
+
sections={VIDEO_CONTROLS}
|
|
631
|
+
onClose={() => setShowControls(false)}
|
|
632
|
+
/>
|
|
633
|
+
)}
|
|
634
|
+
|
|
616
635
|
{/* Render modal — adapter.render stream + host export controls */}
|
|
617
636
|
{renderOpen && (
|
|
618
637
|
<RenderModal
|