Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions src/timeline/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<index>-<clip>`), 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
Expand Down Expand Up @@ -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 — `<tag>-` 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`);
}

/**
Expand All @@ -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[] = [];

Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions src/ui/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,18 +237,24 @@ export async function startUiServer(options: UiServerOptions = {}): Promise<UiSe
const atSec = Number(c.req.query('at') ?? '0');
if (Number.isNaN(atSec)) return c.json({ error: 'at must be a number' }, 400);
const output = newOutputPath('timeline-frame', 'jpg');
// Per-request tag so this preview's intermediate segments can't collide with
// a concurrent export's — both run unsynchronized `ffmpeg -y` in the workspace.
const tag = `frame-${randomBytes(4).toString('hex')}`;
try {
const comp = await readComposition();
const media = await buildMediaMap();
const plan = buildFrameAtPlan(comp, { media, dir: getWorkspace(), output }, atSec);
const plan = buildFrameAtPlan(comp, { media, dir: getWorkspace(), output, tag }, atSec);
const result = await runPlan(plan);
const jpeg = await readFile(result.output);
await unlink(result.output).catch(() => {}); // transient preview frame
return c.body(jpeg, 200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'no-store' });
} catch (err) {
await unlink(output).catch(() => {});
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);
}
});

Expand All @@ -272,7 +278,9 @@ export async function startUiServer(options: UiServerOptions = {}): Promise<UiSe
return c.json({ id: entry.id, output: result.output, durationSec: plan.durationSec });
} catch (err) {
if (err instanceof CompileError) return c.json({ error: err.message }, 422);
throw err;
// A render failure (not a bad document) — clean retryable error, not a raw 500.
const message = err instanceof Error ? err.message : String(err);
return c.json({ error: message }, 503);
}
});

Expand Down
59 changes: 48 additions & 11 deletions src/ui/web/src/components/Viewer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';

interface Exportable {
exportable: boolean;
blockers: string[];
}

type FrameState =
| { kind: 'loading' }
| { kind: 'ok'; url: string }
| { kind: 'error'; message: string };

/**
* The monitor: the COMPOSITED frame at the playhead, rendered server-side through
* the real export compiler (GET /api/timeline/frame) so the preview can't diverge
Expand All @@ -13,8 +18,9 @@ interface Exportable {
*/
export function Viewer({ atSec, rev }: { atSec: number; rev: number }) {
const [debouncedAt, setDebouncedAt] = useState(atSec);
const [erroredSrc, setErroredSrc] = useState<string | null>(null);
const [status, setStatus] = useState<Exportable | null>(null);
const [frame, setFrame] = useState<FrameState>({ kind: 'loading' });
const liveUrl = useRef<string | null>(null);

useEffect(() => {
const t = setTimeout(() => setDebouncedAt(atSec), 140);
Expand All @@ -37,27 +43,58 @@ export function Viewer({ atSec, rev }: { atSec: number; rev: number }) {
};
}, [rev]);

// Fetch the composited frame as a blob (not via <img src>) 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 (
<div className="viewer">
<div className="viewer-stage">
{notRenderable ? (
<div className="viewer-msg">Not renderable yet — see the reason below.</div>
) : frameError ? (
<div className="viewer-msg">No frame at this point.</div>
) : (
) : frame.kind === 'error' ? (
<div className="viewer-msg">{frame.message}</div>
) : frame.kind === 'ok' ? (
<img
key={src}
className="viewer-frame"
src={src}
src={frame.url}
alt={`Composited frame at ${debouncedAt.toFixed(2)} seconds`}
onError={() => setErroredSrc(src)}
/>
) : (
<div className="viewer-msg">Rendering…</div>
)}
</div>
<div className="viewer-bar">
Expand Down
11 changes: 11 additions & 0 deletions tests/timeline-introspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(), [
{
Expand Down
Loading