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, OUTPUT_HEIGHTS, backgroundOf, cropOf, exportSignature, matteBelowSetting, systemVolumeOf } from '../../shared/types.ts';
4import { computeGeometry, placeCropped } 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 };
27}
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 // How much the export scales the screen down, for the timeline take where it is scaled most, at
377 // each output height: 1 or more shows the recording at full size. Takes recorded before the
378 // screen size was stored are left out until the server has probed them.
379 const screenScale = (take: Take, height: OutputSettings['height']) => {
380 const box = computeGeometry(video.layout, { ...video.output, height }, true, take.tracks.camera).screen;
381 if (!box || !take.screenSize) return null;
382 const { width, height: h } = take.screenSize;
383 const crop = cropOf(video, take.id, 'screen');
384 return placeCropped(box, width, h, crop, 'contain').clip.w / ((crop?.w ?? 1) * width);
385 };
386 const screenFit = [...new Set(video.timeline.map((s) => s.takeId))]
387 .map((id) => readyTakes.find((t) => t.id === id))
388 .reduce<{ take: Take; scale: number } | null>((worst, t) => {
389 const scale = t ? screenScale(t, video.output.height) : null;
390 return t && scale !== null && (!worst || scale < worst.scale) ? { take: t, scale } : worst;
391 }, null);
392 const fullSizeAt = screenFit ? OUTPUT_HEIGHTS.find((h) => (screenScale(screenFit.take, h) ?? 0) >= 0.995) : undefined;
393 // Takes in the timeline whose camera appears in the output but has not had its background removed yet.
394 const matteBlocking =
395 background.mode === 'none'
396 ? []
397 : [...new Set(video.timeline.map((s) => s.takeId))]
398 .map((id) => video.takes.find((t) => t.id === id)!)
399 .filter((t) => t && t.matte?.status !== 'done' && computeGeometry(video.layout, video.output, t.tracks.screen, t.tracks.camera).camera);
401 const anyBoth = readyTakes.some((t) => t.tracks.screen && t.tracks.camera);
403 return (
404 <div className="page editor">
405 <header className="topbar">
406 <a className="btn ghost" href="#/" onClick={() => flush()}>
407 <Icon name="back" /> Videos
408 </a>
409 <input className="title-input" value={video.title} onChange={(e) => patch({ title: e.target.value })} aria-label="Title" />
410 <span className={`save-state ${saveState}`}>{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Not saved' : 'Saved'}</span>
411 <div className="spacer" />
412 <button className="btn ghost" onClick={() => api.openFolder(videoId).catch((e) => setError(e.message))} title="Open this video's folder">
413 <Icon name="folder" /> Folder
414 </button>
415 <button
416 className="btn record"
417 onClick={async () => {
418 await flush();
419 navigate(`video/${videoId}/record`);
420 }}
421 >
422 <Icon name="record" /> Record take
423 </button>
424 </header>
426 {error && (
427 <div className="banner error" onClick={() => setError(null)}>
428 {error} <span className="muted">(click to dismiss)</span>
429 </div>
430 )}
431 {notice && <div className="toast">{notice}</div>}
433 <div className="editor-grid">
434 <section className="editor-main">
435 {video.takes.length === 0 ? (
436 <div className="empty">
437 <h2>No takes yet</h2>
438 <p className="muted">Record your camera, your screen, or both. You can record several takes and combine them.</p>
439 <button className="btn record big" onClick={() => navigate(`video/${videoId}/record`)}>
440 <Icon name="record" /> Record first take
441 </button>
442 </div>
443 ) : (
444 <>
445 <Stage video={video} player={player} />
446 <div className="transport">
447 <PlayButton player={player} />
448 <TimeDisplay player={player} duration={duration} />
449 <div className="sep" />
450 <button className="icon-btn" title="Undo (Ctrl+Z)" disabled={history.length === 0} onClick={undo}>
451 <Icon name="undo" />
452 </button>
453 <button className="icon-btn" title="Redo (Ctrl+Shift+Z)" disabled={future.length === 0} onClick={redo}>
454 <Icon name="redo" />
455 </button>
456 <div className="sep" />
457 <button className="icon-btn" title="Split at playhead (S)" onClick={split}>
458 <Icon name="split" />
459 </button>
460 <button className="icon-btn" title="Trim: cut from start of this segment to playhead" onClick={trimStart}>
461 <Icon name="trimStart" />
462 </button>
463 <button className="icon-btn" title="Trim: cut from playhead to end of this segment" onClick={trimEnd}>
464 <Icon name="trimEnd" />
465 </button>
466 <div className="sep" />
467 <button className="btn small" title="Mark in (I)" onClick={markIn}>
468 In
469 </button>
470 <button className="btn small" title="Mark out (O)" onClick={markOut}>
471 Out
472 </button>
473 <button className="btn small danger" title="Cut marked range (X)" disabled={!hasRange} onClick={cutMarked}>
474 <Icon name="scissors" /> Cut {hasRange ? fmtTimecode(marks.out! - marks.in!) : ''}
475 </button>
476 <div className="sep" />
477 <button className="icon-btn" title="Move selected segment earlier" disabled={!selectedId} onClick={() => moveSelected(-1)}>
478 <Icon name="left" />
479 </button>
480 <button className="icon-btn" title="Move selected segment later" disabled={!selectedId} onClick={() => moveSelected(1)}>
481 <Icon name="right" />
482 </button>
483 <button className="icon-btn danger" title="Remove selected segment (Delete)" disabled={!selectedId} onClick={deleteSelected}>
484 <Icon name="trash" />
485 </button>
486 <div className="spacer" />
487 <label className="zoom" title="Timeline zoom">
488 <span className="muted small">Zoom</span>
489 <input type="range" min={1} max={20} step={0.5} value={zoom} onChange={(e) => setZoom(parseFloat(e.target.value))} />
490 </label>
491 <button className="icon-btn" title="Use current frame as thumbnail" onClick={setThumbnail}>
492 <Icon name="image" />
493 </button>
494 </div>
495 <Timeline segments={video.timeline} takeNumber={takeNumber} player={player} selectedId={selectedId} onSelect={setSelectedId} marks={marks} peaks={peaks} zoom={zoom} />
496 <p className="shortcuts muted small">
497 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
498 </p>
500 <h3 className="section-title">Takes</h3>
501 <div className="takes">
502 {video.takes.map((t) => (
503 <TakeCard
504 key={t.id}
505 take={t}
506 n={takeNumber(t.id)}
507 inTimeline={video.timeline.some((s) => s.takeId === t.id)}
508 cropped={!!(video.crops?.[t.id]?.screen || video.crops?.[t.id]?.camera)}
509 onAdd={() => addToTimeline(t)}
510 onCrop={() => openCrop(t)}
511 onDelete={() => askDeleteTake(t)}
512 onRecover={() => replaceFromServer(() => api.finalizeTake(videoId, t.id))}
513 />
514 ))}
515 </div>
516 </>
517 )}
518 </section>
520 <aside className="editor-side">
521 <div className="tabs">
522 <button className={tab === 'details' ? 'active' : ''} onClick={() => setTab('details')}>
523 Details
524 </button>
525 <button className={tab === 'transcript' ? 'active' : ''} onClick={() => setTab('transcript')}>
526 Transcript
527 </button>
528 <button className={tab === 'export' ? 'active' : ''} onClick={() => setTab('export')}>
529 Layout & export
530 </button>
531 </div>
533 {tab === 'details' && (
534 <div className="panel">
535 <label className="field">
536 <span>Description</span>
537 <textarea rows={4} value={video.description} onChange={(e) => patch({ description: e.target.value })} />
538 </label>
539 <label className="field grow">
540 <span>Notes (teleprompter)</span>
541 <textarea className="notes" value={video.notes} onChange={(e) => patch({ notes: e.target.value })} />
542 </label>
543 <p className="muted small">Created {fmtDateTime(video.createdAt)}</p>
544 </div>
545 )}
547 {tab === 'transcript' && (
548 <div className="panel">
549 <TranscriptPanel
550 segments={video.timeline}
551 takes={video.takes}
552 transcripts={transcripts}
553 takeNumber={takeNumber}
554 player={player}
555 marks={marks}
556 onMarks={setMarks}
557 onCut={cutMarked}
558 onTranscribe={transcribe}
559 hasKey={!!config?.hasDeepgramKey}
560 />
561 </div>
562 )}
564 {tab === 'export' && (
565 <div className="panel">
566 <h4>Camera and screen layout</h4>
567 {!anyBoth && <p className="muted small">These apply to takes that have both camera and screen.</p>}
568 <div className="seg-control">
569 {(
570 [
571 ['pip', 'Picture in picture'],
572 ['side-by-side', 'Side by side'],
573 ['screen-only', 'Screen'],
574 ['camera-only', 'Camera'],
575 ] as const
576 ).map(([m, label]) => (
577 <button key={m} className={video.layout.mode === m ? 'active' : ''} onClick={() => setLayout({ mode: m })}>
578 {label}
579 </button>
580 ))}
581 </div>
582 {(video.layout.mode === 'pip' || video.layout.mode === 'side-by-side') && (
583 <div className="field">
584 <span>{video.layout.mode === 'pip' ? 'Corner' : 'Camera side'}</span>
585 <div className="seg-control">
586 {(video.layout.mode === 'pip'
587 ? ([
588 ['tl', 'Top left'],
589 ['tr', 'Top right'],
590 ['bl', 'Bottom left'],
591 ['br', 'Bottom right'],
592 ] as const)
593 : ([
594 ['bl', 'Left'],
595 ['br', 'Right'],
596 ] as const)
597 ).map(([c, label]) => (
598 <button
599 key={c}
600 className={(video.layout.mode === 'pip' ? video.layout.corner === c : isLeft(video.layout.corner) === isLeft(c)) ? 'active' : ''}
601 onClick={() => setLayout({ corner: c })}
602 >
603 {label}
604 </button>
605 ))}
606 </div>
607 </div>
608 )}
609 {video.layout.mode === 'pip' && (
610 <>
611 <label className="field">
612 <span>Camera size ({Math.round(video.layout.size * 100)}% of width)</span>
613 <input type="range" min={0.12} max={0.45} step={0.01} value={video.layout.size} onChange={(e) => setLayout({ size: parseFloat(e.target.value) })} />
614 </label>
615 <div className="field">
616 <span>Shape</span>
617 <div className="seg-control">
618 {(
619 [
620 ['rect', 'Rectangle'],
621 ['rounded', 'Rounded'],
622 ['circle', 'Circle'],
623 ] as const
624 ).map(([s, label]) => (
625 <button key={s} className={video.layout.shape === s ? 'active' : ''} onClick={() => setLayout({ shape: s })}>
626 {label}
627 </button>
628 ))}
629 </div>
630 </div>
631 </>
632 )}
634 {cameraTakes.length > 0 && (
635 <>
636 <h4>Background behind you</h4>
637 <div className="seg-control">
638 {(
639 [
640 ['none', 'Original'],
641 ['blur', 'Blur'],
642 ['color', 'Color'],
643 ['cutout', 'Cutout'],
644 ] as const
645 ).map(([m, label]) => (
646 <button
647 key={m}
648 className={background.mode === m ? 'active' : ''}
649 title={m === 'cutout' ? 'Picture in picture only: just you, over the screen' : undefined}
650 onClick={() => setBackground({ mode: m })}
651 >
652 {label}
653 </button>
654 ))}
655 </div>
656 {background.mode === 'cutout' && video.layout.mode !== 'pip' && (
657 <p className="muted small">Cutout applies to picture in picture; with this layout the color below is used instead.</p>
658 )}
659 {(background.mode === 'color' || (background.mode === 'cutout' && video.layout.mode !== 'pip')) && (
660 <label className="row">
661 <span className="small muted">Color</span>
662 <input type="color" value={background.color} onChange={(e) => setBackground({ color: e.target.value })} />
663 </label>
664 )}
665 {background.mode === 'blur' && (
666 <label className="field">
667 <span>Blur strength</span>
668 <input type="range" min={4} max={50} step={1} value={background.blur} onChange={(e) => setBackground({ blur: parseInt(e.target.value) })} />
669 </label>
670 )}
671 {background.mode !== 'none' && (
672 <>
673 <div className="field">
674 <span>Processing resolution</span>
675 <div className="seg-control">
676 {MATTE_RESOLUTIONS.map((r) => (
677 <button key={r} className={background.resolution === r ? 'active' : ''} onClick={() => setBackground({ resolution: r })}>
678 {r}p
679 </button>
680 ))}
681 </div>
682 </div>
683 {cameraBox && (
684 <p className="muted small">
685 The camera box is {cameraBox.w}×{cameraBox.h} px in the {video.output.height}p output
686 {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.
687 </p>
688 )}
689 <div className="matte-status">
690 {cameraTakes.map((t) => (
691 <div key={t.id} className="row small">
692 <span>Take {takeNumber(t.id)}:</span>
693 <MatteStatus take={t} background={background} onStart={() => startMatte(t.id)} onCancel={() => cancelMatte(t.id)} />
694 </div>
695 ))}
696 {matteActive && (
697 <div className="row">
698 <button className="btn small" onClick={() => cancelMatte()}>
699 Cancel all
700 </button>
701 </div>
702 )}
703 <p className="muted small">
704 Background removal runs on {config?.matteHost ? <code>{config.matteHost}</code> : 'this computer'} (Robust Video Matting) and is done once per take.
705 </p>
706 </div>
707 </>
708 )}
709 </>
710 )}
712 <h4>Output</h4>
713 <div className="field">
714 <span>Resolution</span>
715 <div className="seg-control">
716 {OUTPUT_HEIGHTS.map((h) => (
717 <button key={h} className={video.output.height === h ? 'active' : ''} onClick={() => setOutput({ height: h })}>
718 {h}p
719 </button>
720 ))}
721 </div>
722 </div>
723 {screenFit?.take.screenSize && (
724 <p className="muted small">
725 The screen was recorded at {screenFit.take.screenSize.width}×{screenFit.take.screenSize.height}
726 {screenFit.scale >= 0.995
727 ? `, which ${video.output.height}p shows at full size.`
728 : `; ${video.output.height}p shows it at ${Math.round(screenFit.scale * 100)}% of that size. ` +
729 (fullSizeAt ? `${fullSizeAt}p shows it at full size.` : `Even ${OUTPUT_HEIGHTS[OUTPUT_HEIGHTS.length - 1]}p scales it down.`)}{' '}
730 Higher resolutions export more slowly and make larger files.
731 </p>
732 )}
733 <label className="check">
734 <input type="checkbox" checked={video.output.normalizeAudio} onChange={(e) => setOutput({ normalizeAudio: e.target.checked })} />
735 Normalize loudness (−16 LUFS)
736 </label>
737 {readyTakes.some((t) => t.systemAudio) && (
738 <label className="field">
739 <span>Computer audio level ({Math.round(systemVolumeOf(video.output) * 100)}%)</span>
740 <input type="range" min={0} max={1} step={0.05} value={systemVolumeOf(video.output)} onChange={(e) => setOutput({ systemVolume: parseFloat(e.target.value) })} />
741 </label>
742 )}
744 <div className="export-box">
745 {job?.status === 'running' ? (
746 <>
747 <div className="progress">
748 <div style={{ width: `${Math.round(job.progress * 100)}%` }} />
749 </div>
750 <p className="small muted">Exporting… {Math.round(job.progress * 100)}%</p>
751 </>
752 ) : (
753 <button
754 className="btn primary"
755 disabled={video.timeline.length === 0 || matteBlocking.length > 0}
756 title={matteBlocking.length > 0 ? 'Waiting for background removal to finish' : undefined}
757 onClick={startExport}
758 >
759 <Icon name="film" /> {video.export ? 'Export again' : 'Export MP4'}
760 </button>
761 )}
762 {job?.status !== 'running' && matteBlocking.length > 0 && (
763 <p className="small muted">Export will be available once background removal has finished for the takes in the timeline.</p>
764 )}
765 {job?.status === 'failed' && <p className="small error-text">Export failed: {job.error}</p>}
766 {video.export && (
767 <div className="export-info">
768 <div>
769 <a href={`/media/${videoId}/${video.export.file}`} target="_blank" rel="noreferrer">
770 {video.export.file}
771 </a>{' '}
772 <span className="muted small">
773 {fmtDuration(video.export.duration)} · {fmtBytes(video.export.bytes)} · {fmtDateTime(video.export.exportedAt)}
774 </span>
775 </div>
776 {stale && <p className="small warn-text">The timeline, layout, or crop changed since this export.</p>}
777 <button className="btn small" onClick={() => api.openFolder(videoId).catch((e) => setError(e.message))}>
778 <Icon name="folder" /> Show in folder
779 </button>
780 </div>
781 )}
782 </div>
783 </div>
784 )}
785 </aside>
786 </div>
788 {confirm && <ConfirmDialog req={confirm} onClose={() => setConfirm(null)} />}
789 {cropping && (
790 <CropDialog
791 video={video}
792 take={cropping.take}
793 initialTime={cropping.time}
794 onSave={(crop, all) => saveCrop(cropping.take, crop, all)}
795 onClose={() => setCropping(null)}
796 />
797 )}
798 </div>
799 );
800}
802function MatteStatus({ take, background, onStart, onCancel }: { take: Take; background: Background; onStart: () => void; onCancel: () => void }) {
803 const m = take.matte;
804 const cancel = (
805 <button className="btn small" onClick={onCancel}>
806 Cancel
807 </button>
808 );
809 if (!m) return <span className="muted">waiting</span>;
810 if (m.status === 'done')
811 return (
812 <>
813 <span className="ok-text">done{m.height ? ` (${m.height}p)` : ''}</span>
814 {matteBelowSetting(m, background) && (
815 <button className="btn small" onClick={onStart}>
816 Redo at {background.resolution}p
817 </button>
818 )}
819 </>
820 );
821 if (m.status === 'queued')
822 return (
823 <>
824 <span className="muted">queued</span>
825 {cancel}
826 </>
827 );
828 if (m.status === 'running')
829 return (
830 <>
831 <span className="muted">{m.stage ?? 'Starting'}</span>
832 {m.progress !== undefined && (
833 <>
834 <div className="progress mini">
835 <div style={{ width: `${Math.round(m.progress * 100)}%` }} />
836 </div>
837 <span className="muted">{Math.round(m.progress * 100)}%</span>
838 </>
839 )}
840 {cancel}
841 </>
842 );
843 if (m.status === 'cancelled')
844 return (
845 <>
846 <span className="muted">cancelled</span>
847 <button className="btn small" onClick={onStart}>
848 Start
849 </button>
850 </>
851 );
852 return (
853 <>
854 <span className="error-text" title={m.error}>
855 failed
856 </span>
857 <button className="btn small" onClick={onStart}>
858 Retry
859 </button>
860 </>
861 );
862}
864function PlayButton({ player }: { player: TimelinePlayer }) {
865 const playing = usePlayerPlaying(player);
866 return (
867 <button className="icon-btn play" title="Play/pause (Space)" onClick={() => player.toggle()}>
868 <Icon name={playing ? 'pause' : 'play'} size={18} />
869 </button>
870 );
871}
873function TimeDisplay({ player, duration }: { player: TimelinePlayer; duration: number }) {
874 const time = usePlayerTime(player);
875 return (
876 <span className="timecode">
877 {fmtTimecode(time)} <span className="muted">/ {fmtTimecode(duration)}</span>
878 </span>
879 );
880}
882function TakeCard({
883 take,
884 n,
885 inTimeline,
886 cropped,
887 onAdd,
888 onCrop,
889 onDelete,
890 onRecover,
891}: {
892 take: Take;
893 n: number;
894 inTimeline: boolean;
895 cropped: boolean;
896 onAdd: () => void;
897 onCrop: () => void;
898 onDelete: () => void;
899 onRecover: () => void;
900}) {
901 return (
902 <div className="take" style={{ ['--hue' as string]: takeHue(n) }}>
903 <div className="take-head">
904 <span className="take-swatch" />
905 <strong>Take {n}</strong>
906 <span className="kinds">
907 {take.tracks.camera && <Icon name="camera" size={14} />}
908 {take.tracks.screen && <Icon name="screen" size={14} />}
909 {take.audio && <Icon name="mic" size={14} />}
910 {take.systemAudio && <Icon name="speaker" size={14} />}
911 </span>
912 </div>
913 <div className="small muted">
914 {fmtDateTime(take.createdAt)}
915 {take.status === 'ready' && ` · ${fmtDuration(take.duration)}`}
916 {take.transcript?.status === 'done' && ' · transcribed'}
917 {take.transcript?.status === 'pending' && ' · transcribing…'}
918 {cropped && ' · cropped'}
919 {take.matte?.status === 'done' && ' · background removed'}
920 {(take.matte?.status === 'queued' || take.matte?.status === 'running') && ' · removing background…'}
921 </div>
922 {take.status === 'processing' && <div className="small">Processing…</div>}
923 {(take.status === 'recording' || take.status === 'failed') && (
924 <div className="small warn-text">{take.status === 'failed' ? `Failed: ${take.error}` : 'Interrupted recording (not finalized)'}</div>
925 )}
926 <div className="take-actions">
927 {take.status === 'ready' && (
928 <button className="btn small" onClick={onAdd} title="Append the whole take to the end of the timeline">
929 <Icon name="plus" /> {inTimeline ? 'Add again' : 'Add to timeline'}
930 </button>
931 )}
932 {take.status === 'ready' && (take.tracks.screen || take.tracks.camera) && (
933 <button className="btn small" onClick={onCrop} title="Crop the screen or camera of this take">
934 <Icon name="crop" /> Crop
935 </button>
936 )}
937 {(take.status === 'recording' || take.status === 'failed') && (
938 <button className="btn small" onClick={onRecover} title="Try to finalize whatever was recorded">
939 Recover
940 </button>
941 )}
942 <button className="icon-btn danger" onClick={onDelete} title="Delete take">
943 <Icon name="trash" />
944 </button>
945 </div>
946 </div>
947 );
948}