/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / src / pages / VideoPage.tsx
835 lines · 34.5 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 { 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 { 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 retryMatte = 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 openCrop = (t: Take) => {
339 // Show the frame under the playhead if it is in this take.
340 const loc = locate(tl, now());
341 setCropping({ take: t, time: loc && tl[loc.index].takeId === t.id ? loc.takeTime : 0 });
342 };
343 // Replace this take's crop; with applyToAll, also give each track's crop to every other take that has that track.
344 const saveCrop = (take: Take, crop: TakeCrop, applyToAll: boolean) => {
345 const crops = { ...video.crops };
346 const targets = applyToAll ? readyTakes : [take];
347 for (const t of targets) {
348 const next: TakeCrop = { ...crops[t.id] };
349 for (const track of ['screen', 'camera'] as const) {
350 if (!take.tracks[track] || !t.tracks[track]) continue;
351 if (crop[track]) next[track] = crop[track];
352 else delete next[track];
353 }
354 if (next.screen || next.camera) crops[t.id] = next;
355 else delete crops[t.id];
356 }
357 patch({ crops }, 200);
358 };
359 const cameraTakes = readyTakes.filter((t) => t.tracks.camera);
360 // Takes in the timeline whose camera appears in the output but has not had its background removed yet.
361 const matteBlocking =
362 background.mode === 'none'
363 ? []
364 : [...new Set(video.timeline.map((s) => s.takeId))]
365 .map((id) => video.takes.find((t) => t.id === id)!)
366 .filter((t) => t && t.matte?.status !== 'done' && computeGeometry(video.layout, video.output, t.tracks.screen, t.tracks.camera).camera);
368 const anyBoth = readyTakes.some((t) => t.tracks.screen && t.tracks.camera);
370 return (
371 <div className="page editor">
372 <header className="topbar">
373 <a className="btn ghost" href="#/" onClick={() => flush()}>
374 <Icon name="back" /> Videos
375 </a>
376 <input className="title-input" value={video.title} onChange={(e) => patch({ title: e.target.value })} aria-label="Title" />
377 <span className={`save-state ${saveState}`}>{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Not saved' : 'Saved'}</span>
378 <div className="spacer" />
379 <button className="btn ghost" onClick={() => api.openFolder(videoId).catch((e) => setError(e.message))} title="Open this video's folder">
380 <Icon name="folder" /> Folder
381 </button>
382 <button
383 className="btn record"
384 onClick={async () => {
385 await flush();
386 navigate(`video/${videoId}/record`);
387 }}
388 >
389 <Icon name="record" /> Record take
390 </button>
391 </header>
393 {error && (
394 <div className="banner error" onClick={() => setError(null)}>
395 {error} <span className="muted">(click to dismiss)</span>
396 </div>
397 )}
398 {notice && <div className="toast">{notice}</div>}
400 <div className="editor-grid">
401 <section className="editor-main">
402 {video.takes.length === 0 ? (
403 <div className="empty">
404 <h2>No takes yet</h2>
405 <p className="muted">Record your camera, your screen, or both. You can record several takes and combine them.</p>
406 <button className="btn record big" onClick={() => navigate(`video/${videoId}/record`)}>
407 <Icon name="record" /> Record first take
408 </button>
409 </div>
410 ) : (
411 <>
412 <Stage video={video} player={player} />
413 <div className="transport">
414 <PlayButton player={player} />
415 <TimeDisplay player={player} duration={duration} />
416 <div className="sep" />
417 <button className="icon-btn" title="Undo (Ctrl+Z)" disabled={history.length === 0} onClick={undo}>
418 <Icon name="undo" />
419 </button>
420 <button className="icon-btn" title="Redo (Ctrl+Shift+Z)" disabled={future.length === 0} onClick={redo}>
421 <Icon name="redo" />
422 </button>
423 <div className="sep" />
424 <button className="icon-btn" title="Split at playhead (S)" onClick={split}>
425 <Icon name="split" />
426 </button>
427 <button className="icon-btn" title="Trim: cut from start of this segment to playhead" onClick={trimStart}>
428 <Icon name="trimStart" />
429 </button>
430 <button className="icon-btn" title="Trim: cut from playhead to end of this segment" onClick={trimEnd}>
431 <Icon name="trimEnd" />
432 </button>
433 <div className="sep" />
434 <button className="btn small" title="Mark in (I)" onClick={markIn}>
435 In
436 </button>
437 <button className="btn small" title="Mark out (O)" onClick={markOut}>
438 Out
439 </button>
440 <button className="btn small danger" title="Cut marked range (X)" disabled={!hasRange} onClick={cutMarked}>
441 <Icon name="scissors" /> Cut {hasRange ? fmtTimecode(marks.out! - marks.in!) : ''}
442 </button>
443 <div className="sep" />
444 <button className="icon-btn" title="Move selected segment earlier" disabled={!selectedId} onClick={() => moveSelected(-1)}>
445 <Icon name="left" />
446 </button>
447 <button className="icon-btn" title="Move selected segment later" disabled={!selectedId} onClick={() => moveSelected(1)}>
448 <Icon name="right" />
449 </button>
450 <button className="icon-btn danger" title="Remove selected segment (Delete)" disabled={!selectedId} onClick={deleteSelected}>
451 <Icon name="trash" />
452 </button>
453 <div className="spacer" />
454 <label className="zoom" title="Timeline zoom">
455 <span className="muted small">Zoom</span>
456 <input type="range" min={1} max={20} step={0.5} value={zoom} onChange={(e) => setZoom(parseFloat(e.target.value))} />
457 </label>
458 <button className="icon-btn" title="Use current frame as thumbnail" onClick={setThumbnail}>
459 <Icon name="image" />
460 </button>
461 </div>
462 <Timeline segments={video.timeline} takeNumber={takeNumber} player={player} selectedId={selectedId} onSelect={setSelectedId} marks={marks} peaks={peaks} zoom={zoom} />
463 <p className="shortcuts muted small">
464 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
465 </p>
467 <h3 className="section-title">Takes</h3>
468 <div className="takes">
469 {video.takes.map((t) => (
470 <TakeCard
471 key={t.id}
472 take={t}
473 n={takeNumber(t.id)}
474 inTimeline={video.timeline.some((s) => s.takeId === t.id)}
475 cropped={!!(video.crops?.[t.id]?.screen || video.crops?.[t.id]?.camera)}
476 onAdd={() => addToTimeline(t)}
477 onCrop={() => openCrop(t)}
478 onDelete={() => askDeleteTake(t)}
479 onRecover={() => replaceFromServer(() => api.finalizeTake(videoId, t.id))}
480 />
481 ))}
482 </div>
483 </>
484 )}
485 </section>
487 <aside className="editor-side">
488 <div className="tabs">
489 <button className={tab === 'details' ? 'active' : ''} onClick={() => setTab('details')}>
490 Details
491 </button>
492 <button className={tab === 'transcript' ? 'active' : ''} onClick={() => setTab('transcript')}>
493 Transcript
494 </button>
495 <button className={tab === 'export' ? 'active' : ''} onClick={() => setTab('export')}>
496 Layout &amp; export
497 </button>
498 </div>
500 {tab === 'details' && (
501 <div className="panel">
502 <label className="field">
503 <span>Description</span>
504 <textarea rows={4} value={video.description} onChange={(e) => patch({ description: e.target.value })} />
505 </label>
506 <label className="field grow">
507 <span>Notes (teleprompter)</span>
508 <textarea className="notes" value={video.notes} onChange={(e) => patch({ notes: e.target.value })} />
509 </label>
510 <p className="muted small">Created {fmtDateTime(video.createdAt)}</p>
511 </div>
512 )}
514 {tab === 'transcript' && (
515 <div className="panel">
516 <TranscriptPanel
517 segments={video.timeline}
518 takes={video.takes}
519 transcripts={transcripts}
520 takeNumber={takeNumber}
521 player={player}
522 marks={marks}
523 onMarks={setMarks}
524 onCut={cutMarked}
525 onTranscribe={transcribe}
526 hasKey={!!config?.hasDeepgramKey}
527 />
528 </div>
529 )}
531 {tab === 'export' && (
532 <div className="panel">
533 <h4>Camera and screen layout</h4>
534 {!anyBoth && <p className="muted small">These apply to takes that have both camera and screen.</p>}
535 <div className="seg-control">
536 {(
537 [
538 ['pip', 'Picture in picture'],
539 ['side-by-side', 'Side by side'],
540 ['screen-only', 'Screen'],
541 ['camera-only', 'Camera'],
542 ] as const
543 ).map(([m, label]) => (
544 <button key={m} className={video.layout.mode === m ? 'active' : ''} onClick={() => setLayout({ mode: m })}>
545 {label}
546 </button>
547 ))}
548 </div>
549 {(video.layout.mode === 'pip' || video.layout.mode === 'side-by-side') && (
550 <div className="field">
551 <span>{video.layout.mode === 'pip' ? 'Corner' : 'Camera side'}</span>
552 <div className="seg-control">
553 {(video.layout.mode === 'pip'
554 ? ([
555 ['tl', 'Top left'],
556 ['tr', 'Top right'],
557 ['bl', 'Bottom left'],
558 ['br', 'Bottom right'],
559 ] as const)
560 : ([
561 ['bl', 'Left'],
562 ['br', 'Right'],
563 ] as const)
564 ).map(([c, label]) => (
565 <button
566 key={c}
567 className={(video.layout.mode === 'pip' ? video.layout.corner === c : isLeft(video.layout.corner) === isLeft(c)) ? 'active' : ''}
568 onClick={() => setLayout({ corner: c })}
569 >
570 {label}
571 </button>
572 ))}
573 </div>
574 </div>
575 )}
576 {video.layout.mode === 'pip' && (
577 <>
578 <label className="field">
579 <span>Camera size ({Math.round(video.layout.size * 100)}% of width)</span>
580 <input type="range" min={0.12} max={0.45} step={0.01} value={video.layout.size} onChange={(e) => setLayout({ size: parseFloat(e.target.value) })} />
581 </label>
582 <div className="field">
583 <span>Shape</span>
584 <div className="seg-control">
585 {(
586 [
587 ['rect', 'Rectangle'],
588 ['rounded', 'Rounded'],
589 ['circle', 'Circle'],
590 ] as const
591 ).map(([s, label]) => (
592 <button key={s} className={video.layout.shape === s ? 'active' : ''} onClick={() => setLayout({ shape: s })}>
593 {label}
594 </button>
595 ))}
596 </div>
597 </div>
598 </>
599 )}
601 {cameraTakes.length > 0 && (
602 <>
603 <h4>Background behind you</h4>
604 <div className="seg-control">
605 {(
606 [
607 ['none', 'Original'],
608 ['blur', 'Blur'],
609 ['color', 'Color'],
610 ['cutout', 'Cutout'],
611 ] as const
612 ).map(([m, label]) => (
613 <button
614 key={m}
615 className={background.mode === m ? 'active' : ''}
616 title={m === 'cutout' ? 'Picture in picture only: just you, over the screen' : undefined}
617 onClick={() => setBackground({ mode: m })}
618 >
619 {label}
620 </button>
621 ))}
622 </div>
623 {background.mode === 'cutout' && video.layout.mode !== 'pip' && (
624 <p className="muted small">Cutout applies to picture in picture; with this layout the color below is used instead.</p>
625 )}
626 {(background.mode === 'color' || (background.mode === 'cutout' && video.layout.mode !== 'pip')) && (
627 <label className="row">
628 <span className="small muted">Color</span>
629 <input type="color" value={background.color} onChange={(e) => setBackground({ color: e.target.value })} />
630 </label>
631 )}
632 {background.mode === 'blur' && (
633 <label className="field">
634 <span>Blur strength</span>
635 <input type="range" min={4} max={50} step={1} value={background.blur} onChange={(e) => setBackground({ blur: parseInt(e.target.value) })} />
636 </label>
637 )}
638 {background.mode !== 'none' && (
639 <div className="matte-status">
640 {cameraTakes.map((t) => (
641 <div key={t.id} className="row small">
642 <span>Take {takeNumber(t.id)}:</span>
643 <MatteStatus take={t} onRetry={() => retryMatte(t.id)} />
644 </div>
645 ))}
646 <p className="muted small">Background removal runs on this computer (Robust Video Matting) and is done once per take.</p>
647 </div>
648 )}
649 </>
650 )}
652 <h4>Output</h4>
653 <div className="field">
654 <span>Resolution</span>
655 <div className="seg-control">
656 {([720, 1080] as const).map((h) => (
657 <button key={h} className={video.output.height === h ? 'active' : ''} onClick={() => setOutput({ height: h })}>
658 {h}p
659 </button>
660 ))}
661 </div>
662 </div>
663 <label className="check">
664 <input type="checkbox" checked={video.output.normalizeAudio} onChange={(e) => setOutput({ normalizeAudio: e.target.checked })} />
665 Normalize loudness (−16 LUFS)
666 </label>
668 <div className="export-box">
669 {job?.status === 'running' ? (
670 <>
671 <div className="progress">
672 <div style={{ width: `${Math.round(job.progress * 100)}%` }} />
673 </div>
674 <p className="small muted">Exporting… {Math.round(job.progress * 100)}%</p>
675 </>
676 ) : (
677 <button
678 className="btn primary"
679 disabled={video.timeline.length === 0 || matteBlocking.length > 0}
680 title={matteBlocking.length > 0 ? 'Waiting for background removal to finish' : undefined}
681 onClick={startExport}
682 >
683 <Icon name="film" /> {video.export ? 'Export again' : 'Export MP4'}
684 </button>
685 )}
686 {job?.status !== 'running' && matteBlocking.length > 0 && (
687 <p className="small muted">Export will be available once background removal has finished for the takes in the timeline.</p>
688 )}
689 {job?.status === 'failed' && <p className="small error-text">Export failed: {job.error}</p>}
690 {video.export && (
691 <div className="export-info">
692 <div>
693 <a href={`/media/${videoId}/${video.export.file}`} target="_blank" rel="noreferrer">
694 {video.export.file}
695 </a>{' '}
696 <span className="muted small">
697 {fmtDuration(video.export.duration)} · {fmtBytes(video.export.bytes)} · {fmtDateTime(video.export.exportedAt)}
698 </span>
699 </div>
700 {stale && <p className="small warn-text">The timeline, layout, or crop changed since this export.</p>}
701 <button className="btn small" onClick={() => api.openFolder(videoId).catch((e) => setError(e.message))}>
702 <Icon name="folder" /> Show in folder
703 </button>
704 </div>
705 )}
706 </div>
707 </div>
708 )}
709 </aside>
710 </div>
712 {confirm && <ConfirmDialog req={confirm} onClose={() => setConfirm(null)} />}
713 {cropping && (
714 <CropDialog
715 video={video}
716 take={cropping.take}
717 initialTime={cropping.time}
718 onSave={(crop, all) => saveCrop(cropping.take, crop, all)}
719 onClose={() => setCropping(null)}
720 />
721 )}
722 </div>
723 );
726function MatteStatus({ take, onRetry }: { take: Take; onRetry: () => void }) {
727 const m = take.matte;
728 if (!m) return <span className="muted">waiting</span>;
729 if (m.status === 'done') return <span className="ok-text">done</span>;
730 if (m.status === 'queued') return <span className="muted">queued</span>;
731 if (m.status === 'running')
732 return (
733 <>
734 <div className="progress mini">
735 <div style={{ width: `${Math.round((m.progress ?? 0) * 100)}%` }} />
736 </div>
737 <span className="muted">{Math.round((m.progress ?? 0) * 100)}%</span>
738 </>
739 );
740 return (
741 <>
742 <span className="error-text" title={m.error}>
743 failed
744 </span>
745 <button className="btn small" onClick={onRetry}>
746 Retry
747 </button>
748 </>
749 );
752function PlayButton({ player }: { player: TimelinePlayer }) {
753 const playing = usePlayerPlaying(player);
754 return (
755 <button className="icon-btn play" title="Play/pause (Space)" onClick={() => player.toggle()}>
756 <Icon name={playing ? 'pause' : 'play'} size={18} />
757 </button>
758 );
761function TimeDisplay({ player, duration }: { player: TimelinePlayer; duration: number }) {
762 const time = usePlayerTime(player);
763 return (
764 <span className="timecode">
765 {fmtTimecode(time)} <span className="muted">/ {fmtTimecode(duration)}</span>
766 </span>
767 );
770function TakeCard({
771 take,
772 n,
773 inTimeline,
774 cropped,
775 onAdd,
776 onCrop,
777 onDelete,
778 onRecover,
779}: {
780 take: Take;
781 n: number;
782 inTimeline: boolean;
783 cropped: boolean;
784 onAdd: () => void;
785 onCrop: () => void;
786 onDelete: () => void;
787 onRecover: () => void;
788}) {
789 return (
790 <div className="take" style={{ ['--hue' as string]: takeHue(n) }}>
791 <div className="take-head">
792 <span className="take-swatch" />
793 <strong>Take {n}</strong>
794 <span className="kinds">
795 {take.tracks.camera && <Icon name="camera" size={14} />}
796 {take.tracks.screen && <Icon name="screen" size={14} />}
797 {take.audio && <Icon name="mic" size={14} />}
798 </span>
799 </div>
800 <div className="small muted">
801 {fmtDateTime(take.createdAt)}
802 {take.status === 'ready' && ` · ${fmtDuration(take.duration)}`}
803 {take.transcript?.status === 'done' && ' · transcribed'}
804 {take.transcript?.status === 'pending' && ' · transcribing…'}
805 {cropped && ' · cropped'}
806 {take.matte?.status === 'done' && ' · background removed'}
807 {(take.matte?.status === 'queued' || take.matte?.status === 'running') && ' · removing background…'}
808 </div>
809 {take.status === 'processing' && <div className="small">Processing…</div>}
810 {(take.status === 'recording' || take.status === 'failed') && (
811 <div className="small warn-text">{take.status === 'failed' ? `Failed: ${take.error}` : 'Interrupted recording (not finalized)'}</div>
812 )}
813 <div className="take-actions">
814 {take.status === 'ready' && (
815 <button className="btn small" onClick={onAdd} title="Append the whole take to the end of the timeline">
816 <Icon name="plus" /> {inTimeline ? 'Add again' : 'Add to timeline'}
817 </button>
818 )}
819 {take.status === 'ready' && (take.tracks.screen || take.tracks.camera) && (
820 <button className="btn small" onClick={onCrop} title="Crop the screen or camera of this take">
821 <Icon name="crop" /> Crop
822 </button>
823 )}
824 {(take.status === 'recording' || take.status === 'failed') && (
825 <button className="btn small" onClick={onRecover} title="Try to finalize whatever was recorded">
826 Recover
827 </button>
828 )}
829 <button className="icon-btn danger" onClick={onDelete} title="Delete take">
830 <Icon name="trash" />
831 </button>
832 </div>
833 </div>
834 );
↑↓ move↵openescclose