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
32 changes: 32 additions & 0 deletions src/ffmpeg/args/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,35 @@ export function buildPreviewFrameArgs({ input, output, atSec }: PreviewFrameArgs
// -an skips audio decoding — irrelevant for a still frame.
return ['-y', '-ss', String(atSec), '-i', input, '-vframes', '1', '-q:v', '2', '-an', output];
}

export interface ThumbnailArgs {
input: string;
output: string;
/** Source timecode (seconds) to grab the thumbnail from. */
atSec: number;
/** Target height in px; width follows the source aspect (even, for yuv420p). */
height: number;
}

/**
* A small, scaled still for the timeline filmstrip. Same fast input-seek as the
* preview frame, but scaled down (`-2` keeps the aspect with an even width) and
* a slightly cheaper JPEG — these are tiny and there are many per clip.
*/
export function buildThumbnailArgs({ input, output, atSec, height }: ThumbnailArgs): string[] {
return [
'-y',
'-ss',
String(atSec),
'-i',
input,
'-vframes',
'1',
'-vf',
`scale=-2:${height}`,
'-q:v',
'4',
'-an',
output,
];
}
50 changes: 49 additions & 1 deletion src/ui/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomBytes } from 'node:crypto';
import { createWriteStream, existsSync } from 'node:fs';
import { mkdir, readdir, readFile, stat, unlink } from 'node:fs/promises';
import { mkdir, readdir, readFile, rename, stat, unlink } from 'node:fs/promises';
import { basename, dirname, resolve } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
Expand All @@ -11,7 +11,9 @@ import getPort from 'get-port';
import { Hono } from 'hono';
import open from 'open';
import { ZodError, z } from 'zod';
import { buildThumbnailArgs } from '../ffmpeg/args/preview.js';
import { probe } from '../ffmpeg/probe.js';
import { runFfmpeg } from '../ffmpeg/run.js';
import { appendOp, readSession, SessionCorruptError, snapshotsDir } from '../session/store.js';
import type { SessionEntry } from '../session/types.js';
import {
Expand All @@ -31,6 +33,7 @@ import {
import { buildMediaMap } from '../timeline/media-registry.js';
import { CompositionOpError } from '../timeline/ops.js';
import { runPlan } from '../timeline/run-plan.js';
import type { MediaId } from '../timeline/schema.js';
import { CompositionVerbSchema } from '../timeline/verbs.js';
import { ingest } from '../tools/ingest.js';
import { preview } from '../tools/preview.js';
Expand Down Expand Up @@ -252,6 +255,51 @@ export async function startUiServer(options: UiServerOptions = {}): Promise<UiSe
}
});

/**
* GET /api/media/thumb?mediaId=&t=&h= — a single small still from a SOURCE media
* file (not the composited timeline), for the timeline filmstrip. Each thumb is
* keyed by (mediaId, t, h) and cached under the workspace, so the row of frames a
* clip shows is generated once and then served from disk. Cheap: one fast
* input-seek frame extract per cache miss.
*/
app.get('/api/media/thumb', async (c) => {
const mediaId = c.req.query('mediaId') ?? '';
const t = Number(c.req.query('t') ?? '0');
const h = Math.min(Math.max(Math.round(Number(c.req.query('h') ?? '48')), 16), 240);
if (!mediaId || Number.isNaN(t) || t < 0)
return c.json({ error: 'mediaId and t≥0 required' }, 400);

const thumbsDir = resolve(getWorkspace(), '.thumbs');
const safeKey = `${mediaId}-${t.toFixed(2)}-${h}`.replace(/[^\w.-]/g, '_');
const out = resolve(thumbsDir, `${safeKey}.jpg`);

const serve = async () =>
c.body(await readFile(out), 200, {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=31536000, immutable',
});

// Cache hit — the file's existence means this mediaId was already validated.
if (existsSync(out)) return serve();

const info = (await buildMediaMap()).get(mediaId as MediaId);
if (!info) return c.json({ error: `Unknown media: ${mediaId}` }, 404);
try {
await mkdir(thumbsDir, { recursive: true });
// Generate to a unique temp path then atomically rename, so concurrent
// requests for the same thumb never serve a half-written file.
const tmp = resolve(thumbsDir, `${safeKey}.${randomBytes(4).toString('hex')}.tmp.jpg`);
await runFfmpeg(buildThumbnailArgs({ input: info.path, output: tmp, atSec: t, height: h }));
await rename(tmp, out);
return serve();
} catch (err) {
// A non-thumbnailable source (e.g. audio-only) or a transient FFmpeg failure
// — the clip just falls back to its gradient block. Clean error, not a 500.
const message = err instanceof Error ? err.message : String(err);
return c.json({ error: message }, 422);
}
});

/**
* POST /api/timeline/export — compile the whole document to a rendered file and
* log it to the session so it appears in Outputs and is playable. Returns the
Expand Down
85 changes: 70 additions & 15 deletions src/ui/web/src/components/DocTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { clipDuration, clipEndSec, clipLabel, compositionDuration } from '../lib/composition.js';
import type { Clip, Composition } from '../types.js';
import type { Clip, Composition, MediaClip } from '../types.js';

const PX_PER_SEC = 90;
const GUTTER = 128; // left lane-label column width (px)
const MIN_SPAN_SEC = 6; // keep an empty/short timeline from collapsing
const THUMB_H = 48; // filmstrip thumbnail height (px) — the clip's inner height
const TARGET_THUMB_W = 72; // desired filmstrip frame slot width (px)
const MAX_THUMBS = 16; // cap thumbnails per clip to bound requests

/**
* Read view of the CompositionDoc: a seconds ruler, one lane per track, clips
Expand Down Expand Up @@ -40,6 +43,18 @@ export function DocTimeline({
const hasClips = composition.tracks.some((t) => t.clips.length > 0);
const playhead = Math.min(playheadSec, spanSec);

const innerRef = useRef<HTMLDivElement | null>(null);

// Map a pointer's x to a timeline second, accounting for the lane gutter and the
// current horizontal scroll, so dragging the playhead or the ruler scrubs to
// exactly where the cursor is.
const scrubToClientX = (clientX: number) => {
const inner = innerRef.current;
if (!inner) return;
const x = clientX - inner.getBoundingClientRect().left - GUTTER;
onScrub(Math.min(Math.max(x / PX_PER_SEC, 0), spanSec));
};

// Undo/redo availability tracks the doc op-log; refetch whenever the doc moves.
const [history, setHistory] = useState<{ canUndo: boolean; canRedo: boolean }>({
canUndo: false,
Expand All @@ -66,18 +81,6 @@ export function DocTimeline({
{total.toFixed(2)}s · {composition.width}×{composition.height} · {composition.fps}fps ·
rev {composition.rev}
</span>
{hasClips ? (
<input
type="range"
className="doc-tl-scrub"
min={0}
max={spanSec}
step={0.01}
value={playhead}
onChange={(e) => onScrub(Number(e.target.value))}
aria-label="Playhead position (seconds)"
/>
) : null}
<span className="doc-tl-playtime">▶ {playhead.toFixed(2)}s</span>
<button
type="button"
Expand Down Expand Up @@ -109,7 +112,7 @@ export function DocTimeline({

{hasClips ? (
<div className="doc-tl-scroll">
<div className="doc-tl-inner" style={{ width: GUTTER + laneWidth }}>
<div className="doc-tl-inner" ref={innerRef} style={{ width: GUTTER + laneWidth }}>
<div className="doc-tl-ruler-row">
<div className="doc-tl-gutter" />
<div className="doc-tl-ruler" style={{ width: laneWidth }}>
Expand All @@ -118,6 +121,18 @@ export function DocTimeline({
<span className="doc-tl-tick-label">{t}s</span>
</div>
))}
{/* Transparent native range over the ruler: click or drag to scrub,
and arrow keys move the playhead — the accessible scrub control. */}
<input
type="range"
className="doc-tl-rail"
min={0}
max={spanSec}
step={1 / composition.fps}
value={playhead}
onChange={(e) => onScrub(Number(e.target.value))}
aria-label="Playhead position (seconds)"
/>
</div>
</div>

Expand Down Expand Up @@ -154,9 +169,18 @@ export function DocTimeline({
</div>
))}

{/* Pointer-only drag handle; the ruler range input is the accessible
scrub control, so this stays out of the a11y tree. */}
<div
className="doc-tl-playhead"
style={{ left: GUTTER + playhead * PX_PER_SEC }}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
scrubToClientX(e.clientX);
}}
onPointerMove={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) scrubToClientX(e.clientX);
}}
aria-hidden="true"
/>
</div>
Expand Down Expand Up @@ -189,8 +213,39 @@ function ClipBlock({
onClick={() => onSelect(selected ? null : clip.id)}
title={`${clipLabel(clip)} · ${clip.startSec.toFixed(2)}–${clipEndSec(clip).toFixed(2)}s`}
>
{clip.kind === 'media' ? <Filmstrip clip={clip} width={width} /> : null}
<span className="doc-tl-clip-label">{clipLabel(clip)}</span>
<span className="doc-tl-clip-dur">{clipDuration(clip).toFixed(1)}s</span>
</button>
);
}

/**
* A filmstrip: a row of source-frame thumbnails across the clip's trimmed window.
* Each frame is a cached `/api/media/thumb` still sampled at the middle of its
* slot; the clip's gradient shows underneath until they load.
*/
function Filmstrip({ clip, width }: { clip: MediaClip; width: number }) {
const n = Math.max(1, Math.min(MAX_THUMBS, Math.round(width / TARGET_THUMB_W)));
const { mediaId, sourceInSec, sourceOutSec } = clip;
const slotW = width / n;
return (
<span className="doc-tl-filmstrip" aria-hidden="true">
{Array.from({ length: n }, (_, i) => {
const t = sourceInSec + ((i + 0.5) / n) * (sourceOutSec - sourceInSec);
return (
<img
key={t}
className="doc-tl-thumb"
style={{ width: slotW }}
src={`/api/media/thumb?mediaId=${encodeURIComponent(mediaId)}&t=${t.toFixed(2)}&h=${THUMB_H}`}
alt=""
loading="lazy"
draggable={false}
/>
);
})}
<span className="doc-tl-clip-scrim" />
</span>
);
}
Loading
Loading