@bycrux/editor 0.8.3 → 0.8.5
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
|
@@ -61,6 +61,16 @@ const STEPPER_PHASES: RenderPhase[] = RENDER_PHASES.filter(p => p !== 'done')
|
|
|
61
61
|
|
|
62
62
|
const POLL_INTERVAL_MS = 2500
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Consecutive status-poll failures tolerated before the modal declares the
|
|
66
|
+
* render failed. A multi-minute 4K render polls many times; a transient network
|
|
67
|
+
* blip (a Cloudflare-tunnel hiccup, a backend redeploy, a flaky hop) must not be
|
|
68
|
+
* mistaken for a render failure — the render keeps running server-side. ~12 ×
|
|
69
|
+
* 2.5s ≈ 30s of continuous unreachability before giving up. An explicit backend
|
|
70
|
+
* `error` status is still terminal immediately.
|
|
71
|
+
*/
|
|
72
|
+
const MAX_POLL_FAILURES = 12
|
|
73
|
+
|
|
64
74
|
// ── Stepper ───────────────────────────────────────────────────────────────────
|
|
65
75
|
|
|
66
76
|
function PhaseStepper({ current }: { current: RenderPhase }) {
|
|
@@ -144,16 +154,28 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
144
154
|
await adapter.renderAsync!(projectId)
|
|
145
155
|
if (unmountedRef.current || cancelledRef.current) return
|
|
146
156
|
|
|
157
|
+
let pollFailures = 0
|
|
147
158
|
const tick = async () => {
|
|
148
159
|
if (unmountedRef.current || cancelledRef.current) return
|
|
149
160
|
let snap: RenderStatus
|
|
150
161
|
try {
|
|
151
162
|
snap = await adapter.getRenderStatus!(projectId)
|
|
163
|
+
pollFailures = 0
|
|
152
164
|
} catch (e) {
|
|
153
165
|
if (unmountedRef.current || cancelledRef.current) return
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
166
|
+
// A transient network blip (Cloudflare-tunnel hiccup, a backend
|
|
167
|
+
// redeploy, a flaky hop) must NOT be mistaken for a render
|
|
168
|
+
// failure — the render is still running server-side. This is what
|
|
169
|
+
// surfaced as "Render failed: Failed to fetch" while the render
|
|
170
|
+
// actually completed and was delivered. Keep polling; only give
|
|
171
|
+
// up after MAX_POLL_FAILURES consecutive failures. An explicit
|
|
172
|
+
// { status: 'error' } reply is still terminal (handled below).
|
|
173
|
+
pollFailures += 1
|
|
174
|
+
if (pollFailures >= MAX_POLL_FAILURES) {
|
|
175
|
+
stopPolling()
|
|
176
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
177
|
+
setStatus('error')
|
|
178
|
+
}
|
|
157
179
|
return
|
|
158
180
|
}
|
|
159
181
|
if (unmountedRef.current || cancelledRef.current) return
|
|
@@ -172,6 +172,56 @@ describe('RenderModal (poll-driven)', () => {
|
|
|
172
172
|
expect(screen.getByText(/interrupted on the server/i)).toBeTruthy()
|
|
173
173
|
expect(screen.getByText('Close')).toBeTruthy()
|
|
174
174
|
})
|
|
175
|
+
|
|
176
|
+
it('tolerates a transient poll failure and still completes', async () => {
|
|
177
|
+
vi.useFakeTimers()
|
|
178
|
+
// poll #1 throws (network blip), then it recovers and finishes. The modal
|
|
179
|
+
// must NOT flip to "Render failed" on the transient throw — the render is
|
|
180
|
+
// still running server-side.
|
|
181
|
+
const steps: Array<() => RenderStatus> = [
|
|
182
|
+
() => { throw new Error('Failed to fetch') },
|
|
183
|
+
() => ({ status: 'running', phase: 'rendering' } as RenderStatus),
|
|
184
|
+
() => ({ status: 'done', phase: 'done', media: [DONE_MEDIA] } as RenderStatus),
|
|
185
|
+
]
|
|
186
|
+
let call = 0
|
|
187
|
+
const adapter = baseAdapter()
|
|
188
|
+
adapter.renderAsync = vi.fn(async () => ({ status: 'running' }))
|
|
189
|
+
adapter.getRenderStatus = vi.fn(async () => steps[Math.min(call++, steps.length - 1)]())
|
|
190
|
+
|
|
191
|
+
render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
|
|
192
|
+
|
|
193
|
+
// Kick + the first poll (which throws) — still "Rendering…", not failed.
|
|
194
|
+
await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve() })
|
|
195
|
+
expect(screen.queryByText('Render failed')).toBeNull()
|
|
196
|
+
expect(screen.getByText('Rendering…')).toBeTruthy()
|
|
197
|
+
|
|
198
|
+
// Subsequent polls recover → running → done.
|
|
199
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(2500) })
|
|
200
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(2500) })
|
|
201
|
+
|
|
202
|
+
vi.useRealTimers()
|
|
203
|
+
await waitFor(() => {
|
|
204
|
+
expect(document.querySelector('video')?.getAttribute('src')).toBe('https://r2/x.mp4')
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('gives up only after sustained consecutive poll failures', async () => {
|
|
209
|
+
vi.useFakeTimers()
|
|
210
|
+
const adapter = baseAdapter()
|
|
211
|
+
adapter.renderAsync = vi.fn(async () => ({ status: 'running' }))
|
|
212
|
+
adapter.getRenderStatus = vi.fn(async () => { throw new Error('Failed to fetch') })
|
|
213
|
+
|
|
214
|
+
render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
|
|
215
|
+
|
|
216
|
+
// First poll throws — still running, NOT failed (one blip is tolerated).
|
|
217
|
+
await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve() })
|
|
218
|
+
expect(screen.queryByText('Render failed')).toBeNull()
|
|
219
|
+
|
|
220
|
+
// Keep failing past MAX_POLL_FAILURES (~12 × 2.5s) → terminal error.
|
|
221
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(2500 * 15) })
|
|
222
|
+
expect(screen.getByText('Render failed')).toBeTruthy()
|
|
223
|
+
expect(screen.getByText('Failed to fetch')).toBeTruthy()
|
|
224
|
+
})
|
|
175
225
|
})
|
|
176
226
|
|
|
177
227
|
// ── Component (SSE fallback when poll methods absent) ──────────────────────────
|
|
@@ -84,6 +84,31 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted,
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
// ---------------------------------------------------------------------------
|
|
87
|
+
// Recursively rewrite absolute workspace path strings in overlay props to
|
|
88
|
+
// servable proxy URLs (via fileUrl), so <img src> resolves in the browser
|
|
89
|
+
// preview. Mirrors the render-side rewritePathsToFileUrls (bundle.js), which
|
|
90
|
+
// already recurses — without this, image paths nested in array/object props
|
|
91
|
+
// (e.g. players[].src, items[].src) reach <img> raw as /var/hub-scratch/... and
|
|
92
|
+
// render blank in preview even though they render correctly in the final MP4.
|
|
93
|
+
// Already-proxied /api/ URLs are left untouched (idempotent).
|
|
94
|
+
export function resolveOverlayPropPaths(
|
|
95
|
+
value: unknown,
|
|
96
|
+
fileUrl: (path: string) => string,
|
|
97
|
+
): unknown {
|
|
98
|
+
if (typeof value === 'string') {
|
|
99
|
+
return value.startsWith('/') && !value.startsWith('/api/') ? fileUrl(value) : value
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
return value.map((v) => resolveOverlayPropPaths(v, fileUrl))
|
|
103
|
+
}
|
|
104
|
+
if (value && typeof value === 'object') {
|
|
105
|
+
return Object.fromEntries(
|
|
106
|
+
Object.entries(value).map(([k, v]) => [k, resolveOverlayPropPaths(v, fileUrl)]),
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
return value
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
// CustomOverlay: fetches, compiles, and renders a custom JSX overlay file
|
|
88
113
|
//
|
|
89
114
|
// Live overlay-edit reload behavior (Montaj host):
|
|
@@ -158,14 +183,7 @@ function CustomOverlay({
|
|
|
158
183
|
|
|
159
184
|
if (!factory) return null
|
|
160
185
|
|
|
161
|
-
const resolvedProps =
|
|
162
|
-
Object.entries(props).map(([k, v]) => [
|
|
163
|
-
k,
|
|
164
|
-
typeof v === 'string' && v.startsWith('/') && !v.startsWith('/api/')
|
|
165
|
-
? fileUrl(v)
|
|
166
|
-
: v,
|
|
167
|
-
]),
|
|
168
|
-
)
|
|
186
|
+
const resolvedProps = resolveOverlayPropPaths(props, fileUrl) as Record<string, unknown>
|
|
169
187
|
|
|
170
188
|
const element = factory(frame, fps, durationFrames, resolvedProps)
|
|
171
189
|
if (!element) return null
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { resolveOverlayPropPaths } from '../OverlayItemsLayer'
|
|
3
|
+
|
|
4
|
+
// Stand-in for the Hub adapter's fileUrl: workspace path → servable proxy URL.
|
|
5
|
+
const fileUrl = (p: string) => `/api/hub/montaj/files?path=${encodeURIComponent(p)}`
|
|
6
|
+
|
|
7
|
+
describe('resolveOverlayPropPaths', () => {
|
|
8
|
+
it('rewrites a top-level workspace path string (photo_card.src case)', () => {
|
|
9
|
+
const out = resolveOverlayPropPaths(
|
|
10
|
+
{ src: '/var/hub-scratch/p1/assets/a.jpg', text: 'hi' },
|
|
11
|
+
fileUrl,
|
|
12
|
+
)
|
|
13
|
+
expect(out).toEqual({
|
|
14
|
+
src: '/api/hub/montaj/files?path=' + encodeURIComponent('/var/hub-scratch/p1/assets/a.jpg'),
|
|
15
|
+
text: 'hi',
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('rewrites workspace paths nested in an array of objects (player_trio.players case)', () => {
|
|
20
|
+
const out = resolveOverlayPropPaths(
|
|
21
|
+
{
|
|
22
|
+
players: [
|
|
23
|
+
{ src: '/var/hub-scratch/p1/assets/a.jpg', name: 'A' },
|
|
24
|
+
{ src: '/var/hub-scratch/p1/assets/b.jpg', name: 'B' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
fileUrl,
|
|
28
|
+
) as { players: Array<{ src: string; name: string }> }
|
|
29
|
+
expect(out.players[0].src).toBe(fileUrl('/var/hub-scratch/p1/assets/a.jpg'))
|
|
30
|
+
expect(out.players[1].src).toBe(fileUrl('/var/hub-scratch/p1/assets/b.jpg'))
|
|
31
|
+
expect(out.players[0].name).toBe('A')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('leaves already-proxied /api/ URLs and remote URLs untouched (idempotent)', () => {
|
|
35
|
+
const proxied = '/api/hub/montaj/files?path=%2Fx.jpg'
|
|
36
|
+
const remote = 'https://cdn.example.com/x.jpg'
|
|
37
|
+
const out = resolveOverlayPropPaths(
|
|
38
|
+
{ items: [{ src: proxied }, { src: remote }] },
|
|
39
|
+
fileUrl,
|
|
40
|
+
) as { items: Array<{ src: string }> }
|
|
41
|
+
expect(out.items[0].src).toBe(proxied)
|
|
42
|
+
expect(out.items[1].src).toBe(remote)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('preserves non-string scalars and nesting depth', () => {
|
|
46
|
+
const out = resolveOverlayPropPaths(
|
|
47
|
+
{ duration: 90, nested: { deep: [{ src: '/var/x.png' }] } },
|
|
48
|
+
fileUrl,
|
|
49
|
+
) as { duration: number; nested: { deep: Array<{ src: string }> } }
|
|
50
|
+
expect(out.duration).toBe(90)
|
|
51
|
+
expect(out.nested.deep[0].src).toBe(fileUrl('/var/x.png'))
|
|
52
|
+
})
|
|
53
|
+
})
|