@bycrux/editor 0.8.5 → 0.8.7
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/carousel/CarouselRenderModal.tsx +190 -50
- package/src/types.ts +15 -0
- package/src/video/RenderModal.tsx +93 -20
- package/src/video/VideoEditor.tsx +5 -0
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +72 -0
- package/src/video/timeline/useItemDragDrop.ts +16 -0
package/package.json
CHANGED
|
@@ -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' ? (
|
package/src/types.ts
CHANGED
|
@@ -544,6 +544,21 @@ export interface VideoEditorProps<P extends Project = Project> {
|
|
|
544
544
|
*/
|
|
545
545
|
assetsPlacement?: 'sidebar' | 'right' | 'bottom'
|
|
546
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Which progress UI the RenderModal shows while a render runs:
|
|
549
|
+
* - `'phases'` — the compact phase stepper (Preparing → Rendering → … →
|
|
550
|
+
* Saving). Works on any transport: poll-based hosts drive it from the
|
|
551
|
+
* status `phase`; SSE hosts park it on "Rendering". This is the universal
|
|
552
|
+
* default and what Hub clients use.
|
|
553
|
+
* - `'logs'` — the full scrolling render-log panel (colorized lines + Copy).
|
|
554
|
+
* REQUIRES the SSE `adapter.render()` transport, which streams per-line
|
|
555
|
+
* logs; this is the historical montaj-native desktop view. On a poll-based
|
|
556
|
+
* host (no log lines) this panel would sit empty, so only pass `'logs'`
|
|
557
|
+
* from a host whose adapter implements the streaming `render()` path.
|
|
558
|
+
* The host chooses per deployment; the package defaults to `'phases'`.
|
|
559
|
+
*/
|
|
560
|
+
renderProgressView?: 'phases' | 'logs'
|
|
561
|
+
|
|
547
562
|
/**
|
|
548
563
|
* Opt a host OUT of the package's built-in toolbar Render button so it can
|
|
549
564
|
* place Render in its own chrome (e.g. the desktop OS editor's top header).
|
|
@@ -17,6 +17,16 @@ interface RenderModalProps<P extends Project = Project> {
|
|
|
17
17
|
/** Host-supplied export controls (e.g. a "Download all (.zip)" link) rendered
|
|
18
18
|
* in the done state's action area, mirroring the carousel render modal. */
|
|
19
19
|
exportActions?: ReactNode
|
|
20
|
+
/**
|
|
21
|
+
* Which progress UI to show while the render runs:
|
|
22
|
+
* 'phases' — the compact phase stepper (Preparing → … → Saving). Works on
|
|
23
|
+
* any transport; the default when omitted.
|
|
24
|
+
* 'logs' — the full scrolling render-log panel. Requires the SSE
|
|
25
|
+
* `adapter.render()` transport (which streams per-line logs);
|
|
26
|
+
* on a poll-based host it would sit empty.
|
|
27
|
+
* Threaded down from the host as a flag (montaj-native → 'logs', Hub clients
|
|
28
|
+
* → 'phases'), mirroring `VideoEditorProps.renderProgressView`. */
|
|
29
|
+
progressView?: 'phases' | 'logs'
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
/** Promoted render output (R2-presigned), as carried by `RenderStatus.media`. */
|
|
@@ -113,17 +123,49 @@ function PhaseStepper({ current }: { current: RenderPhase }) {
|
|
|
113
123
|
)
|
|
114
124
|
}
|
|
115
125
|
|
|
116
|
-
|
|
126
|
+
// ── Log line (SSE / log-panel view) ───────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function LogLine({ text }: { text: string }) {
|
|
129
|
+
const t = text.replace(/^\[montaj render\]\s*/, '')
|
|
130
|
+
let color = 'text-[var(--editor-text)]/60'
|
|
131
|
+
if (/ready|complete|done|encoded|assembled/i.test(t)) color = 'text-green-400'
|
|
132
|
+
else if (/rendering|bundling|launching|browsers/i.test(t)) color = 'text-sky-400'
|
|
133
|
+
else if (/trimming|building|composing/i.test(t)) color = 'text-amber-400'
|
|
134
|
+
else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-[var(--editor-text)]/55'
|
|
135
|
+
else if (/error|fail|warn/i.test(t)) color = 'text-red-400'
|
|
136
|
+
|
|
137
|
+
const prefix = text.startsWith('[montaj render]')
|
|
138
|
+
? <span className="text-[var(--editor-text)]/40">[render] </span>
|
|
139
|
+
: null
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<span className={`leading-relaxed whitespace-pre-wrap break-all ${color}`}>
|
|
143
|
+
{prefix}{t}
|
|
144
|
+
</span>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default function RenderModal<P extends Project = Project>({ projectId, adapter, onClose, onCancel, exportActions, progressView }: RenderModalProps<P>) {
|
|
117
149
|
const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
|
|
118
150
|
const [phase, setPhase] = useState<RenderPhase>('preparing')
|
|
151
|
+
const [logs, setLogs] = useState<string[]>([])
|
|
119
152
|
const [media, setMedia] = useState<RenderMedia[] | null>(null)
|
|
120
153
|
const [outputPath, setOutput] = useState<string | null>(null)
|
|
121
154
|
const [errorMsg, setError] = useState<string | null>(null)
|
|
155
|
+
const logRef = useRef<HTMLDivElement>(null)
|
|
122
156
|
const cancelledRef = useRef(false)
|
|
123
157
|
const unmountedRef = useRef(false)
|
|
124
158
|
const cleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
125
159
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
126
160
|
|
|
161
|
+
// `usePolling` is the data-transport decision (does this adapter expose the
|
|
162
|
+
// poll-based render API?) — independent of which progress UI we show.
|
|
163
|
+
const usePolling = !!(adapter.renderAsync && adapter.getRenderStatus)
|
|
164
|
+
// `view` is the host's UI choice, threaded down as a flag (montaj-native →
|
|
165
|
+
// 'logs', Hub clients → 'phases'). Defaults to the universally-safe stepper;
|
|
166
|
+
// 'logs' only renders content on the SSE transport, which streams log lines.
|
|
167
|
+
const view = progressView ?? 'phases'
|
|
168
|
+
|
|
127
169
|
useEffect(() => {
|
|
128
170
|
// React StrictMode in dev fires mount → cleanup → mount synchronously to
|
|
129
171
|
// catch effects that aren't idempotent. Triggering a render is the textbook
|
|
@@ -145,8 +187,6 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
145
187
|
unmountedRef.current = false
|
|
146
188
|
cancelledRef.current = false
|
|
147
189
|
|
|
148
|
-
const usePolling = !!(adapter.renderAsync && adapter.getRenderStatus)
|
|
149
|
-
|
|
150
190
|
void (async () => {
|
|
151
191
|
try {
|
|
152
192
|
if (usePolling) {
|
|
@@ -210,8 +250,10 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
210
250
|
for await (const ev of adapter.render(projectId)) {
|
|
211
251
|
if (unmountedRef.current || cancelledRef.current) break
|
|
212
252
|
if (ev.type === 'log') {
|
|
213
|
-
// Streaming hosts have no phase signal
|
|
214
|
-
//
|
|
253
|
+
// Streaming hosts have no phase signal. Accumulate the line for
|
|
254
|
+
// the log panel, and keep the stepper (if shown) on "rendering"
|
|
255
|
+
// as an honest mid-pipeline indicator.
|
|
256
|
+
setLogs(l => [...l, ev.message])
|
|
215
257
|
setPhase('rendering')
|
|
216
258
|
} else if (ev.type === 'done') {
|
|
217
259
|
setOutput(ev.outputPath)
|
|
@@ -252,6 +294,11 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
252
294
|
}
|
|
253
295
|
}, [projectId, adapter])
|
|
254
296
|
|
|
297
|
+
// Auto-scroll the log panel as lines stream in.
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
|
300
|
+
}, [logs])
|
|
301
|
+
|
|
255
302
|
// Escape to close only when done/error
|
|
256
303
|
useEffect(() => {
|
|
257
304
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -341,7 +388,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
341
388
|
|
|
342
389
|
return createPortal(
|
|
343
390
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
|
|
344
|
-
<div className=
|
|
391
|
+
<div className={`w-full ${view === 'logs' ? 'max-w-3xl' : 'max-w-md'} bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden`}>
|
|
345
392
|
|
|
346
393
|
{/* Header */}
|
|
347
394
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
|
|
@@ -359,21 +406,47 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
359
406
|
)}
|
|
360
407
|
</div>
|
|
361
408
|
|
|
362
|
-
{/* Body */}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
409
|
+
{/* Body — full log panel (montaj-native / SSE) or phase stepper (Hub) */}
|
|
410
|
+
{view === 'logs' ? (
|
|
411
|
+
<div className="relative">
|
|
412
|
+
<button
|
|
413
|
+
onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
|
|
414
|
+
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-border)] transition-colors"
|
|
415
|
+
title="Copy logs"
|
|
416
|
+
>
|
|
417
|
+
Copy
|
|
418
|
+
</button>
|
|
419
|
+
<div
|
|
420
|
+
ref={logRef}
|
|
421
|
+
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[var(--editor-text)]/80 bg-[var(--editor-surface)] flex flex-col gap-0.5"
|
|
422
|
+
>
|
|
423
|
+
{logs.length === 0 && status === 'running' && (
|
|
424
|
+
<span className="text-[var(--editor-text)]/40 italic">Starting render engine…</span>
|
|
425
|
+
)}
|
|
426
|
+
{logs.map((line, i) => (
|
|
427
|
+
<LogLine key={i} text={line} />
|
|
428
|
+
))}
|
|
429
|
+
{status === 'error' && errorMsg && (
|
|
430
|
+
<span className="text-red-400 mt-1">{errorMsg}</span>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
) : (
|
|
435
|
+
<div className="px-5 py-5">
|
|
436
|
+
{status === 'running' ? (
|
|
437
|
+
<>
|
|
438
|
+
<PhaseStepper current={phase} />
|
|
439
|
+
<p className="mt-5 text-xs text-[var(--editor-text)]/50">
|
|
440
|
+
This can take a few minutes for longer videos.
|
|
441
|
+
</p>
|
|
442
|
+
</>
|
|
443
|
+
) : (
|
|
444
|
+
<p className="text-sm text-red-400 whitespace-pre-wrap break-words">
|
|
445
|
+
{errorMsg ?? 'Render failed.'}
|
|
369
446
|
</p>
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
{errorMsg ?? 'Render failed.'}
|
|
374
|
-
</p>
|
|
375
|
-
)}
|
|
376
|
-
</div>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
377
450
|
|
|
378
451
|
{/* Footer */}
|
|
379
452
|
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
|
|
@@ -41,6 +41,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
41
41
|
slots,
|
|
42
42
|
onBackToSetup,
|
|
43
43
|
assetsPlacement = 'right',
|
|
44
|
+
renderProgressView = 'phases',
|
|
44
45
|
renderClipInspector,
|
|
45
46
|
renderSubcutRegen,
|
|
46
47
|
regenEnabled,
|
|
@@ -87,6 +88,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
87
88
|
onProjectChange={emit}
|
|
88
89
|
slots={slots}
|
|
89
90
|
assetsPlacement={assetsPlacement}
|
|
91
|
+
renderProgressView={renderProgressView}
|
|
90
92
|
getWaveformChunks={getWaveformChunks}
|
|
91
93
|
resolveFilePath={resolveFilePath}
|
|
92
94
|
save={save}
|
|
@@ -121,6 +123,7 @@ interface SurfaceProps<P extends Project> {
|
|
|
121
123
|
onProjectChange: (p: P) => void
|
|
122
124
|
slots?: VideoEditorProps<P>['slots']
|
|
123
125
|
assetsPlacement?: VideoEditorProps<P>['assetsPlacement']
|
|
126
|
+
renderProgressView?: VideoEditorProps<P>['renderProgressView']
|
|
124
127
|
getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
|
|
125
128
|
resolveFilePath: (path: string) => string
|
|
126
129
|
save: (p: P) => void
|
|
@@ -261,6 +264,7 @@ function ReviewSurface<P extends Project>({
|
|
|
261
264
|
onProjectChange,
|
|
262
265
|
slots,
|
|
263
266
|
assetsPlacement = 'right',
|
|
267
|
+
renderProgressView = 'phases',
|
|
264
268
|
getWaveformChunks,
|
|
265
269
|
resolveFilePath,
|
|
266
270
|
save,
|
|
@@ -615,6 +619,7 @@ function ReviewSurface<P extends Project>({
|
|
|
615
619
|
projectId={project.id}
|
|
616
620
|
adapter={adapter}
|
|
617
621
|
exportActions={slots?.exportActions}
|
|
622
|
+
progressView={renderProgressView}
|
|
618
623
|
onClose={() => setRenderOpen(false)}
|
|
619
624
|
onCancel={() => setRenderOpen(false)}
|
|
620
625
|
/>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { renderHook } from '@testing-library/react'
|
|
3
|
+
import { useItemDragDrop } from '../useItemDragDrop'
|
|
4
|
+
|
|
5
|
+
// A press that drifts < 4px must stay a CLICK (so the timeline's click-to-select
|
|
6
|
+
// fires); only a press that travels past the threshold becomes a drag. Regression
|
|
7
|
+
// guard for the bug where any pixel of jitter suppressed clip selection.
|
|
8
|
+
|
|
9
|
+
const RECT = {
|
|
10
|
+
width: 1000, height: 100, left: 0, top: 0, right: 1000, bottom: 100, x: 0, y: 0,
|
|
11
|
+
toJSON: () => ({}),
|
|
12
|
+
} as DOMRect
|
|
13
|
+
|
|
14
|
+
function setup() {
|
|
15
|
+
const draggedFlagRef = { current: false }
|
|
16
|
+
const scrollRef = { current: { getBoundingClientRect: () => RECT } }
|
|
17
|
+
const { result } = renderHook(() =>
|
|
18
|
+
useItemDragDrop({
|
|
19
|
+
totalDuration: 100,
|
|
20
|
+
snapBoundaries: [],
|
|
21
|
+
scrollRef: scrollRef as unknown as React.RefObject<HTMLDivElement>,
|
|
22
|
+
draggedFlagRef: draggedFlagRef as unknown as React.MutableRefObject<boolean>,
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
return { beginDrag: result.current.beginDrag, draggedFlagRef }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ITEM = { id: 'a', start: 10, end: 20 }
|
|
29
|
+
|
|
30
|
+
function press(beginDrag: ReturnType<typeof setup>['beginDrag']) {
|
|
31
|
+
const onLivePreview = vi.fn()
|
|
32
|
+
const onCommit = vi.fn()
|
|
33
|
+
beginDrag(
|
|
34
|
+
{ stopPropagation: vi.fn(), clientX: 200, clientY: 200 } as unknown as React.MouseEvent,
|
|
35
|
+
ITEM,
|
|
36
|
+
{ onLivePreview, onCommit },
|
|
37
|
+
)
|
|
38
|
+
return { onLivePreview, onCommit }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
// ensure no stray listeners between tests
|
|
43
|
+
document.dispatchEvent(new MouseEvent('mouseup'))
|
|
44
|
+
vi.restoreAllMocks()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('useItemDragDrop — click vs drag threshold', () => {
|
|
48
|
+
it('sub-threshold drift stays a click: no drag flag, no move, no commit', () => {
|
|
49
|
+
const { beginDrag, draggedFlagRef } = setup()
|
|
50
|
+
const { onLivePreview, onCommit } = press(beginDrag)
|
|
51
|
+
|
|
52
|
+
// ~2.2px of jitter (hypot(2,1)) — under the 4px threshold
|
|
53
|
+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 202, clientY: 201 }))
|
|
54
|
+
document.dispatchEvent(new MouseEvent('mouseup'))
|
|
55
|
+
|
|
56
|
+
expect(draggedFlagRef.current).toBe(false) // click-to-select is NOT suppressed
|
|
57
|
+
expect(onLivePreview).not.toHaveBeenCalled()
|
|
58
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('moving past the threshold becomes a drag: flag set, move + commit fire', () => {
|
|
62
|
+
const { beginDrag, draggedFlagRef } = setup()
|
|
63
|
+
const { onLivePreview, onCommit } = press(beginDrag)
|
|
64
|
+
|
|
65
|
+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 240, clientY: 200 })) // 40px → drag
|
|
66
|
+
expect(draggedFlagRef.current).toBe(true)
|
|
67
|
+
expect(onLivePreview).toHaveBeenCalled()
|
|
68
|
+
|
|
69
|
+
document.dispatchEvent(new MouseEvent('mouseup'))
|
|
70
|
+
expect(onCommit).toHaveBeenCalled()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -33,6 +33,13 @@ export interface UseItemDragDropConfig {
|
|
|
33
33
|
draggedFlagRef?: React.MutableRefObject<boolean> // sets true during drag (click suppression)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** A press must travel at least this many pixels before it becomes a drag.
|
|
37
|
+
* Below it the gesture stays a click. Without this threshold, a single pixel of
|
|
38
|
+
* pointer drift during a click flips the drag flag, and the timeline suppresses
|
|
39
|
+
* click-to-select — so clips/overlays can't be selected (or deleted), which
|
|
40
|
+
* bites constantly on trackpads and touchscreens where clicks always jitter. */
|
|
41
|
+
const DRAG_THRESHOLD_PX = 4
|
|
42
|
+
|
|
36
43
|
export function useItemDragDrop(config: UseItemDragDropConfig) {
|
|
37
44
|
const {
|
|
38
45
|
totalDuration,
|
|
@@ -133,8 +140,17 @@ export function useItemDragDrop(config: UseItemDragDropConfig) {
|
|
|
133
140
|
const initStart = item.start
|
|
134
141
|
const initEnd = item.end
|
|
135
142
|
const duration = initEnd - initStart
|
|
143
|
+
let dragStarted = false
|
|
136
144
|
|
|
137
145
|
function onMove(moveE: MouseEvent) {
|
|
146
|
+
// Ignore sub-threshold pointer drift — it's a click, not a drag. Only once
|
|
147
|
+
// the press travels past DRAG_THRESHOLD_PX do we set the drag flag (which
|
|
148
|
+
// suppresses click-to-select) and start moving the item.
|
|
149
|
+
if (!dragStarted) {
|
|
150
|
+
const moved = Math.hypot(moveE.clientX - initX, moveE.clientY - initY)
|
|
151
|
+
if (moved < DRAG_THRESHOLD_PX) return
|
|
152
|
+
dragStarted = true
|
|
153
|
+
}
|
|
138
154
|
if (draggedFlagRef) draggedFlagRef.current = true
|
|
139
155
|
|
|
140
156
|
const rect = scrollRef.current?.getBoundingClientRect()
|