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/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…
)}
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(), [ {