@bycrux/editor 0.8.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- ;(async () => {
109
+
110
+ void (async () => {
69
111
  try {
70
- for await (const ev of adapter.render(projectId)) {
71
- if (cancelledRef.current || unmountedRef.current) break
72
- if (ev.type === 'log') setLogs(l => [...l, ev.message])
73
- else if (ev.type === 'done') { setOutDir(ev.outputPath); setStatus('done') }
74
- else if (ev.type === 'error') { setError(ev.message); setStatus('error') }
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)); setStatus('error')
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' && outputDir) {
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
- <div
121
- className="grid gap-4 w-full max-w-6xl"
122
- style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}
123
- >
124
- {Array.from({ length: slidesCount }).map((_, i) => {
125
- const file = slideFile(i)
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={i}
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={file}
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">{file}</span>
264
+ <span className="text-[var(--editor-text)]/60">{slide.filename}</span>
144
265
  </div>
145
266
  </a>
146
- )
147
- })}
148
- </div>
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 — log readout ─────────────────────────────────
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
- <div className="relative">
204
- <button
205
- onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
206
- 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"
207
- title="Copy logs"
208
- >
209
- Copy
210
- </button>
211
- <div
212
- ref={logRef}
213
- 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"
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
- </div>
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' ? (