1import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2import type { Background, ExportJob, Layout, OutputSettings, PublicConfig, Segment, Take, Transcript, Video } from '../../shared/types.ts';
3import { backgroundOf, exportSignature } from '../../shared/types.ts';
4import { computeGeometry } from '../../shared/layout.ts';
5import { cutRange, locate, moveSegment, randomId, segDuration, segmentStarts, splitAt, timelineDuration } from '../../shared/timeline.ts';
6import { navigate } from '../App.tsx';
7import { api } from '../api.ts';
8import { Icon } from '../components/Icon.tsx';
9import { ConfirmDialog, type ConfirmRequest } from '../components/Modal.tsx';
10import { Stage, captureFrame } from '../components/Stage.tsx';
11import { Timeline, takeHue, type PeaksData } from '../components/Timeline.tsx';
12import { TranscriptPanel } from '../components/TranscriptPanel.tsx';
13import { decodePeaks, fmtBytes, fmtDateTime, fmtDuration, fmtTimecode, loadPref, savePref } from '../lib/format.ts';
14import { TimelinePlayer, usePlayerPlaying, usePlayerTime } from '../lib/player.ts';
16type Tab = 'details' | 'transcript' | 'export';
17type Marks = { in: number | null; out: number | null };
18const NO_MARKS: Marks = { in: null, out: null };
19const FRAME = 1 / 30;
20const isLeft = (c: string) => c === 'tl' || c === 'bl';
22// Keep locally edited fields; take server-managed fields (takes, thumbnail, export) from `fresh`.
23function mergeServer(v: Video, fresh: Video): Video {
24 const takes = JSON.stringify(v.takes) === JSON.stringify(fresh.takes) ? v.takes : fresh.takes;
25 return { ...v, takes, thumbnail: fresh.thumbnail, export: fresh.export };
26}
28export function VideoPage({ videoId }: { videoId: string }) {
29 const [video, setVideo] = useState<Video | null>(null);
30 const [error, setError] = useState<string | null>(null);
31 const [notice, setNotice] = useState<string | null>(null);
32 const [config, setConfig] = useState<PublicConfig | null>(null);
33 const [tab, setTabState] = useState<Tab>(() => loadPref('takes.editor', { tab: 'details' as Tab }).tab);
34 const [selectedId, setSelectedId] = useState<string | null>(null);
35 const [marks, setMarks] = useState<Marks>(NO_MARKS);
36 const [history, setHistory] = useState<Segment[][]>([]);
37 const [future, setFuture] = useState<Segment[][]>([]);
38 const [peaks, setPeaks] = useState<Record<string, PeaksData>>({});
39 const [transcripts, setTranscripts] = useState<Record<string, Transcript>>({});
40 const [job, setJob] = useState<ExportJob | null>(null);
41 const [confirm, setConfirm] = useState<ConfirmRequest | null>(null);
42 const [zoom, setZoom] = useState(1);
43 const [saveState, setSaveState] = useState<'saved' | 'saving' | 'error'>('saved');
44 const player = useMemo(() => new TimelinePlayer(), []);
46 const setTab = (t: Tab) => {
47 setTabState(t);
48 savePref('takes.editor', { tab: t });
49 };
51 useEffect(() => () => player.destroy(), [player]);
53 useEffect(() => {
54 api
55 .video(videoId)
56 .then((v) => {
57 setVideo(v);
58 document.title = `${v.title} · Takes`;
59 })
60 .catch((e) => setError(e.message));
61 api.config().then(setConfig).catch(() => {});
62 api.exportStatus(videoId).then(setJob).catch(() => {});
63 }, [videoId]);
65 useEffect(() => {
66 if (video) player.setData(video.timeline, video.takes);
67 }, [video?.timeline, video?.takes, player]);
69 // ---- saving (debounced PATCH of edited fields) ----
70 const pending = useRef<Partial<Video>>({});
71 const timer = useRef(0);
72 const flush = useCallback(async () => {
73 clearTimeout(timer.current);
74 const p = pending.current;
75 pending.current = {};
76 if (Object.keys(p).length === 0) return;
77 setSaveState('saving');
78 try {
79 await api.patchVideo(videoId, p);
80 setSaveState('saved');
81 } catch (e) {
82 setSaveState('error');
83 setError(`Could not save: ${(e as Error).message}`);
84 }
85 }, [videoId]);
87 const patch = useCallback(
88 (p: Partial<Video>, delay = 500) => {
89 setVideo((v) => (v ? { ...v, ...p } : v));
90 pending.current = { ...pending.current, ...p };
91 clearTimeout(timer.current);
92 timer.current = window.setTimeout(flush, delay);
93 },
94 [flush],
95 );
97 useEffect(() => {
98 const onUnload = () => {
99 if (Object.keys(pending.current).length === 0) return;
100 fetch(`/api/videos/${videoId}`, {
101 method: 'PATCH',
102 keepalive: true,
103 headers: { 'Content-Type': 'application/json', 'X-Takes': '1' },
104 body: JSON.stringify(pending.current),
105 });
106 };
107 window.addEventListener('pagehide', onUnload);
108 return () => {
109 window.removeEventListener('pagehide', onUnload);
110 flush();
111 };
112 }, [videoId, flush]);
114 // ---- peaks and transcripts ----
115 const requested = useRef(new Map<string, string>());
116 useEffect(() => {
117 if (!video) return;
118 for (const t of video.takes) {
119 if (t.status !== 'ready' || !t.audio) continue;
120 const pk = `peaks/${t.id}`;
121 if (!requested.current.has(pk)) {
122 requested.current.set(pk, '1');
123 api.peaks(videoId, t.id).then((p) => p && setPeaks((m) => ({ ...m, [t.id]: { rate: p.rate, values: decodePeaks(p.data) } })));
124 }
125 const tk = `transcript/${t.id}`;
126 if (t.transcript?.status === 'done' && requested.current.get(tk) !== t.transcript.updatedAt) {
127 requested.current.set(tk, t.transcript.updatedAt);
128 api.transcript(videoId, t.id).then((tr) => tr && setTranscripts((m) => ({ ...m, [t.id]: tr })));
129 }
130 }
131 }, [video?.takes, videoId]);
133 // ---- polling while the server is working ----
134 const busy =
135 !!video &&
136 (video.takes.some((t) => t.status === 'processing' || t.transcript?.status === 'pending' || t.matte?.status === 'queued' || t.matte?.status === 'running') ||
137 job?.status === 'running');
139 // With a background chosen, make sure every camera take gets processed (including takes recorded later).
140 const bgMode = video ? backgroundOf(video.layout).mode : 'none';
141 const unprocessed = video?.takes.filter((t) => t.status === 'ready' && t.tracks.camera && !t.matte).map((t) => t.id).join(',') ?? '';
142 useEffect(() => {
143 if (bgMode === 'none' || !unprocessed) return;
144 api
145 .requestMattes(videoId)
146 .then((fresh) => setVideo((v) => (v ? mergeServer(v, fresh) : v)))
147 .catch((e) => setError(e.message));
148 }, [bgMode, unprocessed, videoId]);
149 useEffect(() => {
150 if (!busy) return;
151 const iv = setInterval(async () => {
152 try {
153 const j = await api.exportStatus(videoId);
154 const fresh = await api.video(videoId);
155 setJob(j);
156 setVideo((v) => (v ? mergeServer(v, fresh) : v));
157 } catch {
158 // try again on the next tick
159 }
160 }, 1000);
161 return () => clearInterval(iv);
162 }, [busy, videoId]);
164 const flashNotice = (msg: string) => {
165 setNotice(msg);
166 setTimeout(() => setNotice((n) => (n === msg ? null : n)), 2500);
167 };
169 // ---- timeline editing ----
170 const tl = video?.timeline ?? [];
171 const now = () => player.getSnapshot().time;
173 const commit = (next: Segment[]) => {
174 if (!video || next === video.timeline) return;
175 setHistory((h) => [...h.slice(-199), video.timeline]);
176 setFuture([]);
177 patch({ timeline: next }, 300);
178 };
179 const undo = () => {
180 if (!video || history.length === 0) return;
181 setFuture((f) => [video.timeline, ...f]);
182 patch({ timeline: history[history.length - 1] }, 300);
183 setHistory((h) => h.slice(0, -1));
184 };
185 const redo = () => {
186 if (!video || future.length === 0) return;
187 setHistory((h) => [...h, video.timeline]);
188 patch({ timeline: future[0] }, 300);
189 setFuture((f) => f.slice(1));
190 };
192 const hasRange = marks.in !== null && marks.out !== null && marks.out > marks.in;
193 const split = () => commit(splitAt(tl, now()));
194 const cutMarked = () => {
195 if (!hasRange) return;
196 commit(cutRange(tl, marks.in!, marks.out!));
197 setMarks(NO_MARKS);
198 setTimeout(() => player.seek(marks.in!), 0);
199 };
200 const currentSeg = () => {
201 const loc = locate(tl, now());
202 return loc ? { index: loc.index, start: segmentStarts(tl)[loc.index], seg: tl[loc.index] } : null;
203 };
204 const trimStart = () => {
205 const c = currentSeg();
206 if (!c) return;
207 commit(cutRange(tl, c.start, now()));
208 setTimeout(() => player.seek(c.start), 0);
209 };
210 const trimEnd = () => {
211 const c = currentSeg();
212 if (c) commit(cutRange(tl, now(), c.start + segDuration(c.seg)));
213 };
214 const deleteSelected = () => {
215 if (!selectedId) return;
216 commit(tl.filter((s) => s.id !== selectedId));
217 setSelectedId(null);
218 };
219 const moveSelected = (delta: number) => {
220 const i = tl.findIndex((s) => s.id === selectedId);
221 if (i >= 0) commit(moveSegment(tl, i, delta));
222 };
223 const markIn = () => setMarks((m) => ({ in: now(), out: m.out !== null && m.out > now() ? m.out : null }));
224 const markOut = () => setMarks((m) => ({ in: m.in !== null && m.in < now() ? m.in : null, out: now() }));
226 const setThumbnail = async () => {
227 if (!video) return;
228 const blob = await captureFrame(video, player);
229 if (!blob) return;
230 try {
231 const fresh = await api.uploadThumbnail(videoId, blob);
232 setVideo((v) => (v ? mergeServer(v, fresh) : v));
233 flashNotice('Thumbnail updated');
234 } catch (e) {
235 setError((e as Error).message);
236 }
237 };
239 // ---- keyboard shortcuts ----
240 const keyHandler = useRef<(e: KeyboardEvent) => void>(() => {});
241 keyHandler.current = (e: KeyboardEvent) => {
242 const el = e.target as HTMLElement;
243 if (el.closest('input, textarea, select, [contenteditable]') || confirm) return;
244 const mod = e.ctrlKey || e.metaKey;
245 const k = e.key;
246 const step = e.shiftKey ? 5 : 1;
247 if (mod && k.toLowerCase() === 'z') (e.shiftKey ? redo : undo)();
248 else if (mod && k.toLowerCase() === 'y') redo();
249 else if (mod) return;
250 else if (k === ' ') player.toggle();
251 else if (k === 'ArrowLeft') player.seek(now() - step);
252 else if (k === 'ArrowRight') player.seek(now() + step);
253 else if (k === ',') player.seek(now() - FRAME);
254 else if (k === '.') player.seek(now() + FRAME);
255 else if (k === 'Home') player.seek(0);
256 else if (k === 'End') player.seek(player.duration);
257 else if (k === 's' || k === 'S') split();
258 else if (k === 'i' || k === '[') markIn();
259 else if (k === 'o' || k === ']') markOut();
260 else if (k === 'x' || k === 'X') cutMarked();
261 else if (k === 'Delete' || k === 'Backspace') (hasRange ? cutMarked : deleteSelected)();
262 else if (k === 'Escape') {
263 setMarks(NO_MARKS);
264 setSelectedId(null);
265 } else return;
266 e.preventDefault();
267 };
268 useEffect(() => {
269 const on = (e: KeyboardEvent) => keyHandler.current(e);
270 window.addEventListener('keydown', on);
271 return () => window.removeEventListener('keydown', on);
272 }, []);
274 if (error && !video)
275 return (
276 <div className="page">
277 <div className="banner error">{error}</div>
278 <a href="#/">Back to videos</a>
279 </div>
280 );
281 if (!video) return <div className="page loading">Loading…</div>;
283 const readyTakes = video.takes.filter((t) => t.status === 'ready');
284 const takeNumber = (id: string) => video.takes.findIndex((t) => t.id === id) + 1;
285 const duration = timelineDuration(video.timeline);
286 const stale = !!video.export && video.export.signature !== exportSignature(video);
288 // ---- take actions ----
289 const refresh = (fresh: Video) => setVideo((v) => (v ? mergeServer(v, fresh) : v));
290 const replaceFromServer = async (op: () => Promise<Video>) => {
291 await flush();
292 try {
293 const fresh = await op();
294 setVideo(fresh);
295 setHistory([]);
296 setFuture([]);
297 } catch (e) {
298 setError((e as Error).message);
299 }
300 };
301 const addToTimeline = (t: Take) => commit([...tl, { id: randomId(), takeId: t.id, start: 0, end: t.duration }]);
302 const askDeleteTake = (t: Take) =>
303 setConfirm({
304 title: `Delete take ${takeNumber(t.id)}?`,
305 message: 'The recording will be permanently deleted from disk, and any parts of it in the timeline will be removed.',
306 confirmLabel: 'Delete take',
307 danger: true,
308 onConfirm: () => replaceFromServer(() => api.deleteTake(videoId, t.id)),
309 });
310 const transcribe = async (takeId: string) => {
311 try {
312 refresh(await api.transcribe(videoId, takeId));
313 } catch (e) {
314 setError((e as Error).message);
315 }
316 };
317 const startExport = async () => {
318 await flush();
319 try {
320 setJob(await api.startExport(videoId));
321 } catch (e) {
322 setError((e as Error).message);
323 }
324 };
325 const setLayout = (l: Partial<Layout>) => patch({ layout: { ...video.layout, ...l } }, 200);
326 const setOutput = (o: Partial<OutputSettings>) => patch({ output: { ...video.output, ...o } }, 200);
327 const background = backgroundOf(video.layout);
328 const setBackground = (b: Partial<Background>) => setLayout({ background: { ...background, ...b } });
329 const retryMatte = async (takeId: string) => {
330 try {
331 refresh(await api.requestMattes(videoId, takeId));
332 } catch (e) {
333 setError((e as Error).message);
334 }
335 };
336 const cameraTakes = readyTakes.filter((t) => t.tracks.camera);
337 // Takes in the timeline whose camera appears in the output but has not had its background removed yet.
338 const matteBlocking =
339 background.mode === 'none'
340 ? []
341 : [...new Set(video.timeline.map((s) => s.takeId))]
342 .map((id) => video.takes.find((t) => t.id === id)!)
343 .filter((t) => t && t.matte?.status !== 'done' && computeGeometry(video.layout, video.output, t.tracks.screen, t.tracks.camera).camera);
345 const anyBoth = readyTakes.some((t) => t.tracks.screen && t.tracks.camera);
347 return (
348 <div className="page editor">
349 <header className="topbar">
350 <a className="btn ghost" href="#/" onClick={() => flush()}>
351 <Icon name="back" /> Videos
352 </a>
353 <input className="title-input" value={video.title} onChange={(e) => patch({ title: e.target.value })} aria-label="Title" />
354 <span className={`save-state ${saveState}`}>{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Not saved' : 'Saved'}</span>
355 <div className="spacer" />
356 <button className="btn ghost" onClick={() => api.openFolder(videoId).catch((e) => setError(e.message))} title="Open this video's folder">
357 <Icon name="folder" /> Folder
358 </button>
359 <button
360 className="btn record"
361 onClick={async () => {
362 await flush();
363 navigate(`video/${videoId}/record`);
364 }}
365 >
366 <Icon name="record" /> Record take
367 </button>
368 </header>
370 {error && (
371 <div className="banner error" onClick={() => setError(null)}>
372 {error} <span className="muted">(click to dismiss)</span>
373 </div>
374 )}
375 {notice && <div className="toast">{notice}</div>}
377 <div className="editor-grid">
378 <section className="editor-main">
379 {video.takes.length === 0 ? (
380 <div className="empty">
381 <h2>No takes yet</h2>
382 <p className="muted">Record your camera, your screen, or both. You can record several takes and combine them.</p>
383 <button className="btn record big" onClick={() => navigate(`video/${videoId}/record`)}>
384 <Icon name="record" /> Record first take
385 </button>
386 </div>
387 ) : (
388 <>
389 <Stage video={video} player={player} />
390 <div className="transport">
391 <PlayButton player={player} />
392 <TimeDisplay player={player} duration={duration} />
393 <div className="sep" />
394 <button className="icon-btn" title="Undo (Ctrl+Z)" disabled={history.length === 0} onClick={undo}>
395 <Icon name="undo" />
396 </button>
397 <button className="icon-btn" title="Redo (Ctrl+Shift+Z)" disabled={future.length === 0} onClick={redo}>
398 <Icon name="redo" />
399 </button>
400 <div className="sep" />
401 <button className="icon-btn" title="Split at playhead (S)" onClick={split}>
402 <Icon name="split" />
403 </button>
404 <button className="icon-btn" title="Trim: cut from start of this segment to playhead" onClick={trimStart}>
405 <Icon name="trimStart" />
406 </button>
407 <button className="icon-btn" title="Trim: cut from playhead to end of this segment" onClick={trimEnd}>
408 <Icon name="trimEnd" />
409 </button>
410 <div className="sep" />
411 <button className="btn small" title="Mark in (I)" onClick={markIn}>
412 In
413 </button>
414 <button className="btn small" title="Mark out (O)" onClick={markOut}>
415 Out
416 </button>
417 <button className="btn small danger" title="Cut marked range (X)" disabled={!hasRange} onClick={cutMarked}>
418 <Icon name="scissors" /> Cut {hasRange ? fmtTimecode(marks.out! - marks.in!) : ''}
419 </button>
420 <div className="sep" />
421 <button className="icon-btn" title="Move selected segment earlier" disabled={!selectedId} onClick={() => moveSelected(-1)}>
422 <Icon name="left" />
423 </button>
424 <button className="icon-btn" title="Move selected segment later" disabled={!selectedId} onClick={() => moveSelected(1)}>
425 <Icon name="right" />
426 </button>
427 <button className="icon-btn danger" title="Remove selected segment (Delete)" disabled={!selectedId} onClick={deleteSelected}>
428 <Icon name="trash" />
429 </button>
430 <div className="spacer" />
431 <label className="zoom" title="Timeline zoom">
432 <span className="muted small">Zoom</span>
433 <input type="range" min={1} max={20} step={0.5} value={zoom} onChange={(e) => setZoom(parseFloat(e.target.value))} />
434 </label>
435 <button className="icon-btn" title="Use current frame as thumbnail" onClick={setThumbnail}>
436 <Icon name="image" />
437 </button>
438 </div>
439 <Timeline segments={video.timeline} takeNumber={takeNumber} player={player} selectedId={selectedId} onSelect={setSelectedId} marks={marks} peaks={peaks} zoom={zoom} />
440 <p className="shortcuts muted small">
441 Space play/pause · ←/→ 1 s (Shift 5 s) · , / . one frame · S split · I / O mark in/out · X cut marked · Delete remove segment · Ctrl+Z undo
442 </p>
444 <h3 className="section-title">Takes</h3>
445 <div className="takes">
446 {video.takes.map((t) => (
447 <TakeCard
448 key={t.id}
449 take={t}
450 n={takeNumber(t.id)}
451 inTimeline={video.timeline.some((s) => s.takeId === t.id)}
452 onAdd={() => addToTimeline(t)}
453 onDelete={() => askDeleteTake(t)}
454 onRecover={() => replaceFromServer(() => api.finalizeTake(videoId, t.id))}
455 />
456 ))}
457 </div>
458 </>
459 )}
460 </section>
462 <aside className="editor-side">
463 <div className="tabs">
464 <button className={tab === 'details' ? 'active' : ''} onClick={() => setTab('details')}>
465 Details
466 </button>
467 <button className={tab === 'transcript' ? 'active' : ''} onClick={() => setTab('transcript')}>
468 Transcript
469 </button>
470 <button className={tab === 'export' ? 'active' : ''} onClick={() => setTab('export')}>
471 Layout & export
472 </button>
473 </div>
475 {tab === 'details' && (
476 <div className="panel">
477 <label className="field">
478 <span>Description</span>
479 <textarea rows={4} value={video.description} onChange={(e) => patch({ description: e.target.value })} />
480 </label>
481 <label className="field grow">
482 <span>Notes (teleprompter)</span>
483 <textarea className="notes" value={video.notes} onChange={(e) => patch({ notes: e.target.value })} />
484 </label>
485 <p className="muted small">Created {fmtDateTime(video.createdAt)}</p>
486 </div>
487 )}
489 {tab === 'transcript' && (
490 <div className="panel">
491 <TranscriptPanel
492 segments={video.timeline}
493 takes={video.takes}
494 transcripts={transcripts}
495 takeNumber={takeNumber}
496 player={player}
497 marks={marks}
498 onMarks={setMarks}
499 onCut={cutMarked}
500 onTranscribe={transcribe}
501 hasKey={!!config?.hasDeepgramKey}
502 />
503 </div>
504 )}
506 {tab === 'export' && (
507 <div className="panel">
508 <h4>Camera and screen layout</h4>
509 {!anyBoth && <p className="muted small">These apply to takes that have both camera and screen.</p>}
510 <div className="seg-control">
511 {(
512 [
513 ['pip', 'Picture in picture'],
514 ['side-by-side', 'Side by side'],
515 ['screen-only', 'Screen'],
516 ['camera-only', 'Camera'],
517 ] as const
518 ).map(([m, label]) => (
519 <button key={m} className={video.layout.mode === m ? 'active' : ''} onClick={() => setLayout({ mode: m })}>
520 {label}
521 </button>
522 ))}
523 </div>
524 {(video.layout.mode === 'pip' || video.layout.mode === 'side-by-side') && (
525 <div className="field">
526 <span>{video.layout.mode === 'pip' ? 'Corner' : 'Camera side'}</span>
527 <div className="seg-control">
528 {(video.layout.mode === 'pip'
529 ? ([
530 ['tl', 'Top left'],
531 ['tr', 'Top right'],
532 ['bl', 'Bottom left'],
533 ['br', 'Bottom right'],
534 ] as const)
535 : ([
536 ['bl', 'Left'],
537 ['br', 'Right'],
538 ] as const)
539 ).map(([c, label]) => (
540 <button
541 key={c}
542 className={(video.layout.mode === 'pip' ? video.layout.corner === c : isLeft(video.layout.corner) === isLeft(c)) ? 'active' : ''}
543 onClick={() => setLayout({ corner: c })}
544 >
545 {label}
546 </button>
547 ))}
548 </div>
549 </div>
550 )}
551 {video.layout.mode === 'pip' && (
552 <>
553 <label className="field">
554 <span>Camera size ({Math.round(video.layout.size * 100)}% of width)</span>
555 <input type="range" min={0.12} max={0.45} step={0.01} value={video.layout.size} onChange={(e) => setLayout({ size: parseFloat(e.target.value) })} />
556 </label>
557 <div className="field">
558 <span>Shape</span>
559 <div className="seg-control">
560 {(
561 [
562 ['rect', 'Rectangle'],
563 ['rounded', 'Rounded'],
564 ['circle', 'Circle'],
565 ] as const
566 ).map(([s, label]) => (
567 <button key={s} className={video.layout.shape === s ? 'active' : ''} onClick={() => setLayout({ shape: s })}>
568 {label}
569 </button>
570 ))}
571 </div>
572 </div>
573 </>
574 )}
576 {cameraTakes.length > 0 && (
577 <>
578 <h4>Background behind you</h4>
579 <div className="seg-control">
580 {(
581 [
582 ['none', 'Original'],
583 ['blur', 'Blur'],
584 ['color', 'Color'],
585 ['cutout', 'Cutout'],
586 ] as const
587 ).map(([m, label]) => (
588 <button
589 key={m}
590 className={background.mode === m ? 'active' : ''}
591 title={m === 'cutout' ? 'Picture in picture only: just you, over the screen' : undefined}
592 onClick={() => setBackground({ mode: m })}
593 >
594 {label}
595 </button>
596 ))}
597 </div>
598 {background.mode === 'cutout' && video.layout.mode !== 'pip' && (
599 <p className="muted small">Cutout applies to picture in picture; with this layout the color below is used instead.</p>
600 )}
601 {(background.mode === 'color' || (background.mode === 'cutout' && video.layout.mode !== 'pip')) && (
602 <label className="row">
603 <span className="small muted">Color</span>
604 <input type="color" value={background.color} onChange={(e) => setBackground({ color: e.target.value })} />
605 </label>
606 )}
607 {background.mode === 'blur' && (
608 <label className="field">
609 <span>Blur strength</span>
610 <input type="range" min={4} max={50} step={1} value={background.blur} onChange={(e) => setBackground({ blur: parseInt(e.target.value) })} />
611 </label>
612 )}
613 {background.mode !== 'none' && (
614 <div className="matte-status">
615 {cameraTakes.map((t) => (
616 <div key={t.id} className="row small">
617 <span>Take {takeNumber(t.id)}:</span>
618 <MatteStatus take={t} onRetry={() => retryMatte(t.id)} />
619 </div>
620 ))}
621 <p className="muted small">Background removal runs on this computer (Robust Video Matting) and is done once per take.</p>
622 </div>
623 )}
624 </>
625 )}
627 <h4>Output</h4>
628 <div className="field">
629 <span>Resolution</span>
630 <div className="seg-control">
631 {([720, 1080] as const).map((h) => (
632 <button key={h} className={video.output.height === h ? 'active' : ''} onClick={() => setOutput({ height: h })}>
633 {h}p
634 </button>
635 ))}
636 </div>
637 </div>
638 <label className="check">
639 <input type="checkbox" checked={video.output.normalizeAudio} onChange={(e) => setOutput({ normalizeAudio: e.target.checked })} />
640 Normalize loudness (−16 LUFS)
641 </label>
643 <div className="export-box">
644 {job?.status === 'running' ? (
645 <>
646 <div className="progress">
647 <div style={{ width: `${Math.round(job.progress * 100)}%` }} />
648 </div>
649 <p className="small muted">Exporting… {Math.round(job.progress * 100)}%</p>
650 </>
651 ) : (
652 <button
653 className="btn primary"
654 disabled={video.timeline.length === 0 || matteBlocking.length > 0}
655 title={matteBlocking.length > 0 ? 'Waiting for background removal to finish' : undefined}
656 onClick={startExport}
657 >
658 <Icon name="film" /> {video.export ? 'Export again' : 'Export MP4'}
659 </button>
660 )}
661 {job?.status !== 'running' && matteBlocking.length > 0 && (
662 <p className="small muted">Export will be available once background removal has finished for the takes in the timeline.</p>
663 )}
664 {job?.status === 'failed' && <p className="small error-text">Export failed: {job.error}</p>}
665 {video.export && (
666 <div className="export-info">
667 <div>
668 <a href={`/media/${videoId}/${video.export.file}`} target="_blank" rel="noreferrer">
669 {video.export.file}
670 </a>{' '}
671 <span className="muted small">
672 {fmtDuration(video.export.duration)} · {fmtBytes(video.export.bytes)} · {fmtDateTime(video.export.exportedAt)}
673 </span>
674 </div>
675 {stale && <p className="small warn-text">The timeline or layout changed since this export.</p>}
676 <button className="btn small" onClick={() => api.openFolder(videoId).catch((e) => setError(e.message))}>
677 <Icon name="folder" /> Show in folder
678 </button>
679 </div>
680 )}
681 </div>
682 </div>
683 )}
684 </aside>
685 </div>
687 {confirm && <ConfirmDialog req={confirm} onClose={() => setConfirm(null)} />}
688 </div>
689 );
690}
692function MatteStatus({ take, onRetry }: { take: Take; onRetry: () => void }) {
693 const m = take.matte;
694 if (!m) return <span className="muted">waiting</span>;
695 if (m.status === 'done') return <span className="ok-text">done</span>;
696 if (m.status === 'queued') return <span className="muted">queued</span>;
697 if (m.status === 'running')
698 return (
699 <>
700 <div className="progress mini">
701 <div style={{ width: `${Math.round((m.progress ?? 0) * 100)}%` }} />
702 </div>
703 <span className="muted">{Math.round((m.progress ?? 0) * 100)}%</span>
704 </>
705 );
706 return (
707 <>
708 <span className="error-text" title={m.error}>
709 failed
710 </span>
711 <button className="btn small" onClick={onRetry}>
712 Retry
713 </button>
714 </>
715 );
716}
718function PlayButton({ player }: { player: TimelinePlayer }) {
719 const playing = usePlayerPlaying(player);
720 return (
721 <button className="icon-btn play" title="Play/pause (Space)" onClick={() => player.toggle()}>
722 <Icon name={playing ? 'pause' : 'play'} size={18} />
723 </button>
724 );
725}
727function TimeDisplay({ player, duration }: { player: TimelinePlayer; duration: number }) {
728 const time = usePlayerTime(player);
729 return (
730 <span className="timecode">
731 {fmtTimecode(time)} <span className="muted">/ {fmtTimecode(duration)}</span>
732 </span>
733 );
734}
736function TakeCard({ take, n, inTimeline, onAdd, onDelete, onRecover }: { take: Take; n: number; inTimeline: boolean; onAdd: () => void; onDelete: () => void; onRecover: () => void }) {
737 return (
738 <div className="take" style={{ ['--hue' as string]: takeHue(n) }}>
739 <div className="take-head">
740 <span className="take-swatch" />
741 <strong>Take {n}</strong>
742 <span className="kinds">
743 {take.tracks.camera && <Icon name="camera" size={14} />}
744 {take.tracks.screen && <Icon name="screen" size={14} />}
745 {take.audio && <Icon name="mic" size={14} />}
746 </span>
747 </div>
748 <div className="small muted">
749 {fmtDateTime(take.createdAt)}
750 {take.status === 'ready' && ` · ${fmtDuration(take.duration)}`}
751 {take.transcript?.status === 'done' && ' · transcribed'}
752 {take.transcript?.status === 'pending' && ' · transcribing…'}
753 {take.matte?.status === 'done' && ' · background removed'}
754 {(take.matte?.status === 'queued' || take.matte?.status === 'running') && ' · removing background…'}
755 </div>
756 {take.status === 'processing' && <div className="small">Processing…</div>}
757 {(take.status === 'recording' || take.status === 'failed') && (
758 <div className="small warn-text">{take.status === 'failed' ? `Failed: ${take.error}` : 'Interrupted recording (not finalized)'}</div>
759 )}
760 <div className="take-actions">
761 {take.status === 'ready' && (
762 <button className="btn small" onClick={onAdd} title="Append the whole take to the end of the timeline">
763 <Icon name="plus" /> {inTimeline ? 'Add again' : 'Add to timeline'}
764 </button>
765 )}
766 {(take.status === 'recording' || take.status === 'failed') && (
767 <button className="btn small" onClick={onRecover} title="Try to finalize whatever was recorded">
768 Recover
769 </button>
770 )}
771 <button className="icon-btn danger" onClick={onDelete} title="Delete take">
772 <Icon name="trash" />
773 </button>
774 </div>
775 </div>
776 );
777}