From 7407b708319a4c788deb58b76d67fd4bc34a72e8 Mon Sep 17 00:00:00 2001 From: Slava Yultyyev Date: Thu, 18 Jun 2026 16:39:24 -0700 Subject: [PATCH 1/2] fix(timeline): give the frame plan collision-free intermediate paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intermediate segment/text/fold filenames were deterministic (tl-seg--), so a preview frame render and a concurrent export wrote and ffmpeg -y-overwrote the SAME files — and each unlinked the other's in run-plan's finally — yielding a transient garbled frame or 500. The timeline doc (source of truth) was never touched, so it self-healed on retry, but it is a real race on the unauthenticated localhost dev server. Add an optional tag to CompileContext, mixed into the intermediate names. Export stays deterministic (no tag); the clip ui sets a per-request tag on each frame render (next commit), so previews get their own paths and cannot collide. Default (untagged) paths are unchanged — existing plans/tests are untouched. --- src/timeline/compile.ts | 26 +++++++++++++++++++++----- tests/timeline-introspect.test.ts | 11 +++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/timeline/compile.ts b/src/timeline/compile.ts index 6392b16..ed7d632 100644 --- a/src/timeline/compile.ts +++ b/src/timeline/compile.ts @@ -49,6 +49,14 @@ export interface CompileContext { dir: string; /** Final output file path. */ output: string; + /** + * Optional token mixed into intermediate (segment / text / fold) filenames. + * Those names are otherwise deterministic (`tl-seg--`), so a + * preview render and a concurrent export would write and `-y`-overwrite the + * SAME files and unlink each other's. A per-request tag (the `clip ui` sets + * one per frame request) gives the preview its own collision-free paths. + */ + tag?: string; } /** A side file (concat list / drawtext text) the runner must write before @@ -233,8 +241,14 @@ const SEG_PREFIX = 'tl-seg'; const FOLD_PREFIX = 'tl-fold'; const TEXT_PREFIX = 'tl-text'; -function segPath(dir: string, index: number, clipId: string): string { - return resolve(dir, `${SEG_PREFIX}-${index}-${clipId}.mp4`); +/** A disambiguating infix for intermediate filenames — `-` when a tag is + * set (a preview's collision-free paths), empty otherwise (export's stable ones). */ +function tagInfix(tag: string | undefined): string { + return tag ? `${tag}-` : ''; +} + +function segPath(dir: string, index: number, clipId: string, tag?: string): string { + return resolve(dir, `${SEG_PREFIX}-${tagInfix(tag)}${index}-${clipId}.mp4`); } /** @@ -252,7 +266,7 @@ function buildSegmentStep( assertCompilable(clip); const outDur = outputDuration(clip); const { width, height, fps, background } = comp; - const out = segPath(ctx.dir, index, clip.id); + const out = segPath(ctx.dir, index, clip.id, ctx.tag); const { video: fx, audio: afx } = effectFilters(clip, outDur, fadeSuppress); const textFiles: StepSideFile[] = []; @@ -309,7 +323,7 @@ function buildSegmentStep( ); videoSource = '[0:v]'; audioSource = '1:a'; - const textfile = resolve(ctx.dir, `${TEXT_PREFIX}-${index}-${clip.id}.txt`); + const textfile = resolve(ctx.dir, `${TEXT_PREFIX}-${tagInfix(ctx.tag)}${index}-${clip.id}.txt`); textFiles.push({ path: textfile, content: clip.text }); fx.unshift(drawtextFilter(textfile, clip.style)); } @@ -550,7 +564,9 @@ export function compileTimeline(comp: Composition, ctx: CompileContext): FfmpegP const prevClip = segments[i - 1]?.clip; const transition = prevClip ? transitionAfter.get(prevClip.id) : undefined; const isLast = i === segments.length - 1; - const out = isLast ? ctx.output : resolve(ctx.dir, `${FOLD_PREFIX}-${i}.mp4`); + const out = isLast + ? ctx.output + : resolve(ctx.dir, `${FOLD_PREFIX}-${tagInfix(ctx.tag)}${i}.mp4`); if (transition) { // A transition must fit inside BOTH sides: the accumulated A (offset ≥ 0) diff --git a/tests/timeline-introspect.test.ts b/tests/timeline-introspect.test.ts index cb7bbbc..6be22a3 100644 --- a/tests/timeline-introspect.test.ts +++ b/tests/timeline-introspect.test.ts @@ -79,6 +79,17 @@ describe('buildFrameAtPlan', () => { expect(frame?.args.at(-1)).toBe('/ws/frame.jpg'); }); + it('a tagged context gives the segment a collision-free path; untagged stays stable', () => { + const segOut = (tag?: string) => + buildFrameAtPlan(oneClip(), { ...CTX('/ws/frame.jpg'), tag }, 3).steps[0]?.output; + // Untagged keeps the deterministic name export also uses. + expect(segOut()).toBe('/ws/tl-seg-0-c1.mp4'); + // A tag is mixed in, so a preview can't overwrite/unlink an export's segment… + expect(segOut('frame-abc123')).toBe('/ws/tl-seg-frame-abc123-0-c1.mp4'); + // …and two different preview requests never share a segment path. + expect(segOut('frame-aaa')).not.toBe(segOut('frame-bbb')); + }); + it('selects the second clip and offsets into it for a time in the second clip', () => { const doc = applyOps(oneClip(), [ { From 1363832a6e3f9bce4011499a522f499367f8f992 Mon Sep 17 00:00:00 2001 From: Slava Yultyyev Date: Thu, 18 Jun 2026 16:39:24 -0700 Subject: [PATCH 2/2] fix(ui): isolate preview renders + degrade render failures gracefully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The frame route passes a per-request tag to buildFrameAtPlan, so a preview's intermediate segments never collide with a concurrent export's. - Frame and export routes translate a non-CompileError (a transient FFmpeg failure) into a clean 503 instead of a raw 500. - Viewer fetches the frame as a blob behind an AbortController, so a superseded scrub is cancelled instead of left rendering, and a 503 reads as a message rather than a broken img. The previous frame stays on screen until the next decodes — no blank flash. --- src/ui/server.ts | 14 +++++-- src/ui/web/src/components/Viewer.tsx | 59 ++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/ui/server.ts b/src/ui/server.ts index 9af6d10..b2bb45c 100644 --- a/src/ui/server.ts +++ b/src/ui/server.ts @@ -237,10 +237,13 @@ export async function startUiServer(options: UiServerOptions = {}): Promise {}); // transient preview frame @@ -248,7 +251,10 @@ export async function startUiServer(options: UiServerOptions = {}): Promise {}); if (err instanceof CompileError) return c.json({ error: err.message }, 422); - throw err; + // Not a document problem — a transient render failure (e.g. an FFmpeg + // hiccup). Surface it as retryable instead of leaking a raw 500. + const message = err instanceof Error ? err.message : String(err); + return c.json({ error: message }, 503); } }); @@ -272,7 +278,9 @@ export async function startUiServer(options: UiServerOptions = {}): Promise(null); const [status, setStatus] = useState(null); + const [frame, setFrame] = useState({ kind: 'loading' }); + const liveUrl = useRef(null); useEffect(() => { const t = setTimeout(() => setDebouncedAt(atSec), 140); @@ -37,10 +43,41 @@ export function Viewer({ atSec, rev }: { atSec: number; rev: number }) { }; }, [rev]); + // Fetch the composited frame as a blob (not via ) so a superseded + // scrub is ABORTED instead of left rendering, and a transient 503 reads as a + // clean message rather than a broken image. The previous frame stays on screen + // until the next one decodes — no blank flash while FFmpeg works. const src = `/api/timeline/frame?at=${debouncedAt.toFixed(2)}&rev=${rev}`; - // The error is tied to the exact src that failed, so it clears itself the - // moment the playhead or rev produces a new src — no reset effect needed. - const frameError = erroredSrc === src; + useEffect(() => { + const controller = new AbortController(); + fetch(src, { signal: controller.signal }) + .then(async (res) => { + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null; + setFrame({ kind: 'error', message: body?.error ?? 'Could not render this frame.' }); + return; + } + const url = URL.createObjectURL(await res.blob()); + if (liveUrl.current) URL.revokeObjectURL(liveUrl.current); + liveUrl.current = url; + setFrame({ kind: 'ok', url }); + }) + .catch((err: unknown) => { + // A superseded scrub aborts in flight — ignore it; the next one wins. + if (err instanceof DOMException && err.name === 'AbortError') return; + setFrame({ kind: 'error', message: 'Could not reach the renderer.' }); + }); + return () => controller.abort(); + }, [src]); + + // Release the last blob URL on unmount. + useEffect( + () => () => { + if (liveUrl.current) URL.revokeObjectURL(liveUrl.current); + }, + [], + ); + const notRenderable = status !== null && !status.exportable; return ( @@ -48,16 +85,16 @@ export function Viewer({ atSec, rev }: { atSec: number; rev: number }) {
{notRenderable ? (
Not renderable yet — see the reason below.
- ) : frameError ? ( -
No frame at this point.
- ) : ( + ) : frame.kind === 'error' ? ( +
{frame.message}
+ ) : frame.kind === 'ok' ? ( {`Composited setErroredSrc(src)} /> + ) : ( +
Rendering…
)}