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