@bycrux/editor 0.7.4 → 0.8.1

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.7.4",
3
+ "version": "0.8.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -29,6 +29,8 @@ export type {
29
29
  OverlayFactory,
30
30
  RenderEvent,
31
31
  RenderOptions,
32
+ RenderStatus,
33
+ RenderPhase,
32
34
  CaptionEvent,
33
35
  GenerateCaptionsOptions,
34
36
  MediaScope,
package/src/types.ts CHANGED
@@ -69,6 +69,31 @@ export interface RenderOptions {
69
69
  scale?: number
70
70
  }
71
71
 
72
+ /**
73
+ * Coarse phase of an async render pipeline. Ordered roughly by execution order;
74
+ * hosts may skip phases that don't apply to their pipeline.
75
+ */
76
+ export type RenderPhase = 'preparing' | 'rendering' | 'captions' | 'encoding' | 'saving' | 'done'
77
+
78
+ /**
79
+ * Point-in-time snapshot of an async render's progress. Returned by
80
+ * `EditorAdapter.getRenderStatus`; safe to poll on any cadence.
81
+ *
82
+ * - `'idle'` — no render has been kicked off (or results were cleared).
83
+ * - `'running'` — render is in progress; `phase` indicates where in the
84
+ * pipeline it currently is.
85
+ * - `'done'` — render completed successfully; `media` carries the promoted
86
+ * outputs.
87
+ * - `'error'` — render failed; `error` carries a human-readable message.
88
+ */
89
+ export interface RenderStatus {
90
+ status: 'idle' | 'running' | 'done' | 'error'
91
+ phase?: RenderPhase
92
+ /** Promoted render outputs as directly-fetchable (R2 presigned) media. */
93
+ media?: Array<{ id: string; filename: string; contentType: string; url: string }>
94
+ error?: string
95
+ }
96
+
72
97
  // ── Caption regeneration ─────────────────────────────────────────────────────
73
98
 
74
99
  /**
@@ -214,6 +239,22 @@ export interface EditorAdapter<P extends Project = Project> {
214
239
  */
215
240
  render(id: string, opts?: RenderOptions): AsyncIterable<RenderEvent>
216
241
 
242
+ /**
243
+ * Optional: kick an async render and return immediately. Hosts that support
244
+ * poll-based renders implement this; streaming-only hosts omit it. Poll
245
+ * `getRenderStatus` for progress and completion. Hosts without poll support
246
+ * omit this and the editor falls back to `render`.
247
+ */
248
+ renderAsync?(id: string, opts?: RenderOptions): Promise<{ status: string }>
249
+
250
+ /**
251
+ * Optional: poll the status of an async render kicked off by `renderAsync`.
252
+ * Safe to call at any cadence — returns the latest `RenderStatus` snapshot
253
+ * without side-effects. Hosts without poll support omit this; the editor
254
+ * feature-detects its absence and falls back to `render`.
255
+ */
256
+ getRenderStatus?(id: string): Promise<RenderStatus>
257
+
217
258
  /**
218
259
  * Resolve an `ImageElement` to a directly displayable URL. This is the host's
219
260
  * job because the resolution rule differs per host:
@@ -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, RenderPhase, RenderStatus } from '../types'
4
4
 
5
5
  interface RenderModalProps<P extends Project = Project> {
6
6
  projectId: string
@@ -19,37 +19,100 @@ interface RenderModalProps<P extends Project = Project> {
19
19
  exportActions?: ReactNode
20
20
  }
21
21
 
22
+ /** Promoted render output (R2-presigned), as carried by `RenderStatus.media`. */
23
+ type RenderMedia = NonNullable<RenderStatus['media']>[number]
24
+
22
25
  function basename(p: string) { return p.split('/').pop() ?? p }
23
26
 
24
- function LogLine({ text }: { text: string }) {
25
- const t = text.replace(/^\[montaj render\]\s*/, '')
26
- let color = 'text-[var(--editor-text)]/60'
27
- if (/ready|complete|done|encoded|assembled/i.test(t)) color = 'text-green-400'
28
- else if (/rendering|bundling|launching|browsers/i.test(t)) color = 'text-sky-400'
29
- else if (/trimming|building|composing/i.test(t)) color = 'text-amber-400'
30
- else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-[var(--editor-text)]/55'
31
- else if (/error|fail|warn/i.test(t)) color = 'text-red-400'
27
+ // ── Phase model (pure, exported for tests + the stepper) ──────────────────────
28
+
29
+ /**
30
+ * Ordered render phases, earliest → terminal. `phaseIndex` reads off this list
31
+ * so the stepper can mark phases before the current one as complete.
32
+ */
33
+ export const RENDER_PHASES: RenderPhase[] = [
34
+ 'preparing',
35
+ 'rendering',
36
+ 'captions',
37
+ 'encoding',
38
+ 'saving',
39
+ 'done',
40
+ ]
41
+
42
+ /** User-facing label for a render phase. Honest, plain-English, no jargon. */
43
+ export function phaseLabel(phase: RenderPhase): string {
44
+ switch (phase) {
45
+ case 'preparing': return 'Preparing'
46
+ case 'rendering': return 'Rendering graphics'
47
+ case 'captions': return 'Adding captions'
48
+ case 'encoding': return 'Encoding video'
49
+ case 'saving': return 'Saving to your library'
50
+ case 'done': return 'Done'
51
+ }
52
+ }
53
+
54
+ /** Ordinal of a phase within {@link RENDER_PHASES}. */
55
+ export function phaseIndex(phase: RenderPhase): number {
56
+ return RENDER_PHASES.indexOf(phase)
57
+ }
58
+
59
+ /** The five phases shown in the running stepper (terminal `done` excluded). */
60
+ const STEPPER_PHASES: RenderPhase[] = RENDER_PHASES.filter(p => p !== 'done')
32
61
 
33
- const prefix = text.startsWith('[montaj render]')
34
- ? <span className="text-[var(--editor-text)]/40">[render] </span>
35
- : null
62
+ const POLL_INTERVAL_MS = 2500
36
63
 
64
+ // ── Stepper ───────────────────────────────────────────────────────────────────
65
+
66
+ function PhaseStepper({ current }: { current: RenderPhase }) {
67
+ const currentIdx = phaseIndex(current)
37
68
  return (
38
- <span className={`leading-relaxed whitespace-pre-wrap break-all ${color}`}>
39
- {prefix}{t}
40
- </span>
69
+ <div className="flex flex-col gap-3">
70
+ {STEPPER_PHASES.map((phase) => {
71
+ const idx = phaseIndex(phase)
72
+ // `done` sits past every stepper phase, so a done status marks them all complete.
73
+ const complete = idx < currentIdx
74
+ const active = idx === currentIdx
75
+ return (
76
+ <div key={phase} className="flex items-center gap-3">
77
+ <span
78
+ className={
79
+ complete
80
+ ? 'w-5 h-5 shrink-0 rounded-full bg-green-500/90 text-black flex items-center justify-center text-[11px] font-bold'
81
+ : active
82
+ ? 'w-5 h-5 shrink-0 rounded-full border-2 border-amber-400 border-t-transparent animate-spin'
83
+ : 'w-5 h-5 shrink-0 rounded-full border border-[var(--editor-border)]'
84
+ }
85
+ >
86
+ {complete ? '✓' : ''}
87
+ </span>
88
+ <span
89
+ className={
90
+ complete
91
+ ? 'text-sm text-[var(--editor-text)]/70'
92
+ : active
93
+ ? 'text-sm font-semibold text-[var(--editor-text)]'
94
+ : 'text-sm text-[var(--editor-text)]/35'
95
+ }
96
+ >
97
+ {phaseLabel(phase)}
98
+ </span>
99
+ </div>
100
+ )
101
+ })}
102
+ </div>
41
103
  )
42
104
  }
43
105
 
44
106
  export default function RenderModal<P extends Project = Project>({ projectId, adapter, onClose, onCancel, exportActions }: RenderModalProps<P>) {
45
- const [logs, setLogs] = useState<string[]>([])
46
107
  const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
108
+ const [phase, setPhase] = useState<RenderPhase>('preparing')
109
+ const [media, setMedia] = useState<RenderMedia[] | null>(null)
47
110
  const [outputPath, setOutput] = useState<string | null>(null)
48
111
  const [errorMsg, setError] = useState<string | null>(null)
49
- const logRef = useRef<HTMLDivElement>(null)
50
112
  const cancelledRef = useRef(false)
51
113
  const unmountedRef = useRef(false)
52
114
  const cleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
115
+ const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
53
116
 
54
117
  useEffect(() => {
55
118
  // React StrictMode in dev fires mount → cleanup → mount synchronously to
@@ -72,18 +135,69 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
72
135
  unmountedRef.current = false
73
136
  cancelledRef.current = false
74
137
 
138
+ const usePolling = !!(adapter.renderAsync && adapter.getRenderStatus)
139
+
75
140
  void (async () => {
76
141
  try {
77
- for await (const ev of adapter.render(projectId)) {
78
- if (unmountedRef.current || cancelledRef.current) break
79
- if (ev.type === 'log') {
80
- setLogs(l => [...l, ev.message])
81
- } else if (ev.type === 'done') {
82
- setOutput(ev.outputPath)
83
- setStatus('done')
84
- } else {
85
- setError(ev.message)
86
- setStatus('error')
142
+ if (usePolling) {
143
+ // Async, poll-based render: kick once, then poll status until terminal.
144
+ await adapter.renderAsync!(projectId)
145
+ if (unmountedRef.current || cancelledRef.current) return
146
+
147
+ const tick = async () => {
148
+ if (unmountedRef.current || cancelledRef.current) return
149
+ let snap: RenderStatus
150
+ try {
151
+ snap = await adapter.getRenderStatus!(projectId)
152
+ } catch (e) {
153
+ if (unmountedRef.current || cancelledRef.current) return
154
+ stopPolling()
155
+ setError(e instanceof Error ? e.message : String(e))
156
+ setStatus('error')
157
+ return
158
+ }
159
+ if (unmountedRef.current || cancelledRef.current) return
160
+
161
+ if (snap.phase) setPhase(snap.phase)
162
+ if (snap.media) setMedia(snap.media)
163
+
164
+ if (snap.status === 'done') {
165
+ stopPolling()
166
+ setMedia(snap.media ?? null)
167
+ setStatus('done')
168
+ } else if (snap.status === 'error') {
169
+ stopPolling()
170
+ setError(snap.error ?? 'Render failed.')
171
+ setStatus('error')
172
+ } else if (snap.status === 'idle') {
173
+ // We just kicked the render, so a job exists server-side; an 'idle'
174
+ // reply means the sidecar lost it (e.g. restarted mid-render).
175
+ // Without this we'd poll forever with the stepper frozen.
176
+ stopPolling()
177
+ setError('The render was interrupted on the server. Please try again.')
178
+ setStatus('error')
179
+ }
180
+ }
181
+
182
+ // First poll immediately, then on the interval.
183
+ await tick()
184
+ if (unmountedRef.current || cancelledRef.current) return
185
+ pollTimerRef.current = setInterval(() => { void tick() }, POLL_INTERVAL_MS)
186
+ } else {
187
+ // Fallback for older hosts: consume the SSE render stream.
188
+ for await (const ev of adapter.render(projectId)) {
189
+ if (unmountedRef.current || cancelledRef.current) break
190
+ if (ev.type === 'log') {
191
+ // Streaming hosts have no phase signal — keep the stepper on
192
+ // "rendering" as an honest mid-pipeline indicator.
193
+ setPhase('rendering')
194
+ } else if (ev.type === 'done') {
195
+ setOutput(ev.outputPath)
196
+ setStatus('done')
197
+ } else {
198
+ setError(ev.message)
199
+ setStatus('error')
200
+ }
87
201
  }
88
202
  }
89
203
  } catch (e) {
@@ -96,23 +210,26 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
96
210
 
97
211
  return scheduleCleanup
98
212
 
213
+ function stopPolling() {
214
+ if (pollTimerRef.current !== null) {
215
+ clearInterval(pollTimerRef.current)
216
+ pollTimerRef.current = null
217
+ }
218
+ }
219
+
99
220
  function scheduleCleanup() {
100
221
  // Defer the actual teardown. StrictMode's transient unmount fires before
101
222
  // the next mount; setTimeout(0) puts the teardown after both, giving the
102
223
  // next mount a chance to clearTimeout it. On real unmount the timer fires
103
- // and the render stream is abandoned for real.
224
+ // and the render stream / poll loop is abandoned for real.
104
225
  cleanupTimerRef.current = setTimeout(() => {
105
226
  cleanupTimerRef.current = null
106
227
  unmountedRef.current = true
228
+ stopPolling()
107
229
  }, 0)
108
230
  }
109
231
  }, [projectId, adapter])
110
232
 
111
- // Auto-scroll logs
112
- useEffect(() => {
113
- if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
114
- }, [logs])
115
-
116
233
  // Escape to close only when done/error
117
234
  useEffect(() => {
118
235
  const onKey = (e: KeyboardEvent) => {
@@ -131,7 +248,13 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
131
248
  ;(onCancel ?? onClose)()
132
249
  }
133
250
 
134
- if (status === 'done' && outputPath) {
251
+ if (status === 'done') {
252
+ // Prefer the promoted R2 media (poll path); fall back to a workspace path
253
+ // resolved via fileUrl (SSE path); else show a graceful no-player view.
254
+ const primary = media?.[0] ?? null
255
+ const videoUrl = primary?.url ?? (outputPath ? adapter.fileUrl(outputPath) : null)
256
+ const downloadName = primary?.filename ?? (outputPath ? basename(outputPath) : 'render')
257
+
135
258
  // Portal to document.body: a transformed/filtered host ancestor (e.g. the
136
259
  // Los Parceros app-shell wrapper) would otherwise become the containing
137
260
  // block for this `fixed` overlay, sizing it to the scrolled page height and
@@ -142,13 +265,17 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
142
265
 
143
266
  {/* Left — video */}
144
267
  <div className="flex-1 bg-black flex items-center justify-center overflow-hidden">
145
- <video
146
- src={adapter.fileUrl(outputPath)}
147
- controls
148
- autoPlay
149
- playsInline
150
- className="h-full w-full object-contain"
151
- />
268
+ {videoUrl ? (
269
+ <video
270
+ src={videoUrl}
271
+ controls
272
+ autoPlay
273
+ playsInline
274
+ className="h-full w-full object-contain"
275
+ />
276
+ ) : (
277
+ <p className="text-sm text-[var(--editor-text)]/60">Render complete.</p>
278
+ )}
152
279
  </div>
153
280
 
154
281
  {/* Right — info panel */}
@@ -158,23 +285,24 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
158
285
  <span className="w-2 h-2 rounded-full bg-green-400" />
159
286
  <div>
160
287
  <p className="text-sm font-semibold text-[var(--editor-text)]">Render complete</p>
161
- <p className="text-xs text-[var(--editor-text)]/60">Your video is ready.</p>
288
+ <p className="text-xs text-[var(--editor-text)]/60">Saved to your library.</p>
162
289
  </div>
163
290
  </div>
164
291
  <button onClick={onClose} className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
165
292
  </div>
166
293
 
167
294
  <div className="flex flex-col gap-3 p-5 flex-1">
168
- <p className="text-xs font-mono text-[var(--editor-text)]/55 break-all leading-relaxed">{outputPath}</p>
169
295
  {/* Host-supplied export controls (e.g. download-all .zip). */}
170
296
  {exportActions}
171
- <a
172
- href={adapter.fileUrl(outputPath)}
173
- download={basename(outputPath)}
174
- 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"
175
- >
176
- Download
177
- </a>
297
+ {videoUrl && (
298
+ <a
299
+ href={videoUrl}
300
+ download={downloadName}
301
+ 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"
302
+ >
303
+ Download
304
+ </a>
305
+ )}
178
306
  <button
179
307
  onClick={onClose}
180
308
  className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:opacity-90 transition-colors"
@@ -191,7 +319,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
191
319
 
192
320
  return createPortal(
193
321
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
194
- <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">
322
+ <div className="w-full max-w-md bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden">
195
323
 
196
324
  {/* Header */}
197
325
  <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
@@ -209,29 +337,20 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
209
337
  )}
210
338
  </div>
211
339
 
212
- {/* Log output */}
213
- <div className="relative">
214
- <button
215
- onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
216
- 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"
217
- title="Copy logs"
218
- >
219
- Copy
220
- </button>
221
- <div
222
- ref={logRef}
223
- 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"
224
- >
225
- {logs.length === 0 && status === 'running' && (
226
- <span className="text-[var(--editor-text)]/40 italic">Starting render engine…</span>
227
- )}
228
- {logs.map((line, i) => (
229
- <LogLine key={i} text={line} />
230
- ))}
231
- {status === 'error' && errorMsg && (
232
- <span className="text-red-400 mt-1">{errorMsg}</span>
233
- )}
234
- </div>
340
+ {/* Body */}
341
+ <div className="px-5 py-5">
342
+ {status === 'running' ? (
343
+ <>
344
+ <PhaseStepper current={phase} />
345
+ <p className="mt-5 text-xs text-[var(--editor-text)]/50">
346
+ This can take a few minutes for longer videos.
347
+ </p>
348
+ </>
349
+ ) : (
350
+ <p className="text-sm text-red-400 whitespace-pre-wrap break-words">
351
+ {errorMsg ?? 'Render failed.'}
352
+ </p>
353
+ )}
235
354
  </div>
236
355
 
237
356
  {/* Footer */}
@@ -0,0 +1,194 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { render, screen, waitFor, act, cleanup } from '@testing-library/react'
3
+ import type { EditorAdapter, ImageElement, Project, RenderStatus } from '../../types'
4
+ import RenderModal, { phaseLabel, phaseIndex, RENDER_PHASES } from '../RenderModal'
5
+
6
+ afterEach(() => {
7
+ cleanup()
8
+ vi.useRealTimers()
9
+ })
10
+
11
+ // ── Pure helpers ──────────────────────────────────────────────────────────────
12
+
13
+ describe('phaseLabel', () => {
14
+ it('returns the exact user-facing label for each phase', () => {
15
+ expect(phaseLabel('preparing')).toBe('Preparing')
16
+ expect(phaseLabel('rendering')).toBe('Rendering graphics')
17
+ expect(phaseLabel('captions')).toBe('Adding captions')
18
+ expect(phaseLabel('encoding')).toBe('Encoding video')
19
+ expect(phaseLabel('saving')).toBe('Saving to your library')
20
+ expect(phaseLabel('done')).toBe('Done')
21
+ })
22
+ })
23
+
24
+ describe('phaseIndex / RENDER_PHASES', () => {
25
+ it('orders the phases preparing → done', () => {
26
+ expect(RENDER_PHASES).toEqual([
27
+ 'preparing',
28
+ 'rendering',
29
+ 'captions',
30
+ 'encoding',
31
+ 'saving',
32
+ 'done',
33
+ ])
34
+ })
35
+
36
+ it('returns the ordinal of each phase', () => {
37
+ expect(phaseIndex('preparing')).toBe(0)
38
+ expect(phaseIndex('rendering')).toBe(1)
39
+ expect(phaseIndex('captions')).toBe(2)
40
+ expect(phaseIndex('encoding')).toBe(3)
41
+ expect(phaseIndex('saving')).toBe(4)
42
+ expect(phaseIndex('done')).toBe(5)
43
+ })
44
+ })
45
+
46
+ // ── Component (poll-driven) ───────────────────────────────────────────────────
47
+
48
+ function baseAdapter(): EditorAdapter<Project> {
49
+ return {
50
+ loadProject: vi.fn(),
51
+ saveProject: vi.fn(),
52
+ subscribe: () => () => {},
53
+ render: async function* () {},
54
+ resolveImageSrc: (el: ImageElement) => el.src,
55
+ compileOverlay: vi.fn(async () => () => null),
56
+ listGlobalOverlays: vi.fn(async () => []),
57
+ listSystemOverlays: vi.fn(async () => []),
58
+ uploadFile: vi.fn(async () => ''),
59
+ fileUrl: (p: string) => `/files?path=${p}`,
60
+ } as unknown as EditorAdapter<Project>
61
+ }
62
+
63
+ const DONE_MEDIA = {
64
+ id: 'm1',
65
+ filename: 'x.mp4',
66
+ contentType: 'video/mp4',
67
+ url: 'https://r2/x.mp4',
68
+ }
69
+
70
+ describe('RenderModal (poll-driven)', () => {
71
+ it('advances the active phase label and shows the R2 video on done', async () => {
72
+ vi.useFakeTimers()
73
+
74
+ const sequence: RenderStatus[] = [
75
+ { status: 'running', phase: 'rendering' },
76
+ { status: 'running', phase: 'captions' },
77
+ { status: 'done', phase: 'done', media: [DONE_MEDIA] },
78
+ ]
79
+ let call = 0
80
+
81
+ const adapter = baseAdapter()
82
+ adapter.renderAsync = vi.fn(async () => ({ status: 'running' }))
83
+ adapter.getRenderStatus = vi.fn(async () => sequence[Math.min(call++, sequence.length - 1)])
84
+
85
+ render(
86
+ <RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />,
87
+ )
88
+
89
+ // Kick (renderAsync) + the immediate first poll resolve via microtasks → rendering.
90
+ await act(async () => {
91
+ for (let i = 0; i < 10; i++) await Promise.resolve()
92
+ })
93
+ expect(screen.getByText('Rendering graphics')).toBeTruthy()
94
+
95
+ // Next interval poll → captions.
96
+ await act(async () => {
97
+ await vi.advanceTimersByTimeAsync(2500)
98
+ })
99
+ expect(screen.getByText('Adding captions')).toBeTruthy()
100
+
101
+ // Next interval poll → done, video appears.
102
+ await act(async () => {
103
+ await vi.advanceTimersByTimeAsync(2500)
104
+ })
105
+
106
+ vi.useRealTimers()
107
+ await waitFor(() => {
108
+ const video = document.querySelector('video')
109
+ expect(video).toBeTruthy()
110
+ expect(video?.getAttribute('src')).toBe('https://r2/x.mp4')
111
+ })
112
+
113
+ const download = screen.getByText('Download').closest('a')
114
+ expect(download?.getAttribute('href')).toBe('https://r2/x.mp4')
115
+ expect(download?.getAttribute('download')).toBe('x.mp4')
116
+ })
117
+
118
+ it('never renders raw-log or Copy UI', async () => {
119
+ vi.useFakeTimers()
120
+ const adapter = baseAdapter()
121
+ adapter.renderAsync = vi.fn(async () => ({ status: 'running' }))
122
+ adapter.getRenderStatus = vi.fn(async () => ({
123
+ status: 'running',
124
+ phase: 'rendering',
125
+ } as RenderStatus))
126
+
127
+ render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
128
+
129
+ await act(async () => {
130
+ await Promise.resolve()
131
+ await vi.advanceTimersByTimeAsync(2500)
132
+ })
133
+
134
+ expect(screen.queryByText('Copy')).toBeNull()
135
+ expect(screen.queryByTitle('Copy logs')).toBeNull()
136
+ })
137
+
138
+ it('shows the error string and a Close button on error', async () => {
139
+ vi.useFakeTimers()
140
+ const adapter = baseAdapter()
141
+ adapter.renderAsync = vi.fn(async () => ({ status: 'running' }))
142
+ adapter.getRenderStatus = vi.fn(async () => ({
143
+ status: 'error',
144
+ error: 'sidecar exploded',
145
+ } as RenderStatus))
146
+
147
+ render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
148
+
149
+ await act(async () => {
150
+ await Promise.resolve()
151
+ await vi.advanceTimersByTimeAsync(2500)
152
+ })
153
+
154
+ expect(screen.getByText('sidecar exploded')).toBeTruthy()
155
+ expect(screen.getByText('Close')).toBeTruthy()
156
+ })
157
+
158
+ it('surfaces an interrupted render when the server reports idle after kicking', async () => {
159
+ vi.useFakeTimers()
160
+ const adapter = baseAdapter()
161
+ adapter.renderAsync = vi.fn(async () => ({ status: 'running' }))
162
+ // Job lost server-side (e.g. sidecar restart mid-render) → status flips to idle.
163
+ adapter.getRenderStatus = vi.fn(async () => ({ status: 'idle' } as RenderStatus))
164
+
165
+ render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
166
+
167
+ await act(async () => {
168
+ await Promise.resolve()
169
+ await vi.advanceTimersByTimeAsync(2500)
170
+ })
171
+
172
+ expect(screen.getByText(/interrupted on the server/i)).toBeTruthy()
173
+ expect(screen.getByText('Close')).toBeTruthy()
174
+ })
175
+ })
176
+
177
+ // ── Component (SSE fallback when poll methods absent) ──────────────────────────
178
+
179
+ describe('RenderModal (SSE fallback)', () => {
180
+ it('falls back to render() when poll methods are absent', async () => {
181
+ const adapter = baseAdapter()
182
+ adapter.render = async function* () {
183
+ yield { type: 'done' as const, outputPath: '/out/final.mp4' }
184
+ }
185
+
186
+ render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
187
+
188
+ await waitFor(() => {
189
+ const video = document.querySelector('video')
190
+ expect(video).toBeTruthy()
191
+ expect(video?.getAttribute('src')).toBe('/files?path=/out/final.mp4')
192
+ })
193
+ })
194
+ })