diff --git a/src/ffmpeg/args/preview.ts b/src/ffmpeg/args/preview.ts index b1030c4..14550ba 100644 --- a/src/ffmpeg/args/preview.ts +++ b/src/ffmpeg/args/preview.ts @@ -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, + ]; +} diff --git a/src/ui/server.ts b/src/ui/server.ts index bf194e1..e600144 100644 --- a/src/ui/server.ts +++ b/src/ui/server.ts @@ -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'; @@ -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 { @@ -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'; @@ -252,6 +255,51 @@ export async function startUiServer(options: UiServerOptions = {}): Promise { + 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 diff --git a/src/ui/web/src/components/DocTimeline.tsx b/src/ui/web/src/components/DocTimeline.tsx index cab9989..dd4c815 100644 --- a/src/ui/web/src/components/DocTimeline.tsx +++ b/src/ui/web/src/components/DocTimeline.tsx @@ -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 @@ -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(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, @@ -66,18 +81,6 @@ export function DocTimeline({ {total.toFixed(2)}s · {composition.width}×{composition.height} · {composition.fps}fps · rev {composition.rev} - {hasClips ? ( - onScrub(Number(e.target.value))} - aria-label="Playhead position (seconds)" - /> - ) : null} ▶ {playhead.toFixed(2)}s ); } + +/** + * 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 ( +