Takes: record, organize, and edit explainer videos
Local web app for recording camera and/or screen with a teleprompter,
editing takes on a timeline (split, cut, trim, join, transcript-based cuts
via Deepgram), optional background removal with Robust Video Matting, and
exporting to MP4 with ffmpeg.
40 changed files+8267−0
.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules/
2+dist/
README.mdadded+73−0View file
@@ -0,0 +1,73 @@
1+# Takes
2+
3+Takes is a small local application for recording, organizing, and editing short explanatory videos on Linux. A video can contain camera footage of the speaker, a screen recording, or both at once. Each video has a title, a description, and notes, and it is built from one or more recorded takes that are trimmed, cut, and joined on a timeline before being rendered to MP4.
4+
5+We wrote it for a specific workflow: explaining or demonstrating something in a few minutes, possibly over several attempts, with as little friction as possible between having an idea and having a shareable file. Unlike general-purpose tools such as OBS (recording) combined with Kdenlive (editing), Takes keeps the library, the recorder, and a deliberately limited editor in one place. The trade-off is that it does much less: there are no scenes, transitions, titles, or multi-track audio.
6+
7+## Requirements
8+
9+- Node.js 22.18 or later (the server runs TypeScript directly through Node's type stripping)
10+- ffmpeg and ffprobe on the `PATH`
11+- Google Chrome or Chromium (recommended; Firefox also works for recording)
12+
13+## Running
14+
15+```sh
16+npm install
17+npm run build
18+npm start # or: node server/index.ts
19+```
20+
21+To install a `takes` command:
22+
23+```sh
24+npm link
25+takes # opens a Chrome app window at http://localhost:4455
26+```
27+
28+Options: `--dir DIR` (library directory, default `~/Videos/takes`), `--port PORT` (default 4455), `--no-open`, and `--browser` (use the default browser instead of a Chrome app window). If Takes is already running, `takes` only opens a new window.
29+
30+For development, `npm run dev` runs the server on port 4455 with automatic restarts and the Vite dev server on http://localhost:5173.
31+
32+## Using it
33+
34+The library shows every video with its thumbnail, duration, and description. Drag cards to reorder them. Deleting a video (with confirmation) moves its folder to `.trash/` inside the library, so a mistaken deletion can be undone by hand.
35+
36+**Recording.** Choose any combination of camera, microphone, and screen, then start recording. The screen picker is GNOME's own portal dialog, which lets you choose a monitor or a single window. A countdown precedes the recording, and it can be paused and resumed. The *Teleprompter window* button opens the notes in a separate window with large text, optional auto-scroll, and start/pause/stop controls, so it can sit on a second monitor or outside the captured window. Recorded data is uploaded to the server once per second while recording, so a browser crash loses at most about a second; an interrupted take appears on the video page with a *Recover* button.
37+
38+**Editing.** Each video has a timeline made of segments of its takes. A new take is appended to the end of the timeline, and a take can be added again from the *Takes* list. The editor supports splitting at the playhead (S), marking a range (I and O) and cutting it (X), trimming the current segment to the playhead, removing and reordering segments, and undo/redo. The timeline shows the audio waveform, and a zoom slider helps with precise cuts. Editing is non-destructive: the raw takes are never modified.
39+
40+**Transcripts.** With a Deepgram API key (set it on the Settings page), each take's audio is transcribed after recording. The *Transcript* tab shows the words that remain in the timeline. Clicking a word seeks to it, and shift-clicking a second word selects the range between them, which can then be cut. This is often the quickest way to remove a false start or a filler word.
41+
42+**Layout and export.** For takes that have both camera and screen, the camera can be placed as picture-in-picture (corner, size, and rectangle, rounded, or circle shape), side by side, or hidden. The preview in the editor uses the same geometry as the export. Export renders the timeline to an H.264/AAC MP4 at 720p or 1080p, optionally with loudness normalization to −16 LUFS, and marks the export as outdated when the timeline or layout changes afterwards.
43+
44+**Background removal.** In *Layout & export*, the background behind you can be kept, blurred, replaced with a solid color, or removed entirely (*Cutout*, for picture-in-picture, where only you appear over the screen recording, sitting on the bottom edge of the video). Choosing any of these processes the camera track of each take with Robust Video Matting (Lin et al., 2021), a recurrent segmentation model designed for video, which gives soft edges around hair and little flicker between frames. The model runs on the CPU through onnxruntime; takes are processed one at a time in the background, and the result is stored as `cutout.webm` (VP9 with an alpha channel) so that each take is processed only once. The model (about 15 MB) is downloaded from GitHub to `~/.cache/takes/models/` the first time it is needed. Export waits until every take in the timeline has been processed.
45+
46+## Storage layout
47+
48+```
49+~/Videos/takes/
50+ library.json order of videos
51+ config.json settings, including the Deepgram key (mode 0600)
52+ <video-id>/
53+ video.json title, description, notes, takes, timeline, layout
54+ thumbnail.jpg
55+ <title>.mp4 latest export
56+ takes/<take-id>/
57+ screen.webm camera.webm raw recordings (mic audio is in camera.webm when there is a camera)
58+ cutout.webm camera with background removed (VP9 + alpha), if requested
59+ peaks.json waveform data
60+ transcript.json Deepgram words with timestamps
61+```
62+
63+Everything is plain files, so videos can be backed up, synced, or inspected without the application.
64+
65+## Limitations
66+
67+- Screen and camera are recorded by two independent MediaRecorders started at the same instant. In our tests they stay aligned to within a frame, but we have not measured drift over recordings longer than about 20 minutes.
68+- Screen capture on Wayland depends on the browser's PipeWire support. Recent Chrome enables it by default; if the picker does not appear or the capture is black, check `chrome://flags/#enable-webrtc-pipewire-capturer`.
69+- Only the microphone is recorded; system audio (for example, sound from a demo) is not captured.
70+- The camera layout and background are set per video, not per segment.
71+- Background removal is a CPU-bound step and on a laptop it runs slower than real time; the matting quality also degrades with a cluttered background, poor lighting, or objects held close to the body, which the model may partly treat as background.
72+- Export speed depends on the CPU; with the `veryfast` x264 preset, a 1080p picture-in-picture export ran at roughly 3× real time on the development machine.
73+- The server listens only on 127.0.0.1 and rejects requests addressed to other host names, but it has no authentication beyond that; it is meant for a single user on their own machine.
bin/takes.jsadded+3−0View file
@@ -0,0 +1,3 @@
1+#!/usr/bin/env node
2+// Entry point for the `takes` command (install with `npm link`).
3+import '../server/index.ts';
index.htmladded+13−0View file
@@ -0,0 +1,13 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
7+ <title>Takes</title>
8+ </head>
9+ <body>
10+ <div id="root"></div>
11+ <script type="module" src="/src/main.tsx"></script>
12+ </body>
13+</html>
package-lock.jsonadded+2731−0View file
This diff is 2,736 lines long and is not shown.
package.jsonadded+35−0View file
@@ -0,0 +1,35 @@
1+{
2+ "name": "takes",
3+ "version": "0.1.0",
4+ "private": true,
5+ "description": "Record, organize, and edit explainer videos (camera, screen, or both) on Linux.",
6+ "type": "module",
7+ "bin": {
8+ "takes": "bin/takes.js"
9+ },
10+ "scripts": {
11+ "dev": "concurrently -k -n server,web -c blue,green \"node --watch-path=server --watch-path=shared server/index.ts --no-open --port 4455\" \"vite\"",
12+ "build": "vite build",
13+ "start": "node server/index.ts",
14+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.server.json"
15+ },
16+ "engines": {
17+ "node": ">=22.18"
18+ },
19+ "dependencies": {
20+ "express": "^5.2.1",
21+ "onnxruntime-node": "^1.30.0",
22+ "react": "^19.3.0",
23+ "react-dom": "^19.3.0"
24+ },
25+ "devDependencies": {
26+ "@types/express": "^5.0.6",
27+ "@types/node": "^26.6.3",
28+ "@types/react": "^19.3.0",
29+ "@types/react-dom": "^19.3.0",
30+ "@vitejs/plugin-react": "^6.1.1",
31+ "concurrently": "^10.0.5",
32+ "typescript": "^7.0.2",
33+ "vite": "^8.3.1"
34+ }
35+}
public/favicon.svgadded+1−0View file
@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#1c1f26"/><circle cx="16" cy="16" r="7" fill="#ef4444"/></svg>
server/export.tsadded+169−0View file
@@ -0,0 +1,169 @@
1+// Render the timeline to an MP4 with ffmpeg.
2+//
3+// Each segment becomes a sub-graph: a black canvas of the output size, the screen track fit
4+// into its box, the camera track (optionally masked to a rounded rect or circle) overlaid, and
5+// the mic audio trimmed/padded to exactly the segment length. The segments are then concatenated.
6+
7+import fs from 'node:fs/promises';
8+import path from 'node:path';
9+import type { ExportJob, Video } from '../shared/types.ts';
10+import { exportSignature } from '../shared/types.ts';
11+import { FPS, computeGeometry } from '../shared/layout.ts';
12+import { timelineDuration } from '../shared/timeline.ts';
13+import { fileSize, run } from './media.ts';
14+import { HttpError, getVideo, slugify, takeDir, updateVideo, videoDir } from './store.ts';
15+
16+const jobs = new Map<string, ExportJob>();
17+
18+export const getExportJob = (videoId: string) => jobs.get(videoId) ?? null;
19+
20+const f3 = (x: number) => x.toFixed(3);
21+
22+// Alpha mask for a w x h box with corner radius r (r = w/2 on a square gives a circle), anti-aliased.
23+function alphaExpr(r: number) {
24+ return `255*clip(${r}-hypot(max(abs(X+0.5-W/2)-(W/2-${r}),0),max(abs(Y+0.5-H/2)-(H/2-${r}),0))+0.5,0,1)`;
25+}
26+
27+export function buildExportArgs(v: Video, outFile: string): string[] {
28+ if (v.timeline.length === 0) throw new HttpError(400, 'The timeline is empty');
29+ const inputs: string[] = [];
30+ const filters: string[] = [];
31+ let concat = '';
32+ let nIn = 0;
33+ const addInput = (file: string, start: number, dur: number, decoder?: string) => {
34+ inputs.push(...(decoder ? ['-c:v', decoder] : []), '-ss', f3(start), '-t', f3(dur), '-i', file);
35+ return nIn++;
36+ };
37+
38+ v.timeline.forEach((seg, i) => {
39+ const take = v.takes.find((t) => t.id === seg.takeId);
40+ if (!take || take.status !== 'ready') throw new HttpError(400, `Timeline refers to a missing or unfinished take (${seg.takeId})`);
41+ const D = seg.end - seg.start;
42+ const dir = takeDir(v.id, take.id);
43+ const g = computeGeometry(v.layout, v.output, take.tracks.screen, take.tracks.camera);
44+ const idx: Record<string, number> = {};
45+ const bgMode = g.camera?.background ?? 'none';
46+ if (bgMode !== 'none' && take.matte?.status !== 'done') {
47+ throw new HttpError(409, `Background removal has not finished for take ${v.takes.indexOf(take) + 1}`);
48+ }
49+ if (g.screen) idx.screen = addInput(path.join(dir, 'screen.webm'), seg.start, D);
50+ // The cutout has an alpha channel, which only the libvpx decoder preserves.
51+ if (bgMode !== 'none') idx.cutout = addInput(path.join(dir, 'cutout.webm'), seg.start, D, 'libvpx-vp9');
52+ if (g.camera && (bgMode === 'none' || bgMode === 'blur')) idx.camera = addInput(path.join(dir, 'camera.webm'), seg.start, D);
53+ if (take.audio && idx[take.audio] === undefined) idx[take.audio] = addInput(path.join(dir, `${take.audio}.webm`), seg.start, D);
54+
55+ filters.push(`color=c=black:s=${g.W}x${g.H}:r=${FPS}:d=${f3(D)}[bg${i}]`);
56+ let cur = `bg${i}`;
57+ if (g.screen) {
58+ const b = g.screen;
59+ filters.push(
60+ `[${idx.screen}:v]fps=${FPS}:start_time=0,scale=${b.w}:${b.h}:force_original_aspect_ratio=decrease:force_divisible_by=2,` +
61+ `pad=${b.w}:${b.h}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1[s${i}]`,
62+ );
63+ filters.push(`[${cur}][s${i}]overlay=${b.x}:${b.y}:eof_action=repeat[os${i}]`);
64+ cur = `os${i}`;
65+ }
66+ if (g.camera) {
67+ const { box: b, fit, radius, background, color, blurSigma } = g.camera;
68+ const fitTo = (transparentPad: boolean) =>
69+ fit === 'cover'
70+ ? `scale=${b.w}:${b.h}:force_original_aspect_ratio=increase,crop=${b.w}:${b.h}`
71+ : `scale=${b.w}:${b.h}:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=${b.w}:${b.h}:(ow-iw)/2:(oh-ih)/2:color=${transparentPad ? 'black@0' : 'black'}`;
72+ if (background === 'none') {
73+ filters.push(`[${idx.camera}:v]fps=${FPS}:start_time=0,${fitTo(false)},setsar=1[cam${i}]`);
74+ } else {
75+ filters.push(`[${idx.cutout}:v]fps=${FPS}:start_time=0,format=yuva420p,${fitTo(true)},setsar=1[fg${i}]`);
76+ if (background === 'blur') filters.push(`[${idx.camera}:v]fps=${FPS}:start_time=0,${fitTo(false)},setsar=1,gblur=sigma=${blurSigma}[cb${i}]`);
77+ if (background === 'color') filters.push(`color=c=0x${color.replace('#', '')}:s=${b.w}x${b.h}:r=${FPS}:d=${f3(D)},setsar=1[cb${i}]`);
78+ if (background === 'cutout') filters.push(`[fg${i}]null[cam${i}]`);
79+ else filters.push(`[cb${i}][fg${i}]overlay=0:0:eof_action=repeat[cam${i}]`);
80+ }
81+ if (radius > 0) {
82+ // Compute the mask for a single frame and loop it (a per-frame geq is several times slower).
83+ filters.push(
84+ `color=c=black:s=${b.w}x${b.h}:r=${FPS}:d=1,format=gray,geq=lum='${alphaExpr(radius)}',trim=end_frame=1,` +
85+ `loop=loop=-1:size=1:start=0,setpts=N/${FPS}/TB,trim=duration=${f3(D)}[m${i}]`,
86+ );
87+ filters.push(`[cam${i}]format=yuva420p[cm${i}]`);
88+ filters.push(`[cm${i}][m${i}]alphamerge[c${i}]`);
89+ } else {
90+ filters.push(`[cam${i}]null[c${i}]`);
91+ }
92+ filters.push(`[${cur}][c${i}]overlay=${b.x}:${b.y}:eof_action=repeat[oc${i}]`);
93+ cur = `oc${i}`;
94+ }
95+ filters.push(`[${cur}]format=yuv420p[v${i}]`);
96+
97+ const fade = Math.min(0.015, D / 4);
98+ if (take.audio) {
99+ filters.push(
100+ `[${idx[take.audio]}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,apad,atrim=0:${f3(D)},asetpts=PTS-STARTPTS,` +
101+ `afade=t=in:d=${f3(fade)},afade=t=out:st=${f3(D - fade)}:d=${f3(fade)}[a${i}]`,
102+ );
103+ } else {
104+ filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${f3(D)}[a${i}]`);
105+ }
106+ concat += `[v${i}][a${i}]`;
107+ });
108+
109+ filters.push(`${concat}concat=n=${v.timeline.length}:v=1:a=1[vout][acat]`);
110+ filters.push(v.output.normalizeAudio ? `[acat]loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000[aout]` : `[acat]anull[aout]`);
111+
112+ return [
113+ '-y', '-v', 'error', '-nostats', '-progress', 'pipe:1',
114+ ...inputs,
115+ '-filter_complex', filters.join(';'),
116+ '-map', '[vout]', '-map', '[aout]',
117+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-r', String(FPS),
118+ '-c:a', 'aac', '-b:a', '160k',
119+ '-movflags', '+faststart',
120+ outFile,
121+ ];
122+}
123+
124+export async function startExport(videoId: string): Promise<ExportJob> {
125+ const running = jobs.get(videoId);
126+ if (running?.status === 'running') return running;
127+ const v = await getVideo(videoId);
128+ const signature = exportSignature(v);
129+ const duration = timelineDuration(v.timeline);
130+ const name = `${slugify(v.title) || 'video'}.mp4`;
131+ const dir = videoDir(videoId);
132+ const tmp = path.join(dir, `.export-${Date.now()}.mp4`);
133+ const args = buildExportArgs(v, tmp);
134+
135+ const job: ExportJob = { status: 'running', progress: 0, startedAt: new Date().toISOString() };
136+ jobs.set(videoId, job);
137+
138+ (async () => {
139+ try {
140+ let buf = '';
141+ await run('ffmpeg', args, {
142+ onStdout: (d) => {
143+ buf += d;
144+ const lines = buf.split('\n');
145+ buf = lines.pop() ?? '';
146+ for (const line of lines) {
147+ const m = /^out_time_us=(\d+)/.exec(line);
148+ if (m && duration > 0) job.progress = Math.min(0.99, parseInt(m[1]) / 1e6 / duration);
149+ }
150+ },
151+ });
152+ const prev = (await getVideo(videoId)).export;
153+ await fs.rename(tmp, path.join(dir, name));
154+ if (prev && prev.file !== name) await fs.rm(path.join(dir, prev.file), { force: true });
155+ const bytes = await fileSize(path.join(dir, name));
156+ await updateVideo(videoId, (v) => {
157+ v.export = { file: name, exportedAt: new Date().toISOString(), signature, duration, bytes };
158+ });
159+ job.status = 'done';
160+ job.progress = 1;
161+ } catch (e) {
162+ job.status = 'failed';
163+ job.error = (e as Error).message;
164+ console.warn('export failed:', job.error);
165+ await fs.rm(tmp, { force: true });
166+ }
167+ })();
168+ return job;
169+}
server/index.tsadded+237−0View file
@@ -0,0 +1,237 @@
1+// Takes: local server for recording, organizing, and editing videos.
2+//
3+// Usage: takes [--dir ~/Videos/takes] [--port 4455] [--no-open] [--browser]
4+
5+import express from 'express';
6+import type { NextFunction, Request, Response } from 'express';
7+import { spawn, spawnSync } from 'node:child_process';
8+import fs from 'node:fs';
9+import os from 'node:os';
10+import path from 'node:path';
11+import { fileURLToPath } from 'node:url';
12+import type { PublicConfig, Video } from '../shared/types.ts';
13+import { getExportJob, startExport } from './export.ts';
14+import { HttpError, checkId, createVideo, getConfig, getVideo, initStore, libraryDir, listVideos, setOrder, trashVideo, updateConfig, updateVideo, videoDir } from './store.ts';
15+import { appendChunk, createTake, deleteTake, finalizeTake, setThumbnail } from './takes.ts';
16+import { transcribeTake } from './transcribe.ts';
17+import { requestMattes, withMatteStatus } from './matte.ts';
18+
19+function parseArgs(argv: string[]) {
20+ const opts = { dir: path.join(os.homedir(), 'Videos', 'takes'), port: 4455, open: true, browser: false };
21+ for (let i = 0; i < argv.length; i++) {
22+ const a = argv[i];
23+ if (a === '--dir') opts.dir = path.resolve(argv[++i].replace(/^~(?=$|\/)/, os.homedir()));
24+ else if (a === '--port') opts.port = parseInt(argv[++i]);
25+ else if (a === '--no-open') opts.open = false;
26+ else if (a === '--browser') opts.browser = true;
27+ else if (a === '-h' || a === '--help') {
28+ console.log('Usage: takes [--dir DIR] [--port PORT] [--no-open] [--browser]\n\n --dir library directory (default ~/Videos/takes)\n --port port (default 4455)\n --no-open do not open a window\n --browser open in the default browser instead of a Chrome app window');
29+ process.exit(0);
30+ }
31+ }
32+ return opts;
33+}
34+
35+const opts = parseArgs(process.argv.slice(2));
36+const here = path.dirname(fileURLToPath(import.meta.url));
37+const distDir = path.join(here, '..', 'dist');
38+
39+for (const tool of ['ffmpeg', 'ffprobe']) {
40+ if (spawnSync(tool, ['-version']).error) {
41+ console.error(`${tool} was not found on PATH; please install ffmpeg.`);
42+ process.exit(1);
43+ }
44+}
45+
46+await initStore(opts.dir);
47+
48+const app = express();
49+
50+// Only accept requests addressed to localhost (guards against DNS rebinding), and require a
51+// custom header on mutating API calls so that other web pages cannot issue them (CSRF).
52+app.use((req, res, next) => {
53+ const host = (req.headers.host ?? '').replace(/:\d+$/, '');
54+ if (!['localhost', '127.0.0.1', '[::1]'].includes(host)) return res.status(403).send('Forbidden host');
55+ if (req.path.startsWith('/api/') && req.method !== 'GET' && req.headers['x-takes'] !== '1') return res.status(403).json({ error: 'Missing X-Takes header' });
56+ next();
57+});
58+
59+const json = express.json({ limit: '5mb' });
60+const raw = express.raw({ type: () => true, limit: '500mb' });
61+
62+const p = (req: Request, name: string) => checkId(String(req.params[name]));
63+
64+app.get('/api/ping', (_req, res) => {
65+ res.json({ app: 'takes' });
66+});
67+
68+app.get('/api/config', async (_req, res) => {
69+ const { deepgramApiKey, ...rest } = await getConfig();
70+ const pub: PublicConfig = { ...rest, hasDeepgramKey: !!deepgramApiKey, libraryDir: libraryDir() };
71+ res.json(pub);
72+});
73+
74+app.put('/api/config', json, async (req, res) => {
75+ const b = req.body ?? {};
76+ const patch: Record<string, unknown> = {};
77+ if (typeof b.deepgramApiKey === 'string') patch.deepgramApiKey = b.deepgramApiKey.trim();
78+ if (typeof b.deepgramModel === 'string') patch.deepgramModel = b.deepgramModel.trim() || 'nova-3';
79+ if (typeof b.autoTranscribe === 'boolean') patch.autoTranscribe = b.autoTranscribe;
80+ if (typeof b.defaultCountdown === 'number') patch.defaultCountdown = Math.max(0, Math.min(10, Math.round(b.defaultCountdown)));
81+ if (b.defaultCameraHeight === 720 || b.defaultCameraHeight === 1080) patch.defaultCameraHeight = b.defaultCameraHeight;
82+ if (b.defaultOutputHeight === 720 || b.defaultOutputHeight === 1080) patch.defaultOutputHeight = b.defaultOutputHeight;
83+ await updateConfig(patch);
84+ res.json({ ok: true });
85+});
86+
87+app.get('/api/videos', async (_req, res) => {
88+ res.json(await listVideos());
89+});
90+
91+app.post('/api/videos', json, async (req, res) => {
92+ res.json(await createVideo(req.body ?? {}));
93+});
94+
95+app.put('/api/order', json, async (req, res) => {
96+ if (!Array.isArray(req.body?.order)) throw new HttpError(400, 'order must be an array');
97+ await setOrder(req.body.order);
98+ res.json({ ok: true });
99+});
100+
101+app.get('/api/videos/:id', async (req, res) => {
102+ res.json(withMatteStatus(await getVideo(p(req, 'id'))));
103+});
104+
105+// Editable fields only; takes are managed through their own endpoints.
106+app.patch('/api/videos/:id', json, async (req, res) => {
107+ const b = req.body ?? {};
108+ const v = await updateVideo(p(req, 'id'), (v: Video) => {
109+ if (typeof b.title === 'string') v.title = b.title;
110+ if (typeof b.description === 'string') v.description = b.description;
111+ if (typeof b.notes === 'string') v.notes = b.notes;
112+ if (b.layout) {
113+ v.layout = { ...v.layout, ...b.layout };
114+ const bg = v.layout.background;
115+ if (bg && (!['none', 'blur', 'color', 'cutout'].includes(bg.mode) || !/^#[0-9a-fA-F]{6}$/.test(bg.color) || !(bg.blur >= 0 && bg.blur <= 100))) {
116+ throw new HttpError(400, 'Invalid background settings');
117+ }
118+ }
119+ if (b.output) v.output = { ...v.output, ...b.output };
120+ if (Array.isArray(b.timeline)) {
121+ const takes = new Map(v.takes.map((t) => [t.id, t]));
122+ for (const s of b.timeline) {
123+ const t = takes.get(s?.takeId);
124+ if (!t || typeof s.id !== 'string' || !(s.start >= 0) || !(s.end > s.start) || s.end > t.duration + 0.01) throw new HttpError(400, 'Invalid timeline segment');
125+ }
126+ v.timeline = b.timeline.map((s: { id: string; takeId: string; start: number; end: number }) => ({ id: s.id, takeId: s.takeId, start: s.start, end: s.end }));
127+ }
128+ });
129+ res.json(v);
130+});
131+
132+app.delete('/api/videos/:id', async (req, res) => {
133+ await trashVideo(p(req, 'id'));
134+ res.json({ ok: true });
135+});
136+
137+app.post('/api/videos/:id/takes', json, async (req, res) => {
138+ res.json(await createTake(p(req, 'id'), req.body ?? {}));
139+});
140+
141+app.post('/api/videos/:id/takes/:takeId/chunk', raw, async (req, res) => {
142+ await appendChunk(p(req, 'id'), p(req, 'takeId'), String(req.query.track), req.body as Buffer);
143+ res.json({ ok: true });
144+});
145+
146+app.post('/api/videos/:id/takes/:takeId/finalize', async (req, res) => {
147+ res.json(withMatteStatus(await finalizeTake(p(req, 'id'), p(req, 'takeId'))));
148+});
149+
150+app.delete('/api/videos/:id/takes/:takeId', async (req, res) => {
151+ res.json(withMatteStatus(await deleteTake(p(req, 'id'), p(req, 'takeId'))));
152+});
153+
154+app.post('/api/videos/:id/takes/:takeId/transcribe', async (req, res) => {
155+ await transcribeTake(p(req, 'id'), p(req, 'takeId'));
156+ res.json(withMatteStatus(await getVideo(p(req, 'id'))));
157+});
158+
159+// Queue background removal for the video's camera takes (all unprocessed ones, or one take to retry).
160+app.post('/api/videos/:id/matte', json, async (req, res) => {
161+ const takeId = typeof req.body?.takeId === 'string' ? checkId(req.body.takeId) : undefined;
162+ await requestMattes(p(req, 'id'), { takeId, retry: !!takeId });
163+ res.json(withMatteStatus(await getVideo(p(req, 'id'))));
164+});
165+
166+app.post('/api/videos/:id/thumbnail', raw, async (req, res) => {
167+ res.json(withMatteStatus(await setThumbnail(p(req, 'id'), req.body as Buffer)));
168+});
169+
170+app.post('/api/videos/:id/export', async (req, res) => {
171+ res.json(await startExport(p(req, 'id')));
172+});
173+
174+app.get('/api/videos/:id/export', (req, res) => {
175+ res.json(getExportJob(p(req, 'id')));
176+});
177+
178+app.post('/api/videos/:id/open-folder', async (req, res) => {
179+ const dir = videoDir(p(req, 'id'));
180+ spawn('xdg-open', [dir], { detached: true, stdio: 'ignore' }).unref();
181+ res.json({ ok: true, dir });
182+});
183+
184+// Media files inside a video directory (supports range requests for seeking).
185+app.get('/media/:id/*rest', (req, res, next) => {
186+ const id = p(req, 'id');
187+ const rest = (req.params as unknown as { rest: string[] }).rest.join('/');
188+ res.sendFile(rest, { root: videoDir(id), dotfiles: 'deny', headers: { 'Cache-Control': 'no-cache' } }, (err) => {
189+ if (err && !res.headersSent) next(new HttpError(404, 'Not found'));
190+ });
191+});
192+
193+if (fs.existsSync(path.join(distDir, 'index.html'))) {
194+ app.use(express.static(distDir));
195+ app.get('/{*any}', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
196+} else {
197+ app.get('/', (_req, res) => res.send('UI not built. Run <code>npm run build</code>, or use <code>npm run dev</code> and open http://localhost:5173.'));
198+}
199+
200+app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
201+ const status = err instanceof HttpError ? err.status : 500;
202+ if (status === 500) console.error(err);
203+ res.status(status).json({ error: err.message });
204+});
205+
206+const url = `http://localhost:${opts.port}/`;
207+
208+function openWindow() {
209+ if (!opts.open) return;
210+ const chrome = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'].find((c) => !spawnSync('which', [c]).status);
211+ const [cmd, args] = chrome && !opts.browser ? [chrome, [`--app=${url}`]] : ['xdg-open', [url]];
212+ spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
213+}
214+
215+const server = app.listen(opts.port, '127.0.0.1', () => {
216+ console.log(`Takes is running at ${url}`);
217+ console.log(`Library: ${libraryDir()}`);
218+ openWindow();
219+});
220+
221+server.on('error', async (err: NodeJS.ErrnoException) => {
222+ if (err.code === 'EADDRINUSE') {
223+ try {
224+ const r = await fetch(`${url}api/ping`);
225+ if (((await r.json()) as { app?: string }).app === 'takes') {
226+ console.log(`Takes is already running at ${url}`);
227+ openWindow();
228+ process.exit(0);
229+ }
230+ } catch {
231+ // fall through
232+ }
233+ console.error(`Port ${opts.port} is in use. Try --port.`);
234+ process.exit(1);
235+ }
236+ throw err;
237+});
server/matte.tsadded+210−0View file
@@ -0,0 +1,210 @@
1+// Background removal for camera tracks with Robust Video Matting (Lin et al., 2021), run on the CPU
2+// through onnxruntime. For each take, camera.webm is decoded to 30 fps RGB frames, the recurrent
3+// model predicts a foreground color and an alpha matte per frame, and the result is written as
4+// cutout.webm (VP9 with an alpha channel), which Chrome plays with transparency and ffmpeg can
5+// composite at export. Takes are processed one at a time, since each job already uses most cores.
6+
7+import { spawn } from 'node:child_process';
8+import { once } from 'node:events';
9+import fs from 'node:fs/promises';
10+import os from 'node:os';
11+import path from 'node:path';
12+import type { Readable } from 'node:stream';
13+import ort from 'onnxruntime-node';
14+import type { Take, Video } from '../shared/types.ts';
15+import { FPS } from '../shared/layout.ts';
16+import { probe } from './media.ts';
17+import { HttpError, exists, getVideo, takeDir, updateVideo } from './store.ts';
18+
19+const MODEL_URL = 'https://github.com/PeterL1n/RobustVideoMatting/releases/download/v1.0.0/rvm_mobilenetv3_fp32.onnx';
20+const MODEL_PATH = path.join(os.homedir(), '.cache', 'takes', 'models', 'rvm_mobilenetv3_fp32.onnx');
21+
22+interface Job {
23+ videoId: string;
24+ takeId: string;
25+ progress: number;
26+ running: boolean;
27+}
28+
29+const jobs = new Map<string, Job>(); // key: videoId/takeId, in queue order
30+let pumping = false;
31+let session: Promise<ort.InferenceSession> | null = null;
32+
33+const key = (videoId: string, takeId: string) => `${videoId}/${takeId}`;
34+
35+export const cutoutFile = (videoId: string, takeId: string) => path.join(takeDir(videoId, takeId), 'cutout.webm');
36+
37+// Merge the live queue state into a video before sending it to the client.
38+export function withMatteStatus(v: Video): Video {
39+ const takes = v.takes.map((t): Take => {
40+ const j = jobs.get(key(v.id, t.id));
41+ return j ? { ...t, matte: { status: j.running ? 'running' : 'queued', progress: j.progress, updatedAt: new Date().toISOString() } } : t;
42+ });
43+ return { ...v, takes };
44+}
45+
46+// Queue every ready camera take of the video that has not been processed yet (or failed, if retry).
47+export async function requestMattes(videoId: string, opts: { retry?: boolean; takeId?: string } = {}) {
48+ const v = await getVideo(videoId);
49+ for (const t of v.takes) {
50+ if (opts.takeId && t.id !== opts.takeId) continue;
51+ if (t.status !== 'ready' || !t.tracks.camera || jobs.has(key(videoId, t.id))) continue;
52+ if (t.matte?.status === 'done' && (await exists(cutoutFile(videoId, t.id)))) continue;
53+ if (t.matte?.status === 'failed' && !opts.retry) continue;
54+ jobs.set(key(videoId, t.id), { videoId, takeId: t.id, progress: 0, running: false });
55+ }
56+ pump();
57+}
58+
59+async function pump() {
60+ if (pumping) return;
61+ pumping = true;
62+ try {
63+ for (;;) {
64+ const job = jobs.values().next().value as Job | undefined;
65+ if (!job) break;
66+ job.running = true;
67+ const setMatte = (matte: Take['matte']) =>
68+ updateVideo(job.videoId, (v) => {
69+ const t = v.takes.find((t) => t.id === job.takeId);
70+ if (t) t.matte = matte;
71+ }).catch(() => {}); // the video or take may have been deleted meanwhile
72+ try {
73+ const t0 = Date.now();
74+ const frames = await processTake(job);
75+ console.log(`matte: ${job.videoId}/${job.takeId}: ${frames} frames in ${((Date.now() - t0) / 1000).toFixed(1)} s`);
76+ await setMatte({ status: 'done', updatedAt: new Date().toISOString() });
77+ } catch (e) {
78+ console.warn(`matte failed for ${job.videoId}/${job.takeId}:`, (e as Error).message);
79+ await setMatte({ status: 'failed', error: (e as Error).message, updatedAt: new Date().toISOString() });
80+ } finally {
81+ jobs.delete(key(job.videoId, job.takeId));
82+ }
83+ }
84+ } finally {
85+ pumping = false;
86+ }
87+}
88+
89+async function ensureModel() {
90+ if (await exists(MODEL_PATH)) return;
91+ console.log(`Downloading background removal model to ${MODEL_PATH}`);
92+ const res = await fetch(MODEL_URL);
93+ if (!res.ok) throw new Error(`Could not download the model (${res.status}) from ${MODEL_URL}`);
94+ await fs.mkdir(path.dirname(MODEL_PATH), { recursive: true });
95+ const tmp = `${MODEL_PATH}.${process.pid}.tmp`;
96+ await fs.writeFile(tmp, Buffer.from(await res.arrayBuffer()));
97+ await fs.rename(tmp, MODEL_PATH);
98+}
99+
100+function getSession() {
101+ if (!session) {
102+ session = ensureModel().then(() =>
103+ ort.InferenceSession.create(MODEL_PATH, {
104+ executionProviders: ['cpu'],
105+ graphOptimizationLevel: 'all',
106+ intraOpNumThreads: Math.max(1, os.cpus().length - 2),
107+ }),
108+ );
109+ session.catch(() => (session = null)); // allow a retry after e.g. a failed download
110+ }
111+ return session;
112+}
113+
114+// Yield fixed-size frames from a raw video stream. The same buffer is reused, so each frame must be
115+// consumed before asking for the next.
116+async function* readFrames(stream: Readable, size: number) {
117+ const frame = Buffer.alloc(size);
118+ let off = 0;
119+ for await (const chunk of stream as AsyncIterable<Buffer>) {
120+ let p = 0;
121+ while (p < chunk.length) {
122+ const n = Math.min(size - off, chunk.length - p);
123+ chunk.copy(frame, off, p, p + n);
124+ off += n;
125+ p += n;
126+ if (off === size) {
127+ yield frame;
128+ off = 0;
129+ }
130+ }
131+ }
132+}
133+
134+const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
135+
136+async function processTake(job: Job): Promise<number> {
137+ const sess = await getSession();
138+ const src = path.join(takeDir(job.videoId, job.takeId), 'camera.webm');
139+ const info = await probe(src);
140+ if (!info.width || !info.height) throw new HttpError(400, 'Camera track has no video');
141+ // Process at the camera's resolution, capped at 1080 lines.
142+ const scale = Math.min(1, 1080 / info.height);
143+ const W = even(info.width * scale);
144+ const H = even(info.height * scale);
145+ // Downsample ratio for the model's coarse pass, following the RVM guidance (0.25 at 1080p, 0.375 at 720p).
146+ const ds = Math.min(1, 480 / Math.max(W, H));
147+ const total = Math.max(1, Math.round(info.duration * FPS));
148+
149+ const out = cutoutFile(job.videoId, job.takeId);
150+ const tmp = `${out}.partial.webm`;
151+ const decoder = spawn('ffmpeg', ['-v', 'error', '-i', src, '-an', '-vf', `fps=${FPS}:start_time=0,scale=${W}:${H}`, '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'], { stdio: ['ignore', 'pipe', 'pipe'] });
152+ const encoder = spawn(
153+ 'ffmpeg',
154+ ['-y', '-v', 'error', '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-r', String(FPS), '-i', '-',
155+ '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p', '-deadline', 'realtime', '-cpu-used', '8', '-row-mt', '1', '-b:v', '3M', '-auto-alt-ref', '0', tmp],
156+ { stdio: ['pipe', 'ignore', 'pipe'] },
157+ );
158+ let stderr = '';
159+ decoder.stderr.on('data', (d) => (stderr += d));
160+ encoder.stderr.on('data', (d) => (stderr += d));
161+ const decoderDone = once(decoder, 'close');
162+ const encoderDone = once(encoder, 'close');
163+
164+ const plane = W * H;
165+ const input = new Float32Array(3 * plane);
166+ let rec: ort.Tensor[] = [0, 1, 2, 3].map(() => new ort.Tensor('float32', new Float32Array([0]), [1, 1, 1, 1]));
167+ const dsr = new ort.Tensor('float32', new Float32Array([ds]), [1]);
168+ let n = 0;
169+ try {
170+ for await (const frame of readFrames(decoder.stdout, plane * 3)) {
171+ for (let i = 0, j = 0; i < plane; i++, j += 3) {
172+ input[i] = frame[j] / 255;
173+ input[i + plane] = frame[j + 1] / 255;
174+ input[i + 2 * plane] = frame[j + 2] / 255;
175+ }
176+ const r = await sess.run({
177+ src: new ort.Tensor('float32', input, [1, 3, H, W]),
178+ r1i: rec[0],
179+ r2i: rec[1],
180+ r3i: rec[2],
181+ r4i: rec[3],
182+ downsample_ratio: dsr,
183+ });
184+ rec = [r.r1o, r.r2o, r.r3o, r.r4o];
185+ const fgr = r.fgr.data as Float32Array;
186+ const pha = r.pha.data as Float32Array;
187+ const rgba = new Uint8ClampedArray(plane * 4);
188+ for (let i = 0, j = 0; i < plane; i++, j += 4) {
189+ rgba[j] = fgr[i] * 255;
190+ rgba[j + 1] = fgr[i + plane] * 255;
191+ rgba[j + 2] = fgr[i + 2 * plane] * 255;
192+ rgba[j + 3] = pha[i] * 255;
193+ }
194+ if (!encoder.stdin.write(Buffer.from(rgba.buffer))) await once(encoder.stdin, 'drain');
195+ n++;
196+ job.progress = Math.min(0.99, n / total);
197+ }
198+ encoder.stdin.end();
199+ const [[dcode], [ecode]] = await Promise.all([decoderDone, encoderDone]);
200+ if (dcode !== 0 || ecode !== 0) throw new Error(`ffmpeg failed: ${stderr.trim().split('\n').slice(-4).join('\n')}`);
201+ if (n === 0) throw new Error('No camera frames were decoded');
202+ await fs.rename(tmp, out);
203+ return n;
204+ } catch (e) {
205+ decoder.kill();
206+ encoder.kill();
207+ await fs.rm(tmp, { force: true });
208+ throw e;
209+ }
210+}
server/media.tsadded+107−0View file
@@ -0,0 +1,107 @@
1+// ffmpeg / ffprobe helpers.
2+
3+import { spawn } from 'node:child_process';
4+import fs from 'node:fs/promises';
5+import type { Peaks } from '../shared/types.ts';
6+import { writeJsonAtomic } from './store.ts';
7+
8+export function run(cmd: string, args: string[], opts: { onStdout?: (chunk: string) => void } = {}): Promise<{ stdout: string; stderr: string }> {
9+ return new Promise((resolve, reject) => {
10+ const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
11+ let stdout = '';
12+ let stderr = '';
13+ p.stdout.setEncoding('utf8');
14+ p.stderr.setEncoding('utf8');
15+ p.stdout.on('data', (d: string) => {
16+ if (opts.onStdout) opts.onStdout(d);
17+ else stdout += d;
18+ });
19+ p.stderr.on('data', (d: string) => {
20+ stderr += d;
21+ if (stderr.length > 200_000) stderr = stderr.slice(-100_000);
22+ });
23+ p.on('error', reject);
24+ p.on('close', (code) => {
25+ if (code === 0) resolve({ stdout, stderr });
26+ else reject(new Error(`${cmd} exited with code ${code}: ${stderr.trim().split('\n').slice(-8).join('\n')}`));
27+ });
28+ });
29+}
30+
31+export interface ProbeResult {
32+ duration: number;
33+ width?: number;
34+ height?: number;
35+ hasAudio: boolean;
36+}
37+
38+export async function probe(file: string): Promise<ProbeResult> {
39+ const { stdout } = await run('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file]);
40+ const info = JSON.parse(stdout);
41+ const streams: { codec_type: string; width?: number; height?: number }[] = info.streams ?? [];
42+ const v = streams.find((s) => s.codec_type === 'video');
43+ return {
44+ duration: parseFloat(info.format?.duration ?? '0') || 0,
45+ width: v?.width,
46+ height: v?.height,
47+ hasAudio: streams.some((s) => s.codec_type === 'audio'),
48+ };
49+}
50+
51+// Browser MediaRecorder output has no duration or seek index; a stream copy fixes both.
52+export async function remux(src: string, dst: string) {
53+ await run('ffmpeg', ['-y', '-v', 'error', '-fflags', '+genpts', '-i', src, '-c', 'copy', dst]);
54+}
55+
56+export async function extractFrame(file: string, t: number, dst: string, width = 640) {
57+ await run('ffmpeg', ['-y', '-v', 'error', '-ss', t.toFixed(3), '-i', file, '-frames:v', '1', '-vf', `scale=${width}:-2`, '-q:v', '3', dst]);
58+}
59+
60+export async function extractAudio(file: string, dst: string) {
61+ await run('ffmpeg', ['-y', '-v', 'error', '-i', file, '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'libopus', '-b:a', '32k', dst]);
62+}
63+
64+// Peak amplitude per 1/rate seconds, sqrt-scaled to 0..255 so quiet speech is still visible.
65+export function computePeaks(file: string, dst: string, rate = 50): Promise<void> {
66+ const sr = 8000;
67+ const win = sr / rate;
68+ return new Promise((resolve, reject) => {
69+ const p = spawn('ffmpeg', ['-v', 'error', '-i', file, '-vn', '-ac', '1', '-ar', String(sr), '-f', 's16le', '-'], { stdio: ['ignore', 'pipe', 'pipe'] });
70+ const out: number[] = [];
71+ let cur = 0;
72+ let n = 0;
73+ let leftover: Buffer | null = null;
74+ p.stdout.on('data', (chunk: Buffer) => {
75+ const buf = leftover ? Buffer.concat([leftover, chunk]) : chunk;
76+ const usable = buf.length - (buf.length % 2);
77+ leftover = usable < buf.length ? buf.subarray(usable) : null;
78+ for (let i = 0; i < usable; i += 2) {
79+ const a = Math.abs(buf.readInt16LE(i));
80+ if (a > cur) cur = a;
81+ if (++n >= win) {
82+ out.push(Math.round(255 * Math.sqrt(cur / 32768)));
83+ cur = 0;
84+ n = 0;
85+ }
86+ }
87+ });
88+ let stderr = '';
89+ p.stderr.on('data', (d) => (stderr += d));
90+ p.on('error', reject);
91+ p.on('close', async (code) => {
92+ if (code !== 0) return reject(new Error(`ffmpeg peaks failed: ${stderr.slice(-500)}`));
93+ if (n > 0) out.push(Math.round(255 * Math.sqrt(cur / 32768)));
94+ const peaks: Peaks = { rate, data: Buffer.from(Uint8Array.from(out)).toString('base64') };
95+ try {
96+ await writeJsonAtomic(dst, peaks);
97+ resolve();
98+ } catch (e) {
99+ reject(e);
100+ }
101+ });
102+ });
103+}
104+
105+export async function fileSize(p: string) {
106+ return (await fs.stat(p)).size;
107+}
server/store.tsadded+227−0View file
@@ -0,0 +1,227 @@
1+// On-disk library: one directory per video, each with a video.json.
2+//
3+// <root>/library.json { order: [videoId, ...] }
4+// <root>/config.json app settings (incl. Deepgram key), mode 0600
5+// <root>/<videoId>/video.json
6+// <root>/<videoId>/thumbnail.jpg
7+// <root>/<videoId>/takes/<takeId>/{screen,camera}.webm, peaks.json, transcript.json
8+// <root>/.trash/ deleted videos are moved here
9+
10+import fs from 'node:fs/promises';
11+import path from 'node:path';
12+import type { AppConfig, Video, VideoSummary } from '../shared/types.ts';
13+import { exportSignature } from '../shared/types.ts';
14+import { randomId, timelineDuration } from '../shared/timeline.ts';
15+
16+export class HttpError extends Error {
17+ status: number;
18+ constructor(status: number, message: string) {
19+ super(message);
20+ this.status = status;
21+ }
22+}
23+
24+let root = '';
25+
26+export async function initStore(dir: string) {
27+ root = dir;
28+ await fs.mkdir(root, { recursive: true });
29+}
30+
31+export const libraryDir = () => root;
32+export const videoDir = (id: string) => path.join(root, checkId(id));
33+export const takeDir = (id: string, takeId: string) => path.join(videoDir(id), 'takes', checkId(takeId));
34+
35+const ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
36+
37+export function checkId(id: string): string {
38+ if (typeof id !== 'string' || !ID_RE.test(id)) throw new HttpError(400, `Invalid id: ${id}`);
39+ return id;
40+}
41+
42+export async function exists(p: string) {
43+ try {
44+ await fs.access(p);
45+ return true;
46+ } catch {
47+ return false;
48+ }
49+}
50+
51+async function readJson<T>(file: string, fallback: T): Promise<T> {
52+ try {
53+ return JSON.parse(await fs.readFile(file, 'utf8')) as T;
54+ } catch (e) {
55+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return fallback;
56+ throw e;
57+ }
58+}
59+
60+export async function writeJsonAtomic(file: string, data: unknown, mode = 0o644) {
61+ const tmp = `${file}.${randomId(6)}.tmp`;
62+ await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode });
63+ await fs.rename(tmp, file);
64+}
65+
66+// Serialize read-modify-write cycles per key.
67+const locks = new Map<string, Promise<unknown>>();
68+function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
69+ const prev = locks.get(key) ?? Promise.resolve();
70+ const next = prev.then(fn, fn);
71+ const settled = next.catch(() => {});
72+ locks.set(key, settled);
73+ settled.then(() => {
74+ if (locks.get(key) === settled) locks.delete(key);
75+ });
76+ return next;
77+}
78+
79+// ---- config ----
80+
81+const DEFAULT_CONFIG: AppConfig & { deepgramApiKey: string } = {
82+ deepgramApiKey: '',
83+ deepgramModel: 'nova-3',
84+ autoTranscribe: true,
85+ defaultCountdown: 3,
86+ defaultCameraHeight: 720,
87+ defaultOutputHeight: 1080,
88+};
89+
90+export type FullConfig = typeof DEFAULT_CONFIG;
91+
92+export async function getConfig(): Promise<FullConfig> {
93+ return { ...DEFAULT_CONFIG, ...(await readJson<Partial<FullConfig>>(path.join(root, 'config.json'), {})) };
94+}
95+
96+export function updateConfig(patch: Partial<FullConfig>): Promise<FullConfig> {
97+ return withLock('__config__', async () => {
98+ const cfg = await getConfig();
99+ for (const k of Object.keys(DEFAULT_CONFIG) as (keyof FullConfig)[]) {
100+ if (patch[k] !== undefined) (cfg as unknown as Record<string, unknown>)[k] = patch[k];
101+ }
102+ await writeJsonAtomic(path.join(root, 'config.json'), cfg, 0o600);
103+ return cfg;
104+ });
105+}
106+
107+// ---- videos ----
108+
109+export async function getVideo(id: string): Promise<Video> {
110+ const v = await readJson<Video | null>(path.join(videoDir(id), 'video.json'), null);
111+ if (!v) throw new HttpError(404, `Video not found: ${id}`);
112+ return v;
113+}
114+
115+export function updateVideo(id: string, fn: (v: Video) => void | Promise<void>): Promise<Video> {
116+ return withLock(id, async () => {
117+ const v = await getVideo(id);
118+ await fn(v);
119+ v.updatedAt = new Date().toISOString();
120+ await writeJsonAtomic(path.join(videoDir(id), 'video.json'), v);
121+ return v;
122+ });
123+}
124+
125+function newVideoId(title: string) {
126+ const d = new Date();
127+ const pad = (n: number) => String(n).padStart(2, '0');
128+ const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
129+ const slug = slugify(title).slice(0, 40);
130+ return [stamp, slug, randomId(4)].filter(Boolean).join('-');
131+}
132+
133+export function slugify(s: string) {
134+ return s
135+ .toLowerCase()
136+ .normalize('NFKD')
137+ .replace(/[^a-z0-9]+/g, '-')
138+ .replace(/^-+|-+$/g, '');
139+}
140+
141+export async function createVideo(input: { title?: string; description?: string; notes?: string }): Promise<Video> {
142+ const cfg = await getConfig();
143+ const now = new Date().toISOString();
144+ const title = (input.title ?? '').trim() || 'Untitled video';
145+ const v: Video = {
146+ id: newVideoId(title),
147+ title,
148+ description: input.description ?? '',
149+ notes: input.notes ?? '',
150+ createdAt: now,
151+ updatedAt: now,
152+ takes: [],
153+ timeline: [],
154+ layout: { mode: 'pip', corner: 'br', size: 0.22, shape: 'rounded' },
155+ output: { height: cfg.defaultOutputHeight, normalizeAudio: true },
156+ thumbnail: null,
157+ export: null,
158+ };
159+ await fs.mkdir(path.join(videoDir(v.id), 'takes'), { recursive: true });
160+ await writeJsonAtomic(path.join(videoDir(v.id), 'video.json'), v);
161+ await withLock('__library__', async () => {
162+ const lib = await readLibrary();
163+ lib.order.push(v.id);
164+ await writeJsonAtomic(path.join(root, 'library.json'), lib);
165+ });
166+ return v;
167+}
168+
169+async function readLibrary() {
170+ return readJson<{ order: string[] }>(path.join(root, 'library.json'), { order: [] });
171+}
172+
173+export function summarize(v: Video): VideoSummary {
174+ const ready = v.takes.filter((t) => t.status === 'ready');
175+ const used = new Set(v.timeline.map((s) => s.takeId));
176+ const usedTakes = ready.filter((t) => used.has(t.id));
177+ return {
178+ id: v.id,
179+ title: v.title,
180+ description: v.description,
181+ createdAt: v.createdAt,
182+ updatedAt: v.updatedAt,
183+ duration: timelineDuration(v.timeline),
184+ takeCount: ready.length,
185+ hasScreen: usedTakes.some((t) => t.tracks.screen),
186+ hasCamera: usedTakes.some((t) => t.tracks.camera),
187+ thumbnailUrl: v.thumbnail ? `/media/${v.id}/${v.thumbnail.file}?v=${encodeURIComponent(v.thumbnail.updatedAt)}` : null,
188+ exportState: !v.export ? 'none' : v.export.signature === exportSignature(v) ? 'current' : 'stale',
189+ };
190+}
191+
192+export async function listVideos(): Promise<VideoSummary[]> {
193+ const entries = await fs.readdir(root, { withFileTypes: true });
194+ const videos = new Map<string, Video>();
195+ for (const e of entries) {
196+ if (!e.isDirectory() || !ID_RE.test(e.name)) continue;
197+ try {
198+ videos.set(e.name, await getVideo(e.name));
199+ } catch {
200+ // not a video directory
201+ }
202+ }
203+ const lib = await readLibrary();
204+ const ordered = lib.order.filter((id) => videos.has(id));
205+ const rest = [...videos.keys()].filter((id) => !ordered.includes(id)).sort((a, b) => videos.get(a)!.createdAt.localeCompare(videos.get(b)!.createdAt));
206+ return [...ordered, ...rest].map((id) => summarize(videos.get(id)!));
207+}
208+
209+export function setOrder(order: string[]) {
210+ order.forEach(checkId);
211+ return withLock('__library__', async () => {
212+ await writeJsonAtomic(path.join(root, 'library.json'), { order });
213+ });
214+}
215+
216+export async function trashVideo(id: string) {
217+ const src = videoDir(id);
218+ if (!(await exists(path.join(src, 'video.json')))) throw new HttpError(404, `Video not found: ${id}`);
219+ const trash = path.join(root, '.trash');
220+ await fs.mkdir(trash, { recursive: true });
221+ await withLock(id, () => fs.rename(src, path.join(trash, `${id}--deleted-${Date.now()}`)));
222+ await withLock('__library__', async () => {
223+ const lib = await readLibrary();
224+ lib.order = lib.order.filter((x) => x !== id);
225+ await writeJsonAtomic(path.join(root, 'library.json'), lib);
226+ });
227+}
server/takes.tsadded+148−0View file
@@ -0,0 +1,148 @@
1+// Take lifecycle: create -> append chunks while recording -> finalize (remux, probe, peaks, thumbnail).
2+
3+import fs from 'node:fs/promises';
4+import path from 'node:path';
5+import type { Take, TrackKind } from '../shared/types.ts';
6+import { randomId } from '../shared/timeline.ts';
7+import { computePeaks, extractFrame, probe, remux } from './media.ts';
8+import { HttpError, exists, getConfig, getVideo, takeDir, updateVideo, videoDir } from './store.ts';
9+import { transcribeTake } from './transcribe.ts';
10+
11+const TRACKS: TrackKind[] = ['screen', 'camera'];
12+
13+export async function createTake(videoId: string, input: { tracks: { screen: boolean; camera: boolean }; audio: TrackKind | null }): Promise<Take> {
14+ const tracks = { screen: !!input.tracks?.screen, camera: !!input.tracks?.camera };
15+ if (!tracks.screen && !tracks.camera) throw new HttpError(400, 'A take needs a screen or camera track');
16+ const audio = input.audio && tracks[input.audio] ? input.audio : null;
17+ const d = new Date();
18+ const take: Take = {
19+ id: `t${d.getTime().toString(36)}${randomId(3)}`,
20+ createdAt: d.toISOString(),
21+ status: 'recording',
22+ tracks,
23+ audio,
24+ duration: 0,
25+ };
26+ await fs.mkdir(takeDir(videoId, take.id), { recursive: true });
27+ await updateVideo(videoId, (v) => {
28+ v.takes.push(take);
29+ });
30+ return take;
31+}
32+
33+const partFile = (videoId: string, takeId: string, track: TrackKind) => path.join(takeDir(videoId, takeId), `${track}.part.webm`);
34+
35+export async function appendChunk(videoId: string, takeId: string, track: string, data: Buffer) {
36+ if (track !== 'screen' && track !== 'camera') throw new HttpError(400, 'Invalid track');
37+ const v = await getVideo(videoId);
38+ const take = v.takes.find((t) => t.id === takeId);
39+ if (!take) throw new HttpError(404, 'Take not found');
40+ if (take.status !== 'recording') throw new HttpError(409, 'Take is not recording');
41+ if (!take.tracks[track]) throw new HttpError(400, `Take has no ${track} track`);
42+ await fs.appendFile(partFile(videoId, takeId, track), data);
43+}
44+
45+const finalizing = new Set<string>();
46+
47+export async function finalizeTake(videoId: string, takeId: string) {
48+ const key = `${videoId}/${takeId}`;
49+ if (finalizing.has(key)) throw new HttpError(409, 'Already finalizing');
50+ finalizing.add(key);
51+ try {
52+ const v = await getVideo(videoId);
53+ const take = v.takes.find((t) => t.id === takeId);
54+ if (!take) throw new HttpError(404, 'Take not found');
55+ if (take.status === 'ready') return v;
56+ await updateVideo(videoId, (v) => {
57+ const t = v.takes.find((t) => t.id === takeId)!;
58+ t.status = 'processing';
59+ });
60+
61+ const dir = takeDir(videoId, takeId);
62+ const durations: number[] = [];
63+ try {
64+ for (const track of TRACKS) {
65+ if (!take.tracks[track]) continue;
66+ const part = partFile(videoId, takeId, track);
67+ const final = path.join(dir, `${track}.webm`);
68+ if (await exists(part)) {
69+ await remux(part, final);
70+ await fs.rm(part);
71+ }
72+ if (!(await exists(final))) throw new Error(`No ${track} data was recorded`);
73+ durations.push((await probe(final)).duration);
74+ }
75+ const duration = Math.min(...durations);
76+ if (!(duration > 0)) throw new Error('Recording is empty');
77+
78+ const audioFile = take.audio ? path.join(dir, `${take.audio}.webm`) : null;
79+ if (audioFile) await computePeaks(audioFile, path.join(dir, 'peaks.json')).catch((e) => console.warn('peaks:', e.message));
80+
81+ const updated = await updateVideo(videoId, (v) => {
82+ const t = v.takes.find((t) => t.id === takeId)!;
83+ t.status = 'ready';
84+ t.duration = duration;
85+ delete t.error;
86+ v.timeline.push({ id: randomId(), takeId, start: 0, end: duration });
87+ });
88+ if (!updated.thumbnail) await autoThumbnail(videoId);
89+ maybeAutoTranscribe(videoId, takeId, !!take.audio);
90+ return getVideo(videoId);
91+ } catch (e) {
92+ await updateVideo(videoId, (v) => {
93+ const t = v.takes.find((t) => t.id === takeId);
94+ if (t) {
95+ t.status = 'failed';
96+ t.error = (e as Error).message;
97+ }
98+ });
99+ throw e;
100+ }
101+ } finally {
102+ finalizing.delete(key);
103+ }
104+}
105+
106+async function maybeAutoTranscribe(videoId: string, takeId: string, hasAudio: boolean) {
107+ if (!hasAudio) return;
108+ const cfg = await getConfig();
109+ if (cfg.autoTranscribe && cfg.deepgramApiKey) transcribeTake(videoId, takeId).catch((e) => console.warn('transcribe:', e.message));
110+}
111+
112+// Thumbnail from the first timeline segment, preferring the screen track.
113+export async function autoThumbnail(videoId: string) {
114+ const v = await getVideo(videoId);
115+ const seg = v.timeline[0];
116+ if (!seg) return;
117+ const take = v.takes.find((t) => t.id === seg.takeId);
118+ if (!take) return;
119+ const track: TrackKind = take.tracks.screen ? 'screen' : 'camera';
120+ const t = Math.min(seg.start + 1.5, (seg.start + seg.end) / 2);
121+ try {
122+ await extractFrame(path.join(takeDir(videoId, take.id), `${track}.webm`), t, path.join(videoDir(videoId), 'thumbnail.jpg'));
123+ await updateVideo(videoId, (v) => {
124+ v.thumbnail = { file: 'thumbnail.jpg', custom: false, updatedAt: new Date().toISOString() };
125+ });
126+ } catch (e) {
127+ console.warn('thumbnail:', (e as Error).message);
128+ }
129+}
130+
131+export async function setThumbnail(videoId: string, jpeg: Buffer) {
132+ await getVideo(videoId);
133+ await fs.writeFile(path.join(videoDir(videoId), 'thumbnail.jpg'), jpeg);
134+ return updateVideo(videoId, (v) => {
135+ v.thumbnail = { file: 'thumbnail.jpg', custom: true, updatedAt: new Date().toISOString() };
136+ });
137+}
138+
139+export async function deleteTake(videoId: string, takeId: string) {
140+ const updated = await updateVideo(videoId, (v) => {
141+ if (!v.takes.some((t) => t.id === takeId)) throw new HttpError(404, 'Take not found');
142+ v.takes = v.takes.filter((t) => t.id !== takeId);
143+ v.timeline = v.timeline.filter((s) => s.takeId !== takeId);
144+ });
145+ await fs.rm(takeDir(videoId, takeId), { recursive: true, force: true });
146+ if (updated.thumbnail && !updated.thumbnail.custom) await autoThumbnail(videoId);
147+ return getVideo(videoId);
148+}
server/transcribe.tsadded+77−0View file
@@ -0,0 +1,77 @@
1+// Transcription of a take's mic audio with Deepgram's pre-recorded API.
2+
3+import fs from 'node:fs/promises';
4+import path from 'node:path';
5+import type { Transcript, Word } from '../shared/types.ts';
6+import { extractAudio } from './media.ts';
7+import { HttpError, getConfig, getVideo, takeDir, updateVideo, writeJsonAtomic } from './store.ts';
8+
9+const inFlight = new Set<string>();
10+
11+export async function transcribeTake(videoId: string, takeId: string) {
12+ const key = `${videoId}/${takeId}`;
13+ if (inFlight.has(key)) return;
14+ const cfg = await getConfig();
15+ if (!cfg.deepgramApiKey) throw new HttpError(400, 'No Deepgram API key configured (see Settings)');
16+ const v = await getVideo(videoId);
17+ const take = v.takes.find((t) => t.id === takeId);
18+ if (!take) throw new HttpError(404, 'Take not found');
19+ if (take.status !== 'ready') throw new HttpError(409, 'Take is not ready');
20+ if (!take.audio) throw new HttpError(400, 'Take has no audio');
21+
22+ inFlight.add(key);
23+ const setStatus = (status: 'pending' | 'done' | 'failed', error?: string) =>
24+ updateVideo(videoId, (v) => {
25+ const t = v.takes.find((t) => t.id === takeId);
26+ if (t) t.transcript = { status, updatedAt: new Date().toISOString(), ...(error ? { error } : {}) };
27+ });
28+
29+ try {
30+ await setStatus('pending');
31+ } catch (e) {
32+ inFlight.delete(key);
33+ throw e;
34+ }
35+ // Run in the background; callers poll the take's transcript status.
36+ (async () => {
37+ const dir = takeDir(videoId, takeId);
38+ const audio = path.join(dir, 'transcribe-audio.ogg');
39+ try {
40+ await extractAudio(path.join(dir, `${take.audio}.webm`), audio);
41+ const body = await fs.readFile(audio);
42+ const url = new URL('https://api.deepgram.com/v1/listen');
43+ url.searchParams.set('model', cfg.deepgramModel || 'nova-3');
44+ url.searchParams.set('smart_format', 'true');
45+ url.searchParams.set('punctuate', 'true');
46+ const res = await fetch(url, {
47+ method: 'POST',
48+ headers: { Authorization: `Token ${cfg.deepgramApiKey}`, 'Content-Type': 'audio/ogg' },
49+ body,
50+ });
51+ if (!res.ok) throw new Error(`Deepgram ${res.status}: ${(await res.text()).slice(0, 300)}`);
52+ const json = (await res.json()) as any;
53+ const alt = json?.results?.channels?.[0]?.alternatives?.[0] ?? {};
54+ const words: Word[] = (alt.words ?? []).map((w: { word: string; punctuated_word?: string; start: number; end: number }) => ({
55+ word: w.word,
56+ text: w.punctuated_word ?? w.word,
57+ start: w.start,
58+ end: w.end,
59+ }));
60+ const transcript: Transcript = {
61+ takeId,
62+ model: cfg.deepgramModel,
63+ createdAt: new Date().toISOString(),
64+ text: alt.transcript ?? '',
65+ words,
66+ };
67+ await writeJsonAtomic(path.join(dir, 'transcript.json'), transcript);
68+ await setStatus('done');
69+ } catch (e) {
70+ console.warn('transcription failed:', (e as Error).message);
71+ await setStatus('failed', (e as Error).message).catch(() => {});
72+ } finally {
73+ await fs.rm(audio, { force: true });
74+ inFlight.delete(key);
75+ }
76+ })();
77+}
shared/layout.tsadded+86−0View file
@@ -0,0 +1,86 @@
1+// Output geometry for a take, in output pixels. Used both by the ffmpeg export and by the
2+// browser preview, so that what you see while editing is what gets rendered.
3+
4+import type { BackgroundMode, CameraShape, Layout, OutputSettings } from './types.ts';
5+import { backgroundOf } from './types.ts';
6+
7+export interface Box {
8+ x: number;
9+ y: number;
10+ w: number;
11+ h: number;
12+}
13+
14+export interface Geometry {
15+ W: number;
16+ H: number;
17+ screen: Box | null; // screen is always fit (letterboxed) inside its box
18+ camera: Camera | null;
19+}
20+
21+export interface Camera {
22+ box: Box;
23+ fit: 'cover' | 'contain';
24+ shape: CameraShape;
25+ radius: number;
26+ // Effective background: 'cutout' (no box, just the person) only applies to picture-in-picture;
27+ // elsewhere it falls back to 'color'.
28+ background: BackgroundMode;
29+ color: string;
30+ blurSigma: number; // in output pixels
31+}
32+
33+export const FPS = 30;
34+
35+const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
36+
37+export function outputSize(output: OutputSettings) {
38+ const H = output.height;
39+ return { W: even((H * 16) / 9), H };
40+}
41+
42+export function computeGeometry(layout: Layout, output: OutputSettings, hasScreen: boolean, hasCamera: boolean): Geometry {
43+ const { W, H } = outputSize(output);
44+ const full: Box = { x: 0, y: 0, w: W, h: H };
45+ let mode = layout.mode;
46+ if (!hasCamera) mode = 'screen-only';
47+ else if (!hasScreen) mode = 'camera-only';
48+
49+ const bg = backgroundOf(layout);
50+ const background: BackgroundMode = bg.mode === 'cutout' && mode !== 'pip' ? 'color' : bg.mode;
51+ const camera = (box: Box, fit: 'cover' | 'contain', shape: CameraShape, radius: number): Camera => ({
52+ box,
53+ fit,
54+ shape,
55+ radius,
56+ background,
57+ color: bg.color,
58+ blurSigma: Math.round((bg.blur * H) / 1080 * 10) / 10,
59+ });
60+
61+ if (mode === 'screen-only') return { W, H, screen: full, camera: null };
62+ if (mode === 'camera-only') return { W, H, screen: null, camera: camera(full, 'contain', 'rect', 0) };
63+
64+ const left = layout.corner === 'tl' || layout.corner === 'bl';
65+ if (mode === 'side-by-side') {
66+ const cw = even(W * 0.3);
67+ const camBox: Box = { x: left ? 0 : W - cw, y: 0, w: cw, h: H };
68+ const scrBox: Box = { x: left ? cw : 0, y: 0, w: W - cw, h: H };
69+ return { W, H, screen: scrBox, camera: camera(camBox, 'cover', 'rect', 0) };
70+ }
71+
72+ // picture-in-picture
73+ const margin = even(W * 0.025);
74+ const cw = even(W * Math.min(0.5, Math.max(0.08, layout.size)));
75+ const top = layout.corner === 'tl' || layout.corner === 'tr';
76+ if (background === 'cutout') {
77+ // The person is cut off at the bottom of the camera frame, so sit that edge on the bottom of the video.
78+ const ch = even((cw * 9) / 16);
79+ const box: Box = { x: left ? margin : W - margin - cw, y: top ? margin : H - ch, w: cw, h: ch };
80+ return { W, H, screen: full, camera: camera(box, 'cover', 'rect', 0) };
81+ }
82+ const ch = layout.shape === 'circle' ? cw : even((cw * 9) / 16);
83+ const box: Box = { x: left ? margin : W - margin - cw, y: top ? margin : H - margin - ch, w: cw, h: ch };
84+ const radius = layout.shape === 'circle' ? cw / 2 : layout.shape === 'rounded' ? Math.round(Math.min(cw, ch) * 0.12) : 0;
85+ return { W, H, screen: full, camera: camera(box, 'cover', layout.shape, radius) };
86+}
shared/timeline.tsadded+104−0View file
@@ -0,0 +1,104 @@
1+// Pure operations on the timeline (an ordered list of segments of takes).
2+
3+import type { Segment } from './types.ts';
4+
5+// Pieces shorter than this (seconds) are dropped after an edit.
6+const MIN_PIECE = 0.04;
7+
8+export function randomId(n = 8): string {
9+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
10+ let s = '';
11+ for (let i = 0; i < n; i++) s += chars[Math.floor(Math.random() * chars.length)];
12+ return s;
13+}
14+
15+export const segDuration = (s: Segment) => Math.max(0, s.end - s.start);
16+
17+export function timelineDuration(segs: Segment[]): number {
18+ return segs.reduce((acc, s) => acc + segDuration(s), 0);
19+}
20+
21+// Timeline start time of each segment.
22+export function segmentStarts(segs: Segment[]): number[] {
23+ const out: number[] = [];
24+ let t = 0;
25+ for (const s of segs) {
26+ out.push(t);
27+ t += segDuration(s);
28+ }
29+ return out;
30+}
31+
32+// Which segment contains timeline time t, and the corresponding take time.
33+export function locate(segs: Segment[], t: number): { index: number; takeTime: number } | null {
34+ if (segs.length === 0) return null;
35+ let acc = 0;
36+ for (let i = 0; i < segs.length; i++) {
37+ const d = segDuration(segs[i]);
38+ if (t < acc + d || i === segs.length - 1) {
39+ const off = Math.min(Math.max(t - acc, 0), d);
40+ return { index: i, takeTime: segs[i].start + off };
41+ }
42+ acc += d;
43+ }
44+ return null;
45+}
46+
47+export function splitAt(segs: Segment[], t: number): Segment[] {
48+ const loc = locate(segs, t);
49+ if (!loc) return segs;
50+ const s = segs[loc.index];
51+ if (loc.takeTime - s.start < MIN_PIECE || s.end - loc.takeTime < MIN_PIECE) return segs;
52+ const a: Segment = { ...s, end: loc.takeTime };
53+ const b: Segment = { ...s, id: randomId(), start: loc.takeTime };
54+ return [...segs.slice(0, loc.index), a, b, ...segs.slice(loc.index + 1)];
55+}
56+
57+// Remove timeline range [t0, t1].
58+export function cutRange(segs: Segment[], t0: number, t1: number): Segment[] {
59+ if (t1 < t0) [t0, t1] = [t1, t0];
60+ const out: Segment[] = [];
61+ let acc = 0;
62+ for (const s of segs) {
63+ const d = segDuration(s);
64+ const a = acc;
65+ const b = acc + d;
66+ acc = b;
67+ if (t1 <= a || t0 >= b) {
68+ out.push(s);
69+ continue;
70+ }
71+ let usedId = false;
72+ if (t0 > a) {
73+ const piece = { ...s, end: s.start + (t0 - a) };
74+ if (segDuration(piece) >= MIN_PIECE) {
75+ out.push(piece);
76+ usedId = true;
77+ }
78+ }
79+ if (t1 < b) {
80+ const piece = { ...s, id: usedId ? randomId() : s.id, start: s.start + (t1 - a) };
81+ if (segDuration(piece) >= MIN_PIECE) out.push(piece);
82+ }
83+ }
84+ return out;
85+}
86+
87+export function moveSegment(segs: Segment[], index: number, delta: number): Segment[] {
88+ const j = index + delta;
89+ if (index < 0 || index >= segs.length || j < 0 || j >= segs.length) return segs;
90+ const out = segs.slice();
91+ const [s] = out.splice(index, 1);
92+ out.splice(j, 0, s);
93+ return out;
94+}
95+
96+// Timeline time where take time `takeTime` of take `takeId` appears (first occurrence), or null if cut.
97+export function takeTimeToTimeline(segs: Segment[], takeId: string, takeTime: number): number | null {
98+ let acc = 0;
99+ for (const s of segs) {
100+ if (s.takeId === takeId && takeTime >= s.start && takeTime <= s.end) return acc + (takeTime - s.start);
101+ acc += segDuration(s);
102+ }
103+ return null;
104+}
shared/types.tsadded+149−0View file
@@ -0,0 +1,149 @@
1+// Data model shared by the server and the browser UI.
2+
3+export type TrackKind = 'screen' | 'camera';
4+
5+export interface Take {
6+ id: string;
7+ createdAt: string;
8+ status: 'recording' | 'processing' | 'ready' | 'failed';
9+ tracks: { screen: boolean; camera: boolean };
10+ // Which track file carries the microphone audio (null if no mic was recorded).
11+ audio: TrackKind | null;
12+ // Seconds; 0 until the take is finalized.
13+ duration: number;
14+ transcript?: { status: 'pending' | 'done' | 'failed'; error?: string; updatedAt: string };
15+ // Background removal of the camera track (cutout.webm, VP9 with alpha). queued/running are live
16+ // states reported by the server; only done/failed are persisted.
17+ matte?: { status: 'queued' | 'running' | 'done' | 'failed'; progress?: number; error?: string; updatedAt: string };
18+ error?: string;
19+}
20+
21+// A piece of a take, [start, end) in take time (seconds). The timeline is an ordered list of these.
22+export interface Segment {
23+ id: string;
24+ takeId: string;
25+ start: number;
26+ end: number;
27+}
28+
29+export type LayoutMode = 'pip' | 'side-by-side' | 'screen-only' | 'camera-only';
30+export type Corner = 'tl' | 'tr' | 'bl' | 'br';
31+export type CameraShape = 'rect' | 'rounded' | 'circle';
32+export type BackgroundMode = 'none' | 'blur' | 'color' | 'cutout';
33+
34+// What replaces the room behind the speaker (requires background removal of the camera takes).
35+export interface Background {
36+ mode: BackgroundMode;
37+ color: string; // #rrggbb, for 'color'
38+ blur: number; // Gaussian sigma in pixels at 1080p output, for 'blur'
39+}
40+
41+export const DEFAULT_BACKGROUND: Background = { mode: 'none', color: '#1e2530', blur: 18 };
42+
43+export const backgroundOf = (l: Layout): Background => ({ ...DEFAULT_BACKGROUND, ...l.background });
44+
45+// How screen and camera are combined in takes that have both.
46+export interface Layout {
47+ mode: LayoutMode;
48+ corner: Corner; // PiP corner; for side-by-side, the left/right side of the camera column
49+ size: number; // PiP width as a fraction of the output width
50+ shape: CameraShape;
51+ background?: Background; // absent in videos created before background removal existed
52+}
53+
54+export interface OutputSettings {
55+ height: 720 | 1080;
56+ normalizeAudio: boolean;
57+}
58+
59+export interface ExportInfo {
60+ file: string;
61+ exportedAt: string;
62+ signature: string; // exportSignature() at the time of export, used to detect stale exports
63+ duration: number;
64+ bytes: number;
65+}
66+
67+export interface Video {
68+ id: string;
69+ title: string;
70+ description: string;
71+ notes: string;
72+ createdAt: string;
73+ updatedAt: string;
74+ takes: Take[];
75+ timeline: Segment[];
76+ layout: Layout;
77+ output: OutputSettings;
78+ thumbnail: { file: string; custom: boolean; updatedAt: string } | null;
79+ export: ExportInfo | null;
80+}
81+
82+export interface VideoSummary {
83+ id: string;
84+ title: string;
85+ description: string;
86+ createdAt: string;
87+ updatedAt: string;
88+ duration: number;
89+ takeCount: number;
90+ hasScreen: boolean;
91+ hasCamera: boolean;
92+ thumbnailUrl: string | null;
93+ exportState: 'none' | 'current' | 'stale';
94+}
95+
96+export interface ExportJob {
97+ status: 'running' | 'done' | 'failed';
98+ progress: number; // 0..1
99+ startedAt: string;
100+ error?: string;
101+}
102+
103+export interface AppConfig {
104+ deepgramModel: string;
105+ autoTranscribe: boolean;
106+ defaultCountdown: number;
107+ defaultCameraHeight: 720 | 1080;
108+ defaultOutputHeight: 720 | 1080;
109+}
110+
111+export interface PublicConfig extends AppConfig {
112+ hasDeepgramKey: boolean;
113+ libraryDir: string;
114+}
115+
116+export interface Word {
117+ word: string;
118+ text: string; // punctuated form for display
119+ start: number;
120+ end: number;
121+}
122+
123+export interface Transcript {
124+ takeId: string;
125+ model: string;
126+ createdAt: string;
127+ text: string;
128+ words: Word[];
129+}
130+
131+// Audio peaks for waveform display: `rate` values per second, each 0..255, base64-encoded bytes.
132+export interface Peaks {
133+ rate: number;
134+ data: string;
135+}
136+
137+export function exportSignature(v: Pick<Video, 'timeline' | 'layout' | 'output'>): string {
138+ const seg = v.timeline.map((s) => [s.takeId, round3(s.start), round3(s.end)]);
139+ const l = v.layout;
140+ const o = v.output;
141+ const b = backgroundOf(l);
142+ // The background is omitted when off so that exports made before it existed stay current.
143+ const bg = b.mode === 'color' ? ['color', b.color] : b.mode === 'blur' ? ['blur', b.blur] : b.mode;
144+ return JSON.stringify({ seg, layout: [l.mode, l.corner, round3(l.size), l.shape], ...(b.mode === 'none' ? {} : { bg }), output: [o.height, o.normalizeAudio] });
145+}
146+
147+function round3(x: number) {
148+ return Math.round(x * 1000) / 1000;
149+}
src/App.tsxadded+38−0View file
@@ -0,0 +1,38 @@
1+import { useEffect, useState } from 'react';
2+import { Library } from './pages/Library.tsx';
3+import { Prompter } from './pages/Prompter.tsx';
4+import { Recorder } from './pages/Recorder.tsx';
5+import { Settings } from './pages/Settings.tsx';
6+import { VideoPage } from './pages/VideoPage.tsx';
7+
8+// Hash routes:
9+// #/ library
10+// #/settings
11+// #/video/:id editor
12+// #/video/:id/record recorder
13+// #/prompter/:id pop-out teleprompter window
14+
15+function useHash() {
16+ const [hash, setHash] = useState(window.location.hash);
17+ useEffect(() => {
18+ const on = () => setHash(window.location.hash);
19+ window.addEventListener('hashchange', on);
20+ return () => window.removeEventListener('hashchange', on);
21+ }, []);
22+ return hash.replace(/^#\/?/, '');
23+}
24+
25+export function navigate(path: string) {
26+ window.location.hash = `#/${path.replace(/^\//, '')}`;
27+}
28+
29+export function App() {
30+ const path = useHash();
31+ const parts = path.split('/').filter(Boolean);
32+
33+ if (parts[0] === 'settings') return <Settings />;
34+ if (parts[0] === 'prompter' && parts[1]) return <Prompter videoId={parts[1]} />;
35+ if (parts[0] === 'video' && parts[1] && parts[2] === 'record') return <Recorder key={parts[1]} videoId={parts[1]} />;
36+ if (parts[0] === 'video' && parts[1]) return <VideoPage key={parts[1]} videoId={parts[1]} />;
37+ return <Library />;
38+}
src/api.tsadded+61−0View file
@@ -0,0 +1,61 @@
1+import type { ExportJob, Peaks, PublicConfig, Take, TrackKind, Transcript, Video, VideoSummary } from '../shared/types.ts';
2+
3+async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
4+ const headers: Record<string, string> = {};
5+ let payload: BodyInit | undefined;
6+ if (method !== 'GET') headers['X-Takes'] = '1';
7+ if (body instanceof Blob) {
8+ payload = body;
9+ headers['Content-Type'] = body.type || 'application/octet-stream';
10+ } else if (body !== undefined) {
11+ payload = JSON.stringify(body);
12+ headers['Content-Type'] = 'application/json';
13+ }
14+ const res = await fetch(url, { method, headers, body: payload });
15+ if (!res.ok) {
16+ let msg = `${res.status} ${res.statusText}`;
17+ try {
18+ msg = (await res.json()).error ?? msg;
19+ } catch {
20+ // not JSON
21+ }
22+ throw new Error(msg);
23+ }
24+ return res.json() as Promise<T>;
25+}
26+
27+async function getOptional<T>(url: string): Promise<T | null> {
28+ const res = await fetch(url, { cache: 'no-cache' });
29+ return res.ok ? ((await res.json()) as T) : null;
30+}
31+
32+const v = (id: string) => `/api/videos/${id}`;
33+
34+export const api = {
35+ config: () => request<PublicConfig>('GET', '/api/config'),
36+ saveConfig: (patch: Record<string, unknown>) => request<{ ok: true }>('PUT', '/api/config', patch),
37+
38+ videos: () => request<VideoSummary[]>('GET', '/api/videos'),
39+ setOrder: (order: string[]) => request<{ ok: true }>('PUT', '/api/order', { order }),
40+ createVideo: (input: { title: string; description: string; notes: string }) => request<Video>('POST', '/api/videos', input),
41+ video: (id: string) => request<Video>('GET', v(id)),
42+ patchVideo: (id: string, patch: Partial<Video>) => request<Video>('PATCH', v(id), patch),
43+ deleteVideo: (id: string) => request<{ ok: true }>('DELETE', v(id)),
44+
45+ createTake: (id: string, input: { tracks: { screen: boolean; camera: boolean }; audio: TrackKind | null }) => request<Take>('POST', `${v(id)}/takes`, input),
46+ uploadChunk: (id: string, takeId: string, track: TrackKind, blob: Blob) => request<{ ok: true }>('POST', `${v(id)}/takes/${takeId}/chunk?track=${track}`, blob),
47+ finalizeTake: (id: string, takeId: string) => request<Video>('POST', `${v(id)}/takes/${takeId}/finalize`),
48+ deleteTake: (id: string, takeId: string) => request<Video>('DELETE', `${v(id)}/takes/${takeId}`),
49+ transcribe: (id: string, takeId: string) => request<Video>('POST', `${v(id)}/takes/${takeId}/transcribe`),
50+ transcript: (id: string, takeId: string) => getOptional<Transcript>(`/media/${id}/takes/${takeId}/transcript.json`),
51+ peaks: (id: string, takeId: string) => getOptional<Peaks>(`/media/${id}/takes/${takeId}/peaks.json`),
52+
53+ requestMattes: (id: string, takeId?: string) => request<Video>('POST', `${v(id)}/matte`, takeId ? { takeId } : {}),
54+ uploadThumbnail: (id: string, jpeg: Blob) => request<Video>('POST', `${v(id)}/thumbnail`, jpeg),
55+ startExport: (id: string) => request<ExportJob>('POST', `${v(id)}/export`),
56+ exportStatus: (id: string) => request<ExportJob | null>('GET', `${v(id)}/export`),
57+ openFolder: (id: string) => request<{ ok: true; dir: string }>('POST', `${v(id)}/open-folder`),
58+};
59+
60+export const takeFileUrl = (videoId: string, takeId: string, track: TrackKind | 'cutout', version?: string) =>
61+ `/media/${videoId}/takes/${takeId}/${track}.webm${version ? `?v=${encodeURIComponent(version)}` : ''}`;
src/components/Icon.tsxadded+52−0View file
@@ -0,0 +1,52 @@
1+// Small stroke icons (24x24 viewBox).
2+
3+const PATHS: Record<string, string> = {
4+ plus: 'M12 5v14M5 12h14',
5+ trash: 'M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6',
6+ back: 'M15 18l-6-6 6-6',
7+ play: 'M7 4.5v15l12-7.5z',
8+ pause: 'M8 5v14M16 5v14',
9+ stop: 'M6 6h12v12H6z',
10+ record: 'M12 12m-6 0a6 6 0 1 0 12 0a6 6 0 1 0-12 0',
11+ scissors: 'M6 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM20 4L8.1 15.9M14.5 14.5L20 20M8.1 8.1L12 12',
12+ split: 'M12 3v18M8 7l-4 5 4 5M16 7l4 5-4 5',
13+ undo: 'M9 14L4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3',
14+ redo: 'M15 14l5-5-5-5M20 9H9a5 5 0 0 0 0 10h3',
15+ folder: 'M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z',
16+ camera: 'M3 7h11a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zM15 11l6-3v8l-6-3',
17+ screen: 'M3 4h18v12H3zM8 20h8M12 16v4',
18+ mic: 'M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3zM5 11a7 7 0 0 0 14 0M12 18v3',
19+ grip: 'M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01',
20+ settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
21+ image: 'M4 5h16v14H4zM4 16l5-5 4 4 2-2 5 5M15.5 9.5h.01',
22+ film: 'M4 4h16v16H4zM8 4v16M16 4v16M4 9h4M4 15h4M16 9h4M16 15h4',
23+ left: 'M14 7l-5 5 5 5',
24+ right: 'M10 7l5 5-5 5',
25+ popout: 'M14 4h6v6M20 4l-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
26+ text: 'M4 6h16M4 12h16M4 18h10',
27+ check: 'M5 12l5 5 9-10',
28+ x: 'M6 6l12 12M18 6L6 18',
29+ external: 'M14 4h6v6M20 4l-9 9M19 14v5H5V5h5',
30+ trimStart: 'M5 4v16M9 12h10M13 8l-4 4 4 4',
31+ trimEnd: 'M19 4v16M15 12H5M11 8l4 4-4 4',
32+};
33+
34+export function Icon({ name, size = 16, className }: { name: keyof typeof PATHS | string; size?: number; className?: string }) {
35+ const filled = name === 'play' || name === 'record' || name === 'stop';
36+ return (
37+ <svg
38+ className={className}
39+ width={size}
40+ height={size}
41+ viewBox="0 0 24 24"
42+ fill={filled ? 'currentColor' : 'none'}
43+ stroke="currentColor"
44+ strokeWidth={name === 'grip' ? 3 : 2}
45+ strokeLinecap="round"
46+ strokeLinejoin="round"
47+ aria-hidden="true"
48+ >
49+ <path d={PATHS[name] ?? ''} />
50+ </svg>
51+ );
52+}
src/components/MicMeter.tsxadded+38−0View file
@@ -0,0 +1,38 @@
1+import { useEffect, useRef } from 'react';
2+
3+// Live input level of a microphone stream.
4+export function MicMeter({ stream }: { stream: MediaStream | null }) {
5+ const barRef = useRef<HTMLDivElement>(null);
6+ useEffect(() => {
7+ if (!stream || stream.getAudioTracks().length === 0) return;
8+ const ctx = new AudioContext();
9+ const src = ctx.createMediaStreamSource(stream);
10+ const analyser = ctx.createAnalyser();
11+ analyser.fftSize = 1024;
12+ src.connect(analyser);
13+ const buf = new Float32Array(analyser.fftSize);
14+ let raf = 0;
15+ let level = 0;
16+ const loop = () => {
17+ analyser.getFloatTimeDomainData(buf);
18+ let peak = 0;
19+ for (const x of buf) peak = Math.max(peak, Math.abs(x));
20+ level = Math.max(peak, level * 0.9);
21+ if (barRef.current) {
22+ barRef.current.style.width = `${Math.min(100, Math.sqrt(level) * 100)}%`;
23+ barRef.current.classList.toggle('hot', level > 0.9);
24+ }
25+ raf = requestAnimationFrame(loop);
26+ };
27+ loop();
28+ return () => {
29+ cancelAnimationFrame(raf);
30+ ctx.close();
31+ };
32+ }, [stream]);
33+ return (
34+ <div className="meter" title="Microphone level">
35+ <div className="meter-bar" ref={barRef} />
36+ </div>
37+ );
38+}
src/components/Modal.tsxadded+56−0View file
@@ -0,0 +1,56 @@
1+import { useEffect, type ReactNode } from 'react';
2+
3+export function Modal({ title, onClose, children, footer, wide }: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode; wide?: boolean }) {
4+ useEffect(() => {
5+ const onKey = (e: KeyboardEvent) => {
6+ if (e.key === 'Escape') onClose();
7+ };
8+ window.addEventListener('keydown', onKey);
9+ return () => window.removeEventListener('keydown', onKey);
10+ }, [onClose]);
11+ return (
12+ <div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
13+ <div className={`modal${wide ? ' wide' : ''}`} role="dialog" aria-modal="true" aria-label={title}>
14+ <h2>{title}</h2>
15+ <div className="modal-body">{children}</div>
16+ {footer && <div className="modal-footer">{footer}</div>}
17+ </div>
18+ </div>
19+ );
20+}
21+
22+export interface ConfirmRequest {
23+ title: string;
24+ message: ReactNode;
25+ confirmLabel: string;
26+ danger?: boolean;
27+ onConfirm: () => void | Promise<void>;
28+}
29+
30+export function ConfirmDialog({ req, onClose }: { req: ConfirmRequest; onClose: () => void }) {
31+ return (
32+ <Modal
33+ title={req.title}
34+ onClose={onClose}
35+ footer={
36+ <>
37+ <button className="btn" onClick={onClose}>
38+ Cancel
39+ </button>
40+ <button
41+ className={`btn ${req.danger ? 'danger' : 'primary'}`}
42+ autoFocus
43+ onClick={async () => {
44+ onClose();
45+ await req.onConfirm();
46+ }}
47+ >
48+ {req.confirmLabel}
49+ </button>
50+ </>
51+ }
52+ >
53+ <p>{req.message}</p>
54+ </Modal>
55+ );
56+}
src/components/Stage.tsxadded+137−0View file
@@ -0,0 +1,137 @@
1+// Composite preview of the timeline, laid out in output pixels and scaled to fit, so it matches the export.
2+
3+import { useEffect, useRef, useState, type CSSProperties } from 'react';
4+import type { Take, Video } from '../../shared/types.ts';
5+import { computeGeometry, outputSize, type Box, type Camera } from '../../shared/layout.ts';
6+import { takeFileUrl } from '../api.ts';
7+import { usePlayerActiveTake, type TimelinePlayer } from '../lib/player.ts';
8+
9+const boxStyle = (b: Box): CSSProperties => ({ left: b.x, top: b.y, width: b.w, height: b.h });
10+
11+export function Stage({ video, player }: { video: Video; player: TimelinePlayer }) {
12+ const active = usePlayerActiveTake(player);
13+ const outerRef = useRef<HTMLDivElement>(null);
14+ const [scale, setScale] = useState(0.4);
15+ const { W, H } = outputSize(video.output);
16+
17+ useEffect(() => {
18+ const el = outerRef.current;
19+ if (!el) return;
20+ const ro = new ResizeObserver(() => setScale(el.clientWidth / W));
21+ ro.observe(el);
22+ return () => ro.disconnect();
23+ }, [W]);
24+
25+ const takes = video.takes.filter((t) => t.status === 'ready');
26+ return (
27+ <div className="stage" ref={outerRef} style={{ aspectRatio: `${W} / ${H}` }} onClick={() => player.toggle()}>
28+ <div className="stage-inner" style={{ width: W, height: H, transform: `scale(${scale})` }}>
29+ {takes.map((t) => {
30+ const g = computeGeometry(video.layout, video.output, t.tracks.screen, t.tracks.camera);
31+ return (
32+ <div key={t.id} style={{ display: t.id === active ? 'block' : 'none' }}>
33+ {t.tracks.screen && (
34+ <video
35+ ref={player.refFor(t.id, 'screen')}
36+ src={takeFileUrl(video.id, t.id, 'screen')}
37+ muted={t.audio !== 'screen'}
38+ preload="auto"
39+ playsInline
40+ className="layer"
41+ style={g.screen ? { ...boxStyle(g.screen), objectFit: 'contain' } : { display: 'none' }}
42+ />
43+ )}
44+ {t.tracks.camera && <CameraLayer videoId={video.id} take={t} cam={g.camera} player={player} />}
45+ </div>
46+ );
47+ })}
48+ </div>
49+ {!active && <div className="stage-empty">Nothing in the timeline yet</div>}
50+ </div>
51+ );
52+}
53+
54+// The camera box: the original camera, or (once background removal has run) the chosen background
55+// with the cutout on top. The camera element stays mounted even when hidden, since it carries the audio.
56+function CameraLayer({ videoId, take, cam, player }: { videoId: string; take: Take; cam: Camera | null; player: TimelinePlayer }) {
57+ const camSrc = takeFileUrl(videoId, take.id, 'camera');
58+ const muted = take.audio !== 'camera';
59+ if (!cam) return <video ref={player.refFor(take.id, 'camera')} src={camSrc} muted={muted} preload="auto" playsInline style={{ display: 'none' }} />;
60+ const hasCutout = cam.background !== 'none' && take.matte?.status === 'done';
61+ const bg = hasCutout ? cam.background : 'none';
62+ const media: CSSProperties = { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: cam.fit };
63+ return (
64+ <div className="layer" style={{ ...boxStyle(cam.box), borderRadius: cam.radius, overflow: 'hidden', background: bg === 'color' ? cam.color : bg === 'cutout' ? 'transparent' : '#000' }}>
65+ <video
66+ ref={player.refFor(take.id, 'camera')}
67+ src={camSrc}
68+ muted={muted}
69+ preload="auto"
70+ playsInline
71+ style={{
72+ ...media,
73+ visibility: bg === 'color' || bg === 'cutout' ? 'hidden' : 'visible',
74+ // Scaled slightly so the blur does not fade out at the edges of the box.
75+ ...(bg === 'blur' ? { filter: `blur(${cam.blurSigma}px)`, transform: 'scale(1.06)' } : {}),
76+ }}
77+ />
78+ {hasCutout && <video ref={player.refFor(take.id, 'cutout')} src={takeFileUrl(videoId, take.id, 'cutout', take.matte!.updatedAt)} muted preload="auto" playsInline style={media} />}
79+ </div>
80+ );
81+}
82+
83+function drawFit(ctx: CanvasRenderingContext2D, el: HTMLVideoElement, b: Box, fit: 'contain' | 'cover') {
84+ const vw = el.videoWidth;
85+ const vh = el.videoHeight;
86+ if (!vw || !vh) return;
87+ if (fit === 'contain') {
88+ const s = Math.min(b.w / vw, b.h / vh);
89+ const w = vw * s;
90+ const h = vh * s;
91+ ctx.drawImage(el, b.x + (b.w - w) / 2, b.y + (b.h - h) / 2, w, h);
92+ } else {
93+ const s = Math.max(b.w / vw, b.h / vh);
94+ const sw = b.w / s;
95+ const sh = b.h / s;
96+ ctx.drawImage(el, (vw - sw) / 2, (vh - sh) / 2, sw, sh, b.x, b.y, b.w, b.h);
97+ }
98+}
99+
100+// Render the current preview frame to a 640-wide JPEG (for the thumbnail).
101+export async function captureFrame(video: Video, player: TimelinePlayer): Promise<Blob | null> {
102+ const takeId = player.getSnapshot().activeTakeId;
103+ const take = video.takes.find((t) => t.id === takeId);
104+ if (!take) return null;
105+ const els = player.elementsFor(take.id);
106+ const g = computeGeometry(video.layout, video.output, take.tracks.screen, take.tracks.camera);
107+ const canvas = document.createElement('canvas');
108+ canvas.width = 640;
109+ canvas.height = Math.round((640 * g.H) / g.W);
110+ const ctx = canvas.getContext('2d')!;
111+ ctx.scale(640 / g.W, 640 / g.W);
112+ ctx.fillStyle = '#000';
113+ ctx.fillRect(0, 0, g.W, g.H);
114+ if (g.screen && els.screen) drawFit(ctx, els.screen, g.screen, 'contain');
115+ if (g.camera && els.camera) {
116+ const { box, fit, radius, background, color, blurSigma } = g.camera;
117+ const cutout = background !== 'none' && take.matte?.status === 'done' ? els.cutout : undefined;
118+ ctx.save();
119+ ctx.beginPath();
120+ ctx.roundRect(box.x, box.y, box.w, box.h, radius);
121+ ctx.clip();
122+ if (!cutout) drawFit(ctx, els.camera, box, fit);
123+ else {
124+ if (background === 'color') {
125+ ctx.fillStyle = color;
126+ ctx.fillRect(box.x, box.y, box.w, box.h);
127+ } else if (background === 'blur') {
128+ ctx.filter = `blur(${(blurSigma * 640) / g.W}px)`;
129+ drawFit(ctx, els.camera, box, fit);
130+ ctx.filter = 'none';
131+ }
132+ drawFit(ctx, cutout, box, fit);
133+ }
134+ ctx.restore();
135+ }
136+ return new Promise((resolve) => canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.88));
137+}
src/components/Timeline.tsxadded+161−0View file
@@ -0,0 +1,161 @@
1+import { useEffect, useRef, useState, type PointerEvent as RPointerEvent } from 'react';
2+import type { Segment } from '../../shared/types.ts';
3+import { locate, segDuration, segmentStarts, timelineDuration } from '../../shared/timeline.ts';
4+import { fmtDuration, fmtTimecode } from '../lib/format.ts';
5+import { usePlayerTime, type TimelinePlayer } from '../lib/player.ts';
6+
7+export interface PeaksData {
8+ rate: number;
9+ values: Uint8Array;
10+}
11+
12+interface Props {
13+ segments: Segment[];
14+ takeNumber: (takeId: string) => number;
15+ player: TimelinePlayer;
16+ selectedId: string | null;
17+ onSelect: (id: string | null) => void;
18+ marks: { in: number | null; out: number | null };
19+ peaks: Record<string, PeaksData>;
20+ zoom: number;
21+}
22+
23+export const takeHue = (n: number) => (200 + n * 67) % 360;
24+
25+export function Timeline({ segments, takeNumber, player, selectedId, onSelect, marks, peaks, zoom }: Props) {
26+ const duration = timelineDuration(segments);
27+ const starts = segmentStarts(segments);
28+ const scrollRef = useRef<HTMLDivElement>(null);
29+ const trackRef = useRef<HTMLDivElement>(null);
30+ const dragging = useRef(false);
31+ const pct = (t: number) => `${duration > 0 ? (t / duration) * 100 : 0}%`;
32+
33+ const timeAt = (clientX: number) => {
34+ const r = trackRef.current!.getBoundingClientRect();
35+ return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration;
36+ };
37+
38+ const onPointerDown = (e: RPointerEvent) => {
39+ if (duration <= 0) return;
40+ dragging.current = true;
41+ e.currentTarget.setPointerCapture(e.pointerId);
42+ const t = timeAt(e.clientX);
43+ player.seek(t);
44+ const loc = locate(segments, t);
45+ onSelect(loc ? segments[loc.index].id : null);
46+ };
47+ const onPointerMove = (e: RPointerEvent) => {
48+ if (dragging.current) player.seek(timeAt(e.clientX));
49+ };
50+ const onPointerUp = () => {
51+ dragging.current = false;
52+ };
53+
54+ const hasRange = marks.in !== null && marks.out !== null && marks.out > marks.in;
55+
56+ return (
57+ <div className="timeline-scroll" ref={scrollRef}>
58+ <div
59+ className="timeline"
60+ ref={trackRef}
61+ style={{ width: `${zoom * 100}%` }}
62+ onPointerDown={onPointerDown}
63+ onPointerMove={onPointerMove}
64+ onPointerUp={onPointerUp}
65+ onPointerCancel={onPointerUp}
66+ >
67+ <Ruler duration={duration} zoom={zoom} />
68+ <div className="tl-track">
69+ {segments.map((s, i) => {
70+ const n = takeNumber(s.takeId);
71+ return (
72+ <div
73+ key={s.id}
74+ className={`tl-seg${s.id === selectedId ? ' selected' : ''}`}
75+ style={{ left: pct(starts[i]), width: pct(segDuration(s)), ['--hue' as string]: takeHue(n) }}
76+ title={`Take ${n}: ${fmtDuration(s.start)}–${fmtDuration(s.end)}`}
77+ >
78+ <Waveform peaks={peaks[s.takeId]} start={s.start} end={s.end} />
79+ <span className="tl-label">Take {n}</span>
80+ </div>
81+ );
82+ })}
83+ </div>
84+ {hasRange && <div className="tl-range" style={{ left: pct(marks.in!), width: pct(marks.out! - marks.in!) }} />}
85+ {marks.in !== null && <div className="tl-mark in" style={{ left: pct(marks.in) }} />}
86+ {marks.out !== null && <div className="tl-mark out" style={{ left: pct(marks.out) }} />}
87+ <Playhead player={player} duration={duration} scrollRef={scrollRef} />
88+ </div>
89+ </div>
90+ );
91+}
92+
93+function Playhead({ player, duration, scrollRef }: { player: TimelinePlayer; duration: number; scrollRef: React.RefObject<HTMLDivElement | null> }) {
94+ const time = usePlayerTime(player);
95+ const ref = useRef<HTMLDivElement>(null);
96+ const frac = duration > 0 ? time / duration : 0;
97+ useEffect(() => {
98+ // Keep the playhead in view when zoomed in.
99+ const sc = scrollRef.current;
100+ const el = ref.current;
101+ if (!sc || !el) return;
102+ const x = el.offsetLeft;
103+ if (x < sc.scrollLeft + 20 || x > sc.scrollLeft + sc.clientWidth - 20) sc.scrollLeft = x - sc.clientWidth * 0.3;
104+ }, [frac, scrollRef]);
105+ return <div className="tl-playhead" ref={ref} style={{ left: `${frac * 100}%` }} />;
106+}
107+
108+function Ruler({ duration, zoom }: { duration: number; zoom: number }) {
109+ if (duration <= 0) return <div className="tl-ruler" />;
110+ const steps = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1200];
111+ const step = steps.find((s) => duration / s <= 10 * zoom) ?? 1800;
112+ const ticks: number[] = [];
113+ for (let t = 0; t <= duration + 1e-6; t += step) ticks.push(t);
114+ return (
115+ <div className="tl-ruler">
116+ {ticks.map((t) => (
117+ <span key={t} className="tl-tick" style={{ left: `${(t / duration) * 100}%` }}>
118+ {step < 1 ? fmtTimecode(t) : fmtDuration(t)}
119+ </span>
120+ ))}
121+ </div>
122+ );
123+}
124+
125+function Waveform({ peaks, start, end }: { peaks?: PeaksData; start: number; end: number }) {
126+ const ref = useRef<HTMLCanvasElement>(null);
127+ const [size, setSize] = useState({ w: 0, h: 0 });
128+
129+ useEffect(() => {
130+ const el = ref.current;
131+ if (!el) return;
132+ const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }));
133+ ro.observe(el);
134+ return () => ro.disconnect();
135+ }, []);
136+
137+ useEffect(() => {
138+ const c = ref.current;
139+ if (!c || !peaks || c.clientWidth === 0) return;
140+ const dpr = window.devicePixelRatio || 1;
141+ c.width = Math.round(c.clientWidth * dpr);
142+ c.height = Math.round(c.clientHeight * dpr);
143+ const ctx = c.getContext('2d')!;
144+ ctx.clearRect(0, 0, c.width, c.height);
145+ ctx.fillStyle = 'rgba(255,255,255,0.5)';
146+ const { rate, values } = peaks;
147+ const i0 = start * rate;
148+ const perPx = ((end - start) * rate) / c.width;
149+ const mid = c.height / 2;
150+ for (let x = 0; x < c.width; x++) {
151+ const a = Math.floor(i0 + x * perPx);
152+ const b = Math.max(a + 1, Math.floor(i0 + (x + 1) * perPx));
153+ let m = 0;
154+ for (let i = a; i < b && i < values.length; i++) if (values[i] > m) m = values[i];
155+ const h = (m / 255) * c.height * 0.9;
156+ ctx.fillRect(x, mid - h / 2, 1, Math.max(1, h));
157+ }
158+ }, [peaks, start, end, size]);
159+
160+ return <canvas className="tl-wave" ref={ref} />;
161+}
src/components/TranscriptPanel.tsxadded+144−0View file
@@ -0,0 +1,144 @@
1+// Transcript of what is currently in the timeline. Click a word to seek; shift-click to select a
2+// range of words, which can then be cut from the timeline.
3+
4+import { useEffect, useMemo, useRef } from 'react';
5+import type { Segment, Take, Transcript } from '../../shared/types.ts';
6+import { segmentStarts } from '../../shared/timeline.ts';
7+import type { TimelinePlayer } from '../lib/player.ts';
8+import { Icon } from './Icon.tsx';
9+
10+interface TWord {
11+ text: string;
12+ t0: number;
13+ t1: number;
14+ seg: number;
15+}
16+
17+interface Props {
18+ segments: Segment[];
19+ takes: Take[];
20+ transcripts: Record<string, Transcript>;
21+ takeNumber: (takeId: string) => number;
22+ player: TimelinePlayer;
23+ marks: { in: number | null; out: number | null };
24+ onMarks: (m: { in: number | null; out: number | null }) => void;
25+ onCut: () => void;
26+ onTranscribe: (takeId: string) => void;
27+ hasKey: boolean;
28+}
29+
30+export function TranscriptPanel({ segments, takes, transcripts, takeNumber, player, marks, onMarks, onCut, onTranscribe, hasKey }: Props) {
31+ const words = useMemo(() => {
32+ const out: TWord[] = [];
33+ const starts = segmentStarts(segments);
34+ segments.forEach((s, i) => {
35+ const tr = transcripts[s.takeId];
36+ if (!tr) return;
37+ for (const w of tr.words) {
38+ const mid = (w.start + w.end) / 2;
39+ if (mid < s.start || mid >= s.end) continue;
40+ out.push({ text: w.text, t0: starts[i] + Math.max(0, w.start - s.start), t1: starts[i] + Math.min(s.end, w.end) - s.start, seg: i });
41+ }
42+ });
43+ return out;
44+ }, [segments, transcripts]);
45+
46+ const containerRef = useRef<HTMLDivElement>(null);
47+ const anchor = useRef<number | null>(null);
48+
49+ // Highlight the word under the playhead without re-rendering the whole list.
50+ useEffect(() => {
51+ let current = -1;
52+ const update = () => {
53+ const t = player.getSnapshot().time;
54+ let lo = 0;
55+ let hi = words.length - 1;
56+ let idx = -1;
57+ while (lo <= hi) {
58+ const m = (lo + hi) >> 1;
59+ if (words[m].t0 <= t) {
60+ idx = m;
61+ lo = m + 1;
62+ } else hi = m - 1;
63+ }
64+ if (idx >= 0 && t > words[idx].t1 + 0.3) idx = -1;
65+ if (idx === current) return;
66+ const root = containerRef.current;
67+ root?.querySelector(`[data-i="${current}"]`)?.classList.remove('current');
68+ const el = root?.querySelector(`[data-i="${idx}"]`);
69+ el?.classList.add('current');
70+ if (el && player.getSnapshot().playing) el.scrollIntoView({ block: 'nearest' });
71+ current = idx;
72+ };
73+ update();
74+ return player.subscribe(update);
75+ }, [player, words]);
76+
77+ const hasRange = marks.in !== null && marks.out !== null && marks.out > marks.in;
78+ const inRange = (w: TWord) => hasRange && w.t0 >= marks.in! - 0.01 && w.t1 <= marks.out! + 0.01;
79+
80+ const onWordClick = (i: number, shift: boolean) => {
81+ const w = words[i];
82+ if (shift && anchor.current !== null) {
83+ const a = Math.min(anchor.current, i);
84+ const b = Math.max(anchor.current, i);
85+ onMarks({ in: words[a].t0, out: words[b].t1 });
86+ } else {
87+ anchor.current = i;
88+ onMarks({ in: null, out: null });
89+ player.seek(w.t0);
90+ }
91+ };
92+
93+ const takeIds = [...new Set(segments.map((s) => s.takeId))];
94+ const status = takeIds
95+ .map((id) => takes.find((t) => t.id === id))
96+ .filter((t): t is Take => !!t && t.transcript?.status !== 'done');
97+
98+ return (
99+ <div className="transcript">
100+ <div className="transcript-bar">
101+ <span className="muted small">Click a word to jump there. Shift-click another word to select a range.</span>
102+ <button className="btn small danger" disabled={!hasRange} onClick={onCut} title="Cut selection (X)">
103+ <Icon name="scissors" /> Cut selection
104+ </button>
105+ </div>
106+ {status.length > 0 && (
107+ <div className="transcript-status">
108+ {status.map((t) => (
109+ <div key={t.id} className="row small">
110+ <span>Take {takeNumber(t.id)}:</span>
111+ {!t.audio ? (
112+ <span className="muted">no audio</span>
113+ ) : t.transcript?.status === 'pending' ? (
114+ <span className="muted">transcribing…</span>
115+ ) : (
116+ <>
117+ <span className="muted">{t.transcript?.status === 'failed' ? `failed (${t.transcript.error})` : 'not transcribed'}</span>
118+ {hasKey ? (
119+ <button className="btn small" onClick={() => onTranscribe(t.id)}>
120+ Transcribe
121+ </button>
122+ ) : (
123+ <a href="#/settings">Add a Deepgram key in Settings</a>
124+ )}
125+ </>
126+ )}
127+ </div>
128+ ))}
129+ </div>
130+ )}
131+ <div className="transcript-words" ref={containerRef}>
132+ {words.length === 0 && status.length === 0 && <p className="muted">No words.</p>}
133+ {words.map((w, i) => (
134+ <span key={i}>
135+ {i > 0 && words[i - 1].seg !== w.seg && <span className="seg-break" title="Cut / segment boundary" />}
136+ <span data-i={i} className={`word${inRange(w) ? ' selected' : ''}`} onMouseDown={(e) => e.shiftKey && e.preventDefault()} onClick={(e) => onWordClick(i, e.shiftKey)}>
137+ {w.text}
138+ </span>{' '}
139+ </span>
140+ ))}
141+ </div>
142+ </div>
143+ );
144+}
src/lib/channel.tsadded+17−0View file
@@ -0,0 +1,17 @@
1+// Messages between the recorder page (which owns the media streams) and the pop-out teleprompter.
2+
3+export type RecPhase = 'setup' | 'countdown' | 'recording' | 'paused' | 'processing';
4+
5+export interface RecState {
6+ phase: RecPhase;
7+ elapsed: number;
8+ countdown: number;
9+ canStart: boolean;
10+ message?: string;
11+}
12+
13+export type RecCommand = 'start' | 'pause' | 'resume' | 'stop' | 'hello';
14+
15+export type RecMessage = { type: 'state'; state: RecState } | { type: 'command'; cmd: RecCommand } | { type: 'closed' };
16+
17+export const channelName = (videoId: string) => `takes-recorder-${videoId}`;
src/lib/format.tsadded+56−0View file
@@ -0,0 +1,56 @@
1+export function fmtDuration(sec: number): string {
2+ if (!isFinite(sec) || sec < 0) sec = 0;
3+ const s = Math.floor(sec % 60);
4+ const m = Math.floor((sec / 60) % 60);
5+ const h = Math.floor(sec / 3600);
6+ const ss = String(s).padStart(2, '0');
7+ return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
8+}
9+
10+// m:ss.t, for editing
11+export function fmtTimecode(sec: number): string {
12+ if (!isFinite(sec) || sec < 0) sec = 0;
13+ const tenths = Math.floor((sec * 10) % 10);
14+ return `${fmtDuration(sec)}.${tenths}`;
15+}
16+
17+export function fmtDate(iso: string): string {
18+ const d = new Date(iso);
19+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
20+}
21+
22+export function fmtDateTime(iso: string): string {
23+ const d = new Date(iso);
24+ return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
25+}
26+
27+export function fmtBytes(n: number): string {
28+ if (n < 1024) return `${n} B`;
29+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(0)} KB`;
30+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
31+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
32+}
33+
34+export function decodePeaks(data: string): Uint8Array {
35+ const bin = atob(data);
36+ const out = new Uint8Array(bin.length);
37+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
38+ return out;
39+}
40+
41+export function loadPref<T>(key: string, fallback: T): T {
42+ try {
43+ const s = localStorage.getItem(key);
44+ return s ? { ...fallback, ...JSON.parse(s) } : fallback;
45+ } catch {
46+ return fallback;
47+ }
48+}
49+
50+export function savePref(key: string, value: unknown) {
51+ try {
52+ localStorage.setItem(key, JSON.stringify(value));
53+ } catch {
54+ // storage unavailable
55+ }
56+}
src/lib/player.tsadded+186−0View file
@@ -0,0 +1,186 @@
1+// Plays the timeline (segments of takes) using one pair of <video> elements per take.
2+// The element carrying the mic audio is the clock; the other track is kept in sync with it.
3+
4+import { useSyncExternalStore } from 'react';
5+import type { Segment, Take, TrackKind } from '../../shared/types.ts';
6+import { locate, segmentStarts, timelineDuration } from '../../shared/timeline.ts';
7+
8+export interface PlayerSnapshot {
9+ time: number;
10+ playing: boolean;
11+ activeTakeId: string | null;
12+}
13+
14+// Each take has up to three elements: the screen, the camera, and the camera with its background removed.
15+export type ElKey = TrackKind | 'cutout';
16+type Els = Partial<Record<ElKey, HTMLVideoElement>>;
17+
18+export class TimelinePlayer {
19+ private segments: Segment[] = [];
20+ private takes = new Map<string, Take>();
21+ private starts: number[] = [];
22+ private els = new Map<string, Els>();
23+ private refCallbacks = new Map<string, (el: HTMLVideoElement | null) => void>();
24+ private segIndex = 0;
25+ private raf = 0;
26+ private listeners = new Set<() => void>();
27+ private snap: PlayerSnapshot = { time: 0, playing: false, activeTakeId: null };
28+ duration = 0;
29+
30+ subscribe = (fn: () => void) => {
31+ this.listeners.add(fn);
32+ return () => {
33+ this.listeners.delete(fn);
34+ };
35+ };
36+
37+ getSnapshot = () => this.snap;
38+
39+ private emit(patch: Partial<PlayerSnapshot>) {
40+ this.snap = { ...this.snap, ...patch };
41+ this.listeners.forEach((f) => f());
42+ }
43+
44+ setData(segments: Segment[], takes: Take[]) {
45+ this.segments = segments;
46+ this.takes = new Map(takes.map((t) => [t.id, t]));
47+ this.starts = segmentStarts(segments);
48+ this.duration = timelineDuration(segments);
49+ this.seek(Math.min(this.snap.time, this.duration));
50+ }
51+
52+ // Stable ref callback for a take's <video> element.
53+ refFor(takeId: string, track: ElKey) {
54+ const key = `${takeId}/${track}`;
55+ let cb = this.refCallbacks.get(key);
56+ if (!cb) {
57+ cb = (el) => this.register(takeId, track, el);
58+ this.refCallbacks.set(key, cb);
59+ }
60+ return cb;
61+ }
62+
63+ private register(takeId: string, track: ElKey, el: HTMLVideoElement | null) {
64+ const e = this.els.get(takeId) ?? {};
65+ if (el === (e[track] ?? null)) return;
66+ if (el) e[track] = el;
67+ else delete e[track];
68+ this.els.set(takeId, e);
69+ if (el && this.snap.activeTakeId === takeId) {
70+ const loc = locate(this.segments, this.snap.time);
71+ if (loc) el.currentTime = loc.takeTime;
72+ if (this.snap.playing) el.play().catch(() => {});
73+ }
74+ }
75+
76+ elementsFor(takeId: string): Els {
77+ return this.els.get(takeId) ?? {};
78+ }
79+
80+ private list(takeId: string): HTMLVideoElement[] {
81+ const e = this.els.get(takeId);
82+ return e ? ([e.screen, e.camera, e.cutout].filter(Boolean) as HTMLVideoElement[]) : [];
83+ }
84+
85+ private master(takeId: string): HTMLVideoElement | undefined {
86+ const e = this.els.get(takeId);
87+ const take = this.takes.get(takeId);
88+ if (!e || !take) return undefined;
89+ return (take.audio && e[take.audio]) || e.screen || e.camera;
90+ }
91+
92+ private activate(takeId: string, takeTime: number, play: boolean) {
93+ const prev = this.snap.activeTakeId;
94+ if (prev && prev !== takeId) this.list(prev).forEach((el) => el.pause());
95+ for (const el of this.list(takeId)) {
96+ if (Math.abs(el.currentTime - takeTime) > 0.01) el.currentTime = takeTime;
97+ if (play) el.play().catch(() => {});
98+ else el.pause();
99+ }
100+ if (prev !== takeId) this.emit({ activeTakeId: takeId });
101+ }
102+
103+ seek(t: number) {
104+ t = Math.max(0, Math.min(t, this.duration));
105+ const loc = locate(this.segments, t);
106+ if (!loc) {
107+ const prev = this.snap.activeTakeId;
108+ if (prev) this.list(prev).forEach((el) => el.pause());
109+ cancelAnimationFrame(this.raf);
110+ this.emit({ time: 0, playing: false, activeTakeId: null });
111+ return;
112+ }
113+ this.segIndex = loc.index;
114+ this.activate(this.segments[loc.index].takeId, loc.takeTime, this.snap.playing);
115+ this.emit({ time: t });
116+ }
117+
118+ play() {
119+ if (this.segments.length === 0) return;
120+ if (this.snap.time >= this.duration - 0.03) this.emit({ time: 0 });
121+ const loc = locate(this.segments, this.snap.time)!;
122+ this.segIndex = loc.index;
123+ this.emit({ playing: true });
124+ this.activate(this.segments[loc.index].takeId, loc.takeTime, true);
125+ cancelAnimationFrame(this.raf);
126+ this.raf = requestAnimationFrame(this.tick);
127+ }
128+
129+ pause() {
130+ cancelAnimationFrame(this.raf);
131+ const a = this.snap.activeTakeId;
132+ if (a) this.list(a).forEach((el) => el.pause());
133+ this.emit({ playing: false });
134+ }
135+
136+ toggle() {
137+ if (this.snap.playing) this.pause();
138+ else this.play();
139+ }
140+
141+ private tick = () => {
142+ if (!this.snap.playing) return;
143+ const seg = this.segments[this.segIndex];
144+ if (!seg) return this.pause();
145+ const m = this.master(seg.takeId);
146+ if (!m) {
147+ this.raf = requestAnimationFrame(this.tick);
148+ return;
149+ }
150+ let tt = m.currentTime;
151+ if (tt >= seg.end - 0.01 || m.ended) {
152+ const next = this.segIndex + 1;
153+ if (next >= this.segments.length) {
154+ this.pause();
155+ this.emit({ time: this.duration });
156+ return;
157+ }
158+ const ns = this.segments[next];
159+ this.segIndex = next;
160+ const continuous = ns.takeId === seg.takeId && Math.abs(ns.start - seg.end) < 0.05;
161+ if (!continuous) this.activate(ns.takeId, ns.start, true);
162+ tt = ns.start;
163+ } else {
164+ for (const el of this.list(seg.takeId)) if (el !== m && Math.abs(el.currentTime - tt) > 0.15) el.currentTime = tt;
165+ }
166+ const cur = this.segments[this.segIndex];
167+ this.emit({ time: this.starts[this.segIndex] + Math.max(0, Math.min(tt, cur.end) - cur.start) });
168+ this.raf = requestAnimationFrame(this.tick);
169+ };
170+
171+ destroy() {
172+ this.pause();
173+ }
174+}
175+
176+export function usePlayerTime(player: TimelinePlayer) {
177+ return useSyncExternalStore(player.subscribe, () => player.getSnapshot().time);
178+}
179+
180+export function usePlayerPlaying(player: TimelinePlayer) {
181+ return useSyncExternalStore(player.subscribe, () => player.getSnapshot().playing);
182+}
183+
184+export function usePlayerActiveTake(player: TimelinePlayer) {
185+ return useSyncExternalStore(player.subscribe, () => player.getSnapshot().activeTakeId);
186+}
src/lib/recorder.tsadded+102−0View file
@@ -0,0 +1,102 @@
1+// A recording session: one MediaRecorder per track (screen, camera), started and paused together.
2+// Chunks are uploaded to the server as they are produced, so a browser crash loses at most a second.
3+
4+import type { TrackKind } from '../../shared/types.ts';
5+import { api } from '../api.ts';
6+
7+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
8+
9+export function pickMimeType(): string {
10+ const candidates = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
11+ return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? '';
12+}
13+
14+// Uploads chunks strictly in order. If a chunk fails permanently, later chunks are not sent,
15+// since appending them would corrupt the file.
16+class ChunkUploader {
17+ private chain: Promise<void> = Promise.resolve();
18+ private videoId: string;
19+ private takeId: string;
20+ private track: TrackKind;
21+ error: Error | null = null;
22+
23+ constructor(videoId: string, takeId: string, track: TrackKind) {
24+ this.videoId = videoId;
25+ this.takeId = takeId;
26+ this.track = track;
27+ }
28+
29+ push(blob: Blob) {
30+ this.chain = this.chain.then(async () => {
31+ for (let attempt = 0; ; attempt++) {
32+ try {
33+ await api.uploadChunk(this.videoId, this.takeId, this.track, blob);
34+ return;
35+ } catch (e) {
36+ if (attempt >= 8) {
37+ this.error = e as Error;
38+ throw e;
39+ }
40+ await sleep(Math.min(5000, 300 * 2 ** attempt));
41+ }
42+ }
43+ });
44+ }
45+
46+ flush() {
47+ return this.chain;
48+ }
49+}
50+
51+interface Part {
52+ track: TrackKind;
53+ recorder: MediaRecorder;
54+ uploader: ChunkUploader;
55+ stopped: Promise<void>;
56+}
57+
58+export class RecordingSession {
59+ private parts: Part[] = [];
60+
61+ constructor(videoId: string, takeId: string, streams: Partial<Record<TrackKind, MediaStream>>, cameraHeight: number) {
62+ const mimeType = pickMimeType();
63+ for (const track of ['screen', 'camera'] as TrackKind[]) {
64+ const stream = streams[track];
65+ if (!stream) continue;
66+ const recorder = new MediaRecorder(stream, {
67+ mimeType,
68+ videoBitsPerSecond: track === 'screen' ? 6_000_000 : cameraHeight >= 1080 ? 5_000_000 : 3_000_000,
69+ audioBitsPerSecond: 128_000,
70+ });
71+ const uploader = new ChunkUploader(videoId, takeId, track);
72+ recorder.ondataavailable = (e) => {
73+ if (e.data.size > 0) uploader.push(e.data);
74+ };
75+ const stopped = new Promise<void>((resolve) => recorder.addEventListener('stop', () => resolve(), { once: true }));
76+ this.parts.push({ track, recorder, uploader, stopped });
77+ }
78+ }
79+
80+ start() {
81+ for (const p of this.parts) p.recorder.start(1000);
82+ }
83+
84+ pause() {
85+ for (const p of this.parts) if (p.recorder.state === 'recording') p.recorder.pause();
86+ }
87+
88+ resume() {
89+ for (const p of this.parts) if (p.recorder.state === 'paused') p.recorder.resume();
90+ }
91+
92+ // Stops recording and waits until every chunk has been uploaded.
93+ async stop() {
94+ for (const p of this.parts) if (p.recorder.state !== 'inactive') p.recorder.stop();
95+ await Promise.all(this.parts.map((p) => p.stopped));
96+ await Promise.all(this.parts.map((p) => p.uploader.flush()));
97+ }
98+
99+ get uploadError(): Error | null {
100+ return this.parts.find((p) => p.uploader.error)?.uploader.error ?? null;
101+ }
102+}
src/main.tsxadded+10−0View file
@@ -0,0 +1,10 @@
1+import { StrictMode } from 'react';
2+import { createRoot } from 'react-dom/client';
3+import { App } from './App.tsx';
4+import './styles.css';
5+
6+createRoot(document.getElementById('root')!).render(
7+ <StrictMode>
8+ <App />
9+ </StrictMode>,
10+);
src/pages/Library.tsxadded+211−0View file
@@ -0,0 +1,211 @@
1+import { useEffect, useState } from 'react';
2+import type { VideoSummary } from '../../shared/types.ts';
3+import { navigate } from '../App.tsx';
4+import { api } from '../api.ts';
5+import { Icon } from '../components/Icon.tsx';
6+import { ConfirmDialog, Modal, type ConfirmRequest } from '../components/Modal.tsx';
7+import { fmtDate, fmtDuration } from '../lib/format.ts';
8+
9+export function Library() {
10+ const [videos, setVideos] = useState<VideoSummary[] | null>(null);
11+ const [error, setError] = useState<string | null>(null);
12+ const [filter, setFilter] = useState('');
13+ const [dragId, setDragId] = useState<string | null>(null);
14+ const [confirm, setConfirm] = useState<ConfirmRequest | null>(null);
15+ const [creating, setCreating] = useState(false);
16+
17+ const load = () =>
18+ api
19+ .videos()
20+ .then(setVideos)
21+ .catch((e) => setError(e.message));
22+
23+ useEffect(() => {
24+ document.title = 'Takes';
25+ load();
26+ }, []);
27+
28+ const q = filter.trim().toLowerCase();
29+ const shown = (videos ?? []).filter((v) => !q || v.title.toLowerCase().includes(q) || v.description.toLowerCase().includes(q));
30+
31+ const onDragOver = (overId: string) => {
32+ if (!dragId || dragId === overId || !videos) return;
33+ const from = videos.findIndex((v) => v.id === dragId);
34+ const to = videos.findIndex((v) => v.id === overId);
35+ const next = videos.slice();
36+ const [item] = next.splice(from, 1);
37+ next.splice(to, 0, item);
38+ setVideos(next);
39+ };
40+
41+ const onDragEnd = () => {
42+ setDragId(null);
43+ if (videos) api.setOrder(videos.map((v) => v.id)).catch((e) => setError(e.message));
44+ };
45+
46+ const askDelete = (v: VideoSummary) =>
47+ setConfirm({
48+ title: 'Delete video?',
49+ message: (
50+ <>
51+ “{v.title}” and all of its takes will be moved to the <code>.trash</code> folder in your library directory.
52+ </>
53+ ),
54+ confirmLabel: 'Delete',
55+ danger: true,
56+ onConfirm: async () => {
57+ try {
58+ await api.deleteVideo(v.id);
59+ } catch (e) {
60+ setError((e as Error).message);
61+ }
62+ load();
63+ },
64+ });
65+
66+ return (
67+ <div className="page">
68+ <header className="topbar">
69+ <div className="brand">
70+ <span className="brand-dot" /> Takes
71+ </div>
72+ <input className="search" placeholder="Search videos" value={filter} onChange={(e) => setFilter(e.target.value)} />
73+ <div className="spacer" />
74+ <a className="btn ghost" href="#/settings">
75+ <Icon name="settings" /> Settings
76+ </a>
77+ <button className="btn primary" onClick={() => setCreating(true)}>
78+ <Icon name="plus" /> New video
79+ </button>
80+ </header>
81+
82+ {error && (
83+ <div className="banner error" onClick={() => setError(null)}>
84+ {error}
85+ </div>
86+ )}
87+
88+ <main className="library">
89+ {videos && videos.length === 0 && (
90+ <div className="empty">
91+ <h2>No videos yet</h2>
92+ <p className="muted">Create a video, then record yourself, your screen, or both.</p>
93+ <button className="btn primary big" onClick={() => setCreating(true)}>
94+ <Icon name="plus" /> New video
95+ </button>
96+ </div>
97+ )}
98+ <div className="grid">
99+ {shown.map((v) => (
100+ <div
101+ key={v.id}
102+ className={`card${dragId === v.id ? ' dragging' : ''}`}
103+ draggable={!q}
104+ onDragStart={(e) => {
105+ e.dataTransfer.effectAllowed = 'move';
106+ setDragId(v.id);
107+ }}
108+ onDragOver={(e) => {
109+ e.preventDefault();
110+ onDragOver(v.id);
111+ }}
112+ onDragEnd={onDragEnd}
113+ onClick={() => navigate(`video/${v.id}`)}
114+ >
115+ <div className="thumb">
116+ {v.thumbnailUrl ? <img src={v.thumbnailUrl} alt="" draggable={false} /> : <div className="thumb-empty"><Icon name="film" size={32} /></div>}
117+ {v.duration > 0 && <span className="duration">{fmtDuration(v.duration)}</span>}
118+ <span className="kinds">
119+ {v.hasCamera && <Icon name="camera" size={14} />}
120+ {v.hasScreen && <Icon name="screen" size={14} />}
121+ </span>
122+ </div>
123+ <div className="card-body">
124+ <div className="card-title">{v.title}</div>
125+ {v.description && <div className="card-desc">{v.description}</div>}
126+ <div className="card-meta">
127+ <span>{fmtDate(v.createdAt)}</span>
128+ <span>
129+ {v.takeCount} take{v.takeCount === 1 ? '' : 's'}
130+ </span>
131+ {v.exportState === 'current' && <span className="tag ok">exported</span>}
132+ {v.exportState === 'stale' && <span className="tag warn">export outdated</span>}
133+ </div>
134+ </div>
135+ <div className="card-actions" onClick={(e) => e.stopPropagation()}>
136+ <button className="icon-btn" title="Record a take" onClick={() => navigate(`video/${v.id}/record`)}>
137+ <Icon name="record" />
138+ </button>
139+ <button className="icon-btn danger" title="Delete" onClick={() => askDelete(v)}>
140+ <Icon name="trash" />
141+ </button>
142+ </div>
143+ {!q && (
144+ <div className="grip" title="Drag to reorder">
145+ <Icon name="grip" />
146+ </div>
147+ )}
148+ </div>
149+ ))}
150+ </div>
151+ </main>
152+
153+ {creating && <NewVideoDialog onClose={() => setCreating(false)} />}
154+ {confirm && <ConfirmDialog req={confirm} onClose={() => setConfirm(null)} />}
155+ </div>
156+ );
157+}
158+
159+function NewVideoDialog({ onClose }: { onClose: () => void }) {
160+ const [title, setTitle] = useState('');
161+ const [description, setDescription] = useState('');
162+ const [notes, setNotes] = useState('');
163+ const [busy, setBusy] = useState(false);
164+ const [error, setError] = useState<string | null>(null);
165+
166+ const create = async (record: boolean) => {
167+ setBusy(true);
168+ try {
169+ const v = await api.createVideo({ title, description, notes });
170+ navigate(record ? `video/${v.id}/record` : `video/${v.id}`);
171+ } catch (e) {
172+ setError((e as Error).message);
173+ setBusy(false);
174+ }
175+ };
176+
177+ return (
178+ <Modal
179+ title="New video"
180+ onClose={onClose}
181+ wide
182+ footer={
183+ <>
184+ <button className="btn" onClick={onClose}>
185+ Cancel
186+ </button>
187+ <button className="btn" disabled={busy} onClick={() => create(false)}>
188+ Create
189+ </button>
190+ <button className="btn primary" disabled={busy} onClick={() => create(true)}>
191+ <Icon name="record" /> Create and record
192+ </button>
193+ </>
194+ }
195+ >
196+ <label className="field">
197+ <span>Title</span>
198+ <input autoFocus value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. How the solver handles boundary conditions" />
199+ </label>
200+ <label className="field">
201+ <span>Description</span>
202+ <textarea rows={3} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What this video explains or demonstrates" />
203+ </label>
204+ <label className="field">
205+ <span>Notes (shown on the teleprompter while recording)</span>
206+ <textarea rows={8} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={'- Point one\n- Point two\n- Demo: ...'} />
207+ </label>
208+ {error && <div className="banner error">{error}</div>}
209+ </Modal>
210+ );
211+}
src/pages/Prompter.tsxadded+126−0View file
@@ -0,0 +1,126 @@
1+// Pop-out teleprompter: shows the notes large, with recording controls that drive the recorder
2+// window over a BroadcastChannel.
3+
4+import { useEffect, useRef, useState } from 'react';
5+import type { Video } from '../../shared/types.ts';
6+import { api } from '../api.ts';
7+import { Icon } from '../components/Icon.tsx';
8+import { channelName, type RecCommand, type RecMessage, type RecState } from '../lib/channel.ts';
9+import { fmtDuration, loadPref, savePref } from '../lib/format.ts';
10+
11+export function Prompter({ videoId }: { videoId: string }) {
12+ const [video, setVideo] = useState<Video | null>(null);
13+ const [state, setState] = useState<RecState | null>(null);
14+ const [view, setViewState] = useState(() => loadPref('takes.prompter', { fontSize: 28, autoScroll: false, speed: 30 }));
15+ const channel = useRef<BroadcastChannel | null>(null);
16+ const bodyRef = useRef<HTMLDivElement>(null);
17+
18+ const setView = (p: Partial<typeof view>) =>
19+ setViewState((v) => {
20+ const next = { ...v, ...p };
21+ savePref('takes.prompter', next);
22+ return next;
23+ });
24+
25+ const reload = () => api.video(videoId).then(setVideo).catch(() => {});
26+
27+ useEffect(() => {
28+ document.title = 'Teleprompter · Takes';
29+ reload();
30+ const ch = new BroadcastChannel(channelName(videoId));
31+ channel.current = ch;
32+ ch.onmessage = (ev: MessageEvent<RecMessage>) => {
33+ if (ev.data.type === 'state') setState(ev.data.state);
34+ else if (ev.data.type === 'closed') setState(null);
35+ };
36+ ch.postMessage({ type: 'command', cmd: 'hello' } satisfies RecMessage);
37+ return () => ch.close();
38+ }, [videoId]); // eslint-disable-line react-hooks/exhaustive-deps
39+
40+ const send = (cmd: RecCommand) => channel.current?.postMessage({ type: 'command', cmd } satisfies RecMessage);
41+
42+ // Auto-scroll while recording.
43+ const recording = state?.phase === 'recording';
44+ useEffect(() => {
45+ if (!recording || !view.autoScroll) return;
46+ let raf = 0;
47+ let last = performance.now();
48+ let acc = 0;
49+ const loop = (t: number) => {
50+ acc += ((t - last) / 1000) * view.speed;
51+ last = t;
52+ const whole = Math.floor(acc);
53+ if (whole > 0 && bodyRef.current) {
54+ bodyRef.current.scrollTop += whole;
55+ acc -= whole;
56+ }
57+ raf = requestAnimationFrame(loop);
58+ };
59+ raf = requestAnimationFrame(loop);
60+ return () => cancelAnimationFrame(raf);
61+ }, [recording, view.autoScroll, view.speed]);
62+
63+ const phase = state?.phase;
64+ return (
65+ <div className="prompter">
66+ <div className="prompter-bar">
67+ {!state && <span className="muted">Recorder window not open</span>}
68+ {phase === 'setup' && <span className="muted">Ready</span>}
69+ {phase === 'countdown' && <span className="rec-indicator">Starting in {state!.countdown}…</span>}
70+ {(phase === 'recording' || phase === 'paused') && (
71+ <span className={`rec-indicator ${phase}`}>
72+ <span className="rec-dot" /> {phase === 'paused' ? 'Paused' : 'REC'} <span className="timecode">{fmtDuration(state!.elapsed)}</span>
73+ </span>
74+ )}
75+ {phase === 'processing' && <span className="muted">Saving…</span>}
76+ <div className="spacer" />
77+ {phase === 'setup' && (
78+ <button className="btn record" disabled={!state?.canStart} onClick={() => send('start')}>
79+ <Icon name="record" /> Start
80+ </button>
81+ )}
82+ {phase === 'countdown' && (
83+ <button className="btn" onClick={() => send('stop')}>
84+ Cancel
85+ </button>
86+ )}
87+ {phase === 'recording' && (
88+ <button className="btn" onClick={() => send('pause')}>
89+ <Icon name="pause" /> Pause
90+ </button>
91+ )}
92+ {phase === 'paused' && (
93+ <button className="btn" onClick={() => send('resume')}>
94+ <Icon name="record" /> Resume
95+ </button>
96+ )}
97+ {(phase === 'recording' || phase === 'paused') && (
98+ <button className="btn primary" onClick={() => send('stop')}>
99+ <Icon name="stop" /> Stop
100+ </button>
101+ )}
102+ </div>
103+ {state?.message && <div className="banner">{state.message}</div>}
104+ <div className="prompter-tools">
105+ <button className="btn small" onClick={() => setView({ fontSize: Math.max(14, view.fontSize - 2) })}>
106+ A−
107+ </button>
108+ <button className="btn small" onClick={() => setView({ fontSize: Math.min(72, view.fontSize + 2) })}>
109+ A+
110+ </button>
111+ <label className="check small">
112+ <input type="checkbox" checked={view.autoScroll} onChange={(e) => setView({ autoScroll: e.target.checked })} /> Auto-scroll while recording
113+ </label>
114+ {view.autoScroll && <input type="range" min={5} max={120} value={view.speed} onChange={(e) => setView({ speed: parseInt(e.target.value) })} title="Scroll speed" />}
115+ <div className="spacer" />
116+ <button className="btn small ghost" onClick={reload} title="Reload notes">
117+ Reload notes
118+ </button>
119+ </div>
120+ <div className="prompter-body" ref={bodyRef} style={{ fontSize: view.fontSize }}>
121+ {video ? video.notes || <span className="muted">No notes for this video.</span> : 'Loading…'}
122+ <div style={{ height: '60vh' }} />
123+ </div>
124+ </div>
125+ );
126+}
src/pages/Recorder.tsxadded+510−0View file
@@ -0,0 +1,510 @@
1+import { useEffect, useRef, useState } from 'react';
2+import type { PublicConfig, TrackKind, Video } from '../../shared/types.ts';
3+import { navigate } from '../App.tsx';
4+import { api } from '../api.ts';
5+import { Icon } from '../components/Icon.tsx';
6+import { MicMeter } from '../components/MicMeter.tsx';
7+import { channelName, type RecMessage, type RecPhase, type RecState } from '../lib/channel.ts';
8+import { fmtDuration, loadPref, savePref } from '../lib/format.ts';
9+import { RecordingSession } from '../lib/recorder.ts';
10+
11+interface Prefs {
12+ useCamera: boolean;
13+ cameraId: string;
14+ cameraHeight: 720 | 1080;
15+ useMic: boolean;
16+ micId: string;
17+ audioProcessing: boolean;
18+ useScreen: boolean;
19+ countdown: number;
20+}
21+
22+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
23+
24+function stopStream(s: MediaStream | null) {
25+ s?.getTracks().forEach((t) => t.stop());
26+}
27+
28+function VideoPreview({ stream, label }: { stream: MediaStream | null; label: string }) {
29+ const ref = useRef<HTMLVideoElement>(null);
30+ useEffect(() => {
31+ if (ref.current) ref.current.srcObject = stream;
32+ }, [stream]);
33+ return (
34+ <div className="preview">
35+ {stream ? <video ref={ref} autoPlay muted playsInline /> : <div className="preview-empty">{label}</div>}
36+ </div>
37+ );
38+}
39+
40+export function Recorder({ videoId }: { videoId: string }) {
41+ const [video, setVideo] = useState<Video | null>(null);
42+ const [prefs, setPrefsState] = useState<Prefs | null>(null);
43+ const [cams, setCams] = useState<MediaDeviceInfo[]>([]);
44+ const [mics, setMics] = useState<MediaDeviceInfo[]>([]);
45+ const [camStream, setCamStream] = useState<MediaStream | null>(null);
46+ const [micStream, setMicStream] = useState<MediaStream | null>(null);
47+ const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
48+ const [phase, setPhase] = useState<RecPhase>('setup');
49+ const [countdown, setCountdown] = useState(0);
50+ const [elapsed, setElapsed] = useState(0);
51+ const [error, setError] = useState<string | null>(null);
52+ const [message, setMessage] = useState<string | undefined>();
53+
54+ const session = useRef<RecordingSession | null>(null);
55+ const takeId = useRef<string | null>(null);
56+ const aborted = useRef(false);
57+ const clock = useRef({ start: 0, pausedAt: 0, pausedTotal: 0 });
58+ const phaseRef = useRef(phase);
59+ phaseRef.current = phase;
60+
61+ // Load video, and prefs (localStorage, falling back to app defaults).
62+ useEffect(() => {
63+ api.video(videoId).then((v) => {
64+ setVideo(v);
65+ document.title = `Recording: ${v.title} · Takes`;
66+ }).catch((e) => setError(e.message));
67+ api
68+ .config()
69+ .catch(() => null)
70+ .then((cfg: PublicConfig | null) => {
71+ setPrefsState(
72+ loadPref<Prefs>('takes.recorder', {
73+ useCamera: true,
74+ cameraId: '',
75+ cameraHeight: cfg?.defaultCameraHeight ?? 720,
76+ useMic: true,
77+ micId: '',
78+ audioProcessing: true,
79+ useScreen: true,
80+ countdown: cfg?.defaultCountdown ?? 3,
81+ }),
82+ );
83+ });
84+ }, [videoId]);
85+
86+ const setPrefs = (p: Partial<Prefs>) =>
87+ setPrefsState((cur) => {
88+ const next = { ...cur!, ...p };
89+ savePref('takes.recorder', next);
90+ return next;
91+ });
92+
93+ const refreshDevices = async () => {
94+ const devs = await navigator.mediaDevices.enumerateDevices();
95+ setCams(devs.filter((d) => d.kind === 'videoinput' && d.deviceId));
96+ setMics(devs.filter((d) => d.kind === 'audioinput' && d.deviceId));
97+ };
98+ useEffect(() => {
99+ navigator.mediaDevices.addEventListener('devicechange', refreshDevices);
100+ return () => navigator.mediaDevices.removeEventListener('devicechange', refreshDevices);
101+ }, []);
102+
103+ // Camera stream follows the camera settings.
104+ useEffect(() => {
105+ if (!prefs?.useCamera) return;
106+ let stream: MediaStream | null = null;
107+ let cancelled = false;
108+ const h = prefs.cameraHeight;
109+ navigator.mediaDevices
110+ .getUserMedia({
111+ video: { deviceId: prefs.cameraId ? { exact: prefs.cameraId } : undefined, width: { ideal: (h * 16) / 9 }, height: { ideal: h }, frameRate: { ideal: 30 } },
112+ })
113+ .then((s) => {
114+ if (cancelled) return stopStream(s);
115+ stream = s;
116+ setCamStream(s);
117+ refreshDevices();
118+ })
119+ .catch((e) => setError(`Camera: ${e.message}`));
120+ return () => {
121+ cancelled = true;
122+ stopStream(stream);
123+ setCamStream(null);
124+ };
125+ }, [prefs?.useCamera, prefs?.cameraId, prefs?.cameraHeight]);
126+
127+ // Microphone stream follows the mic settings.
128+ useEffect(() => {
129+ if (!prefs?.useMic) return;
130+ let stream: MediaStream | null = null;
131+ let cancelled = false;
132+ const proc = prefs.audioProcessing;
133+ navigator.mediaDevices
134+ .getUserMedia({
135+ audio: { deviceId: prefs.micId ? { exact: prefs.micId } : undefined, echoCancellation: proc, noiseSuppression: proc, autoGainControl: proc },
136+ })
137+ .then((s) => {
138+ if (cancelled) return stopStream(s);
139+ stream = s;
140+ setMicStream(s);
141+ refreshDevices();
142+ })
143+ .catch((e) => setError(`Microphone: ${e.message}`));
144+ return () => {
145+ cancelled = true;
146+ stopStream(stream);
147+ setMicStream(null);
148+ };
149+ }, [prefs?.useMic, prefs?.micId, prefs?.audioProcessing]);
150+
151+ // Screen stream is stopped when leaving the page.
152+ const screenRef = useRef<MediaStream | null>(null);
153+ screenRef.current = screenStream;
154+ useEffect(() => () => stopStream(screenRef.current), []);
155+
156+ const chooseScreen = async (): Promise<MediaStream | null> => {
157+ try {
158+ const s = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 30 } }, audio: false });
159+ stopStream(screenRef.current);
160+ s.getVideoTracks()[0].addEventListener('ended', () => {
161+ setScreenStream((cur) => (cur === s ? null : cur));
162+ if (phaseRef.current === 'recording' || phaseRef.current === 'paused') stopRef.current();
163+ });
164+ setScreenStream(s);
165+ return s;
166+ } catch (e) {
167+ if ((e as Error).name !== 'NotAllowedError') setError(`Screen: ${(e as Error).message}`);
168+ return null;
169+ }
170+ };
171+
172+ const useCamera = !!prefs?.useCamera;
173+ const useScreen = !!prefs?.useScreen;
174+ const useMic = !!prefs?.useMic;
175+ const devicesReady = (useCamera || useScreen) && (!useCamera || !!camStream) && (!useMic || !!micStream);
176+ const canStart = phase === 'setup' && devicesReady;
177+
178+ // ---- recording control ----
179+ const begin = async (screen: MediaStream | null) => {
180+ if (!prefs || !devicesReady || phaseRef.current !== 'setup') return;
181+ if (useScreen && !screen) {
182+ setMessage('Choose the screen or window to record in the main window first.');
183+ return;
184+ }
185+ setError(null);
186+ setMessage(undefined);
187+ aborted.current = false;
188+ setPhase('countdown');
189+ try {
190+ const take = await api.createTake(videoId, {
191+ tracks: { screen: useScreen, camera: useCamera },
192+ audio: useMic ? (useCamera ? 'camera' : 'screen') : null,
193+ });
194+ takeId.current = take.id;
195+ for (let n = prefs.countdown; n > 0; n--) {
196+ setCountdown(n);
197+ await sleep(1000);
198+ if (aborted.current) {
199+ await api.deleteTake(videoId, take.id).catch(() => {});
200+ setPhase('setup');
201+ return;
202+ }
203+ }
204+ setCountdown(0);
205+ const micTracks = useMic && micStream ? micStream.getAudioTracks() : [];
206+ const streams: Partial<Record<TrackKind, MediaStream>> = {};
207+ if (useCamera && camStream) streams.camera = new MediaStream([...camStream.getVideoTracks(), ...micTracks]);
208+ if (useScreen && screen) streams.screen = new MediaStream([...screen.getVideoTracks(), ...(useCamera ? [] : micTracks)]);
209+ session.current = new RecordingSession(videoId, take.id, streams, prefs.cameraHeight);
210+ session.current.start();
211+ clock.current = { start: performance.now(), pausedAt: 0, pausedTotal: 0 };
212+ setElapsed(0);
213+ setPhase('recording');
214+ } catch (e) {
215+ setError((e as Error).message);
216+ setPhase('setup');
217+ }
218+ };
219+
220+ const pause = () => {
221+ if (phaseRef.current !== 'recording') return;
222+ session.current?.pause();
223+ clock.current.pausedAt = performance.now();
224+ setPhase('paused');
225+ };
226+
227+ const resume = () => {
228+ if (phaseRef.current !== 'paused') return;
229+ session.current?.resume();
230+ clock.current.pausedTotal += performance.now() - clock.current.pausedAt;
231+ setPhase('recording');
232+ };
233+
234+ const stop = async () => {
235+ const p = phaseRef.current;
236+ if (p === 'countdown') {
237+ aborted.current = true;
238+ return;
239+ }
240+ if (p !== 'recording' && p !== 'paused') return;
241+ setPhase('processing');
242+ const s = session.current;
243+ session.current = null;
244+ try {
245+ await s?.stop();
246+ await api.finalizeTake(videoId, takeId.current!);
247+ navigate(`video/${videoId}`);
248+ } catch (e) {
249+ setError(`${(e as Error).message}. The take was kept; you can try "Recover" on the video page.`);
250+ setPhase('setup');
251+ }
252+ };
253+ const stopRef = useRef(stop);
254+ stopRef.current = stop;
255+
256+ const discard = async () => {
257+ const p = phaseRef.current;
258+ if (p !== 'recording' && p !== 'paused') return;
259+ setPhase('processing');
260+ const s = session.current;
261+ session.current = null;
262+ await s?.stop().catch(() => {});
263+ await api.deleteTake(videoId, takeId.current!).catch(() => {});
264+ setPhase('setup');
265+ };
266+
267+ // Elapsed time while recording.
268+ useEffect(() => {
269+ if (phase !== 'recording') return;
270+ const iv = setInterval(() => {
271+ const c = clock.current;
272+ setElapsed((performance.now() - c.start - c.pausedTotal) / 1000);
273+ }, 200);
274+ return () => clearInterval(iv);
275+ }, [phase]);
276+
277+ // Warn before closing the window mid-recording.
278+ useEffect(() => {
279+ if (phase === 'setup') return;
280+ const on = (e: BeforeUnloadEvent) => e.preventDefault();
281+ window.addEventListener('beforeunload', on);
282+ return () => window.removeEventListener('beforeunload', on);
283+ }, [phase]);
284+
285+ // ---- teleprompter channel ----
286+ const channel = useRef<BroadcastChannel | null>(null);
287+ const handlers = useRef({ begin: () => {}, pause, resume, stop });
288+ handlers.current = { begin: () => begin(screenStream), pause, resume, stop };
289+ const state: RecState = { phase, elapsed, countdown, canStart, message };
290+ const stateRef = useRef(state);
291+ stateRef.current = state;
292+
293+ useEffect(() => {
294+ const ch = new BroadcastChannel(channelName(videoId));
295+ channel.current = ch;
296+ ch.onmessage = (ev: MessageEvent<RecMessage>) => {
297+ const m = ev.data;
298+ if (m.type !== 'command') return;
299+ if (m.cmd === 'hello') ch.postMessage({ type: 'state', state: stateRef.current } satisfies RecMessage);
300+ else if (m.cmd === 'start') handlers.current.begin();
301+ else handlers.current[m.cmd]();
302+ };
303+ return () => {
304+ ch.postMessage({ type: 'closed' } satisfies RecMessage);
305+ ch.close();
306+ };
307+ }, [videoId]);
308+
309+ useEffect(() => {
310+ channel.current?.postMessage({ type: 'state', state } satisfies RecMessage);
311+ }, [phase, Math.floor(elapsed * 4), countdown, canStart, message]); // eslint-disable-line react-hooks/exhaustive-deps
312+
313+ const openPrompter = () => {
314+ window.open(`#/prompter/${videoId}`, `takes-prompter-${videoId}`, 'popup,width=620,height=800');
315+ };
316+
317+ // Keyboard: Space pauses/resumes while recording.
318+ useEffect(() => {
319+ const on = (e: KeyboardEvent) => {
320+ if ((e.target as HTMLElement).closest('input, textarea, select')) return;
321+ if (e.key === ' ' && (phaseRef.current === 'recording' || phaseRef.current === 'paused')) {
322+ e.preventDefault();
323+ (phaseRef.current === 'recording' ? handlers.current.pause : handlers.current.resume)();
324+ }
325+ };
326+ window.addEventListener('keydown', on);
327+ return () => window.removeEventListener('keydown', on);
328+ }, []);
329+
330+ if (!prefs || !video) return <div className="page loading">{error ?? 'Loading…'}</div>;
331+
332+ const recording = phase === 'recording' || phase === 'paused';
333+ const locked = phase !== 'setup';
334+
335+ return (
336+ <div className="page recorder">
337+ <header className="topbar">
338+ <button className="btn ghost" disabled={locked} onClick={() => navigate(`video/${videoId}`)}>
339+ <Icon name="back" /> Back
340+ </button>
341+ <div className="topbar-title">
342+ <span className="muted">Recording</span> {video.title}
343+ </div>
344+ <div className="spacer" />
345+ <button className="btn" onClick={openPrompter} title="Open notes and recording controls in a separate window">
346+ <Icon name="popout" /> Teleprompter window
347+ </button>
348+ </header>
349+
350+ {error && (
351+ <div className="banner error" onClick={() => setError(null)}>
352+ {error}
353+ </div>
354+ )}
355+ {message && <div className="banner">{message}</div>}
356+
357+ <div className="recorder-grid">
358+ <section className="panel sources">
359+ <div className="source">
360+ <label className="check">
361+ <input type="checkbox" checked={useCamera} disabled={locked} onChange={(e) => setPrefs({ useCamera: e.target.checked })} />
362+ <Icon name="camera" /> Camera
363+ </label>
364+ {useCamera && (
365+ <div className="source-opts">
366+ <select value={prefs.cameraId} disabled={locked} onChange={(e) => setPrefs({ cameraId: e.target.value })}>
367+ <option value="">Default camera</option>
368+ {cams.map((d) => (
369+ <option key={d.deviceId} value={d.deviceId}>
370+ {d.label || 'Camera'}
371+ </option>
372+ ))}
373+ </select>
374+ <select value={prefs.cameraHeight} disabled={locked} onChange={(e) => setPrefs({ cameraHeight: parseInt(e.target.value) as 720 | 1080 })}>
375+ <option value={720}>720p</option>
376+ <option value={1080}>1080p</option>
377+ </select>
378+ </div>
379+ )}
380+ </div>
381+
382+ <div className="source">
383+ <label className="check">
384+ <input type="checkbox" checked={useMic} disabled={locked} onChange={(e) => setPrefs({ useMic: e.target.checked })} />
385+ <Icon name="mic" /> Microphone
386+ </label>
387+ {useMic && (
388+ <div className="source-opts">
389+ <select value={prefs.micId} disabled={locked} onChange={(e) => setPrefs({ micId: e.target.value })}>
390+ <option value="">Default microphone</option>
391+ {mics.map((d) => (
392+ <option key={d.deviceId} value={d.deviceId}>
393+ {d.label || 'Microphone'}
394+ </option>
395+ ))}
396+ </select>
397+ <label className="check small" title="Echo cancellation, noise suppression, and automatic gain">
398+ <input type="checkbox" checked={prefs.audioProcessing} disabled={locked} onChange={(e) => setPrefs({ audioProcessing: e.target.checked })} />
399+ Noise suppression
400+ </label>
401+ </div>
402+ )}
403+ {useMic && <MicMeter stream={micStream} />}
404+ </div>
405+
406+ <div className="source">
407+ <label className="check">
408+ <input type="checkbox" checked={useScreen} disabled={locked} onChange={(e) => setPrefs({ useScreen: e.target.checked })} />
409+ <Icon name="screen" /> Screen
410+ </label>
411+ {useScreen && (
412+ <div className="source-opts">
413+ <button className="btn small" disabled={recording} onClick={chooseScreen}>
414+ {screenStream ? 'Change screen or window…' : 'Choose screen or window…'}
415+ </button>
416+ {screenStream && !locked && (
417+ <button
418+ className="btn small ghost"
419+ onClick={() => {
420+ stopStream(screenStream);
421+ setScreenStream(null);
422+ }}
423+ >
424+ Stop sharing
425+ </button>
426+ )}
427+ </div>
428+ )}
429+ </div>
430+
431+ <div className="source">
432+ <label className="row">
433+ <span>Countdown</span>
434+ <select value={prefs.countdown} disabled={locked} onChange={(e) => setPrefs({ countdown: parseInt(e.target.value) })}>
435+ {[0, 3, 5, 10].map((n) => (
436+ <option key={n} value={n}>
437+ {n === 0 ? 'None' : `${n} s`}
438+ </option>
439+ ))}
440+ </select>
441+ </label>
442+ </div>
443+
444+ <div className="previews">
445+ {useCamera && <VideoPreview stream={camStream} label="Starting camera…" />}
446+ {useScreen && <VideoPreview stream={screenStream} label="No screen chosen yet" />}
447+ </div>
448+
449+ <div className="rec-controls">
450+ {phase === 'setup' && (
451+ <button
452+ className="btn record big"
453+ disabled={!canStart}
454+ onClick={async () => {
455+ let s = screenStream;
456+ if (useScreen && !s) s = await chooseScreen();
457+ if (!useScreen || s) begin(s);
458+ }}
459+ >
460+ <Icon name="record" /> Start recording
461+ </button>
462+ )}
463+ {phase === 'countdown' && (
464+ <button className="btn big" onClick={stop}>
465+ Cancel
466+ </button>
467+ )}
468+ {recording && (
469+ <>
470+ <div className={`rec-indicator ${phase}`}>
471+ <span className="rec-dot" /> {phase === 'paused' ? 'Paused' : 'Recording'} <span className="timecode">{fmtDuration(elapsed)}</span>
472+ </div>
473+ {phase === 'recording' ? (
474+ <button className="btn big" onClick={pause}>
475+ <Icon name="pause" /> Pause
476+ </button>
477+ ) : (
478+ <button className="btn big" onClick={resume}>
479+ <Icon name="record" /> Resume
480+ </button>
481+ )}
482+ <button className="btn primary big" onClick={stop}>
483+ <Icon name="stop" /> Stop and save
484+ </button>
485+ <button className="btn ghost danger" onClick={discard}>
486+ Discard
487+ </button>
488+ </>
489+ )}
490+ {phase === 'processing' && <div className="muted">Saving take…</div>}
491+ </div>
492+ {phase === 'setup' && !devicesReady && (useCamera || useScreen) && <p className="muted small">Waiting for devices…</p>}
493+ {!useCamera && !useScreen && <p className="warn-text small">Enable the camera, the screen, or both.</p>}
494+ <p className="muted small">Space pauses and resumes while this window is focused. When recording your whole screen, move the teleprompter window to another monitor or record a single window.</p>
495+ </section>
496+
497+ <section className="panel notes-panel">
498+ <h4>Notes</h4>
499+ <div className="notes-view">{video.notes || <span className="muted">No notes. Add them on the video page.</span>}</div>
500+ </section>
501+ </div>
502+
503+ {phase === 'countdown' && countdown > 0 && (
504+ <div className="countdown-overlay">
505+ <div className="countdown-num">{countdown}</div>
506+ </div>
507+ )}
508+ </div>
509+ );
510+}
src/pages/Settings.tsxadded+114−0View file
@@ -0,0 +1,114 @@
1+import { useEffect, useState } from 'react';
2+import type { PublicConfig } from '../../shared/types.ts';
3+import { api } from '../api.ts';
4+import { Icon } from '../components/Icon.tsx';
5+
6+export function Settings() {
7+ const [cfg, setCfg] = useState<PublicConfig | null>(null);
8+ const [key, setKey] = useState('');
9+ const [status, setStatus] = useState<string | null>(null);
10+ const [error, setError] = useState<string | null>(null);
11+
12+ const load = () => api.config().then(setCfg).catch((e) => setError(e.message));
13+ useEffect(() => {
14+ document.title = 'Settings · Takes';
15+ load();
16+ }, []);
17+
18+ const save = async (patch: Record<string, unknown>) => {
19+ try {
20+ await api.saveConfig(patch);
21+ await load();
22+ setStatus('Saved');
23+ setTimeout(() => setStatus(null), 1500);
24+ } catch (e) {
25+ setError((e as Error).message);
26+ }
27+ };
28+
29+ if (!cfg) return <div className="page loading">{error ?? 'Loading…'}</div>;
30+
31+ return (
32+ <div className="page">
33+ <header className="topbar">
34+ <a className="btn ghost" href="#/">
35+ <Icon name="back" /> Videos
36+ </a>
37+ <div className="topbar-title">Settings</div>
38+ <div className="spacer" />
39+ {status && <span className="save-state saved">{status}</span>}
40+ </header>
41+ {error && <div className="banner error">{error}</div>}
42+ <main className="settings">
43+ <section className="panel">
44+ <h4>Library</h4>
45+ <p>
46+ Videos are stored in <code>{cfg.libraryDir}</code>. Start the app with <code>--dir</code> to use another directory.
47+ </p>
48+ </section>
49+
50+ <section className="panel">
51+ <h4>Transcription (Deepgram)</h4>
52+ <p className="muted small">
53+ The key is stored in <code>config.json</code> in the library directory, readable only by you. Audio is sent to Deepgram only when a take is transcribed.
54+ </p>
55+ <div className="row">
56+ <input type="password" className="grow" placeholder={cfg.hasDeepgramKey ? 'A key is saved (enter a new one to replace it)' : 'Deepgram API key'} value={key} onChange={(e) => setKey(e.target.value)} />
57+ <button
58+ className="btn primary"
59+ disabled={!key.trim()}
60+ onClick={async () => {
61+ await save({ deepgramApiKey: key });
62+ setKey('');
63+ }}
64+ >
65+ Save key
66+ </button>
67+ {cfg.hasDeepgramKey && (
68+ <button className="btn ghost danger" onClick={() => save({ deepgramApiKey: '' })}>
69+ Remove
70+ </button>
71+ )}
72+ </div>
73+ <label className="field">
74+ <span>Model</span>
75+ <input defaultValue={cfg.deepgramModel} onBlur={(e) => e.target.value !== cfg.deepgramModel && save({ deepgramModel: e.target.value })} />
76+ </label>
77+ <label className="check">
78+ <input type="checkbox" checked={cfg.autoTranscribe} onChange={(e) => save({ autoTranscribe: e.target.checked })} />
79+ Transcribe new takes automatically
80+ </label>
81+ </section>
82+
83+ <section className="panel">
84+ <h4>Defaults</h4>
85+ <label className="row">
86+ <span>Countdown before recording</span>
87+ <select value={cfg.defaultCountdown} onChange={(e) => save({ defaultCountdown: parseInt(e.target.value) })}>
88+ {[0, 3, 5, 10].map((n) => (
89+ <option key={n} value={n}>
90+ {n === 0 ? 'None' : `${n} s`}
91+ </option>
92+ ))}
93+ </select>
94+ </label>
95+ <label className="row">
96+ <span>Camera resolution</span>
97+ <select value={cfg.defaultCameraHeight} onChange={(e) => save({ defaultCameraHeight: parseInt(e.target.value) })}>
98+ <option value={720}>720p</option>
99+ <option value={1080}>1080p</option>
100+ </select>
101+ </label>
102+ <label className="row">
103+ <span>Export resolution for new videos</span>
104+ <select value={cfg.defaultOutputHeight} onChange={(e) => save({ defaultOutputHeight: parseInt(e.target.value) })}>
105+ <option value={720}>720p</option>
106+ <option value={1080}>1080p</option>
107+ </select>
108+ </label>
109+ <p className="muted small">The recorder remembers your last choices; these defaults apply until you change them there.</p>
110+ </section>
111+ </main>
112+ </div>
113+ );
114+}
src/pages/VideoPage.tsxadded+777−0View file
@@ -0,0 +1,777 @@
1+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2+import type { Background, ExportJob, Layout, OutputSettings, PublicConfig, Segment, Take, Transcript, Video } from '../../shared/types.ts';
3+import { backgroundOf, exportSignature } from '../../shared/types.ts';
4+import { computeGeometry } from '../../shared/layout.ts';
5+import { cutRange, locate, moveSegment, randomId, segDuration, segmentStarts, splitAt, timelineDuration } from '../../shared/timeline.ts';
6+import { navigate } from '../App.tsx';
7+import { api } from '../api.ts';
8+import { Icon } from '../components/Icon.tsx';
9+import { ConfirmDialog, type ConfirmRequest } from '../components/Modal.tsx';
10+import { Stage, captureFrame } from '../components/Stage.tsx';
11+import { Timeline, takeHue, type PeaksData } from '../components/Timeline.tsx';
12+import { TranscriptPanel } from '../components/TranscriptPanel.tsx';
13+import { decodePeaks, fmtBytes, fmtDateTime, fmtDuration, fmtTimecode, loadPref, savePref } from '../lib/format.ts';
14+import { TimelinePlayer, usePlayerPlaying, usePlayerTime } from '../lib/player.ts';
15+
16+type Tab = 'details' | 'transcript' | 'export';
17+type Marks = { in: number | null; out: number | null };
18+const NO_MARKS: Marks = { in: null, out: null };
19+const FRAME = 1 / 30;
20+const isLeft = (c: string) => c === 'tl' || c === 'bl';
21+
22+// Keep locally edited fields; take server-managed fields (takes, thumbnail, export) from `fresh`.
23+function mergeServer(v: Video, fresh: Video): Video {
24+ const takes = JSON.stringify(v.takes) === JSON.stringify(fresh.takes) ? v.takes : fresh.takes;
25+ return { ...v, takes, thumbnail: fresh.thumbnail, export: fresh.export };
26+}
27+
28+export function VideoPage({ videoId }: { videoId: string }) {
29+ const [video, setVideo] = useState<Video | null>(null);
30+ const [error, setError] = useState<string | null>(null);
31+ const [notice, setNotice] = useState<string | null>(null);
32+ const [config, setConfig] = useState<PublicConfig | null>(null);
33+ const [tab, setTabState] = useState<Tab>(() => loadPref('takes.editor', { tab: 'details' as Tab }).tab);
34+ const [selectedId, setSelectedId] = useState<string | null>(null);
35+ const [marks, setMarks] = useState<Marks>(NO_MARKS);
36+ const [history, setHistory] = useState<Segment[][]>([]);
37+ const [future, setFuture] = useState<Segment[][]>([]);
38+ const [peaks, setPeaks] = useState<Record<string, PeaksData>>({});
39+ const [transcripts, setTranscripts] = useState<Record<string, Transcript>>({});
40+ const [job, setJob] = useState<ExportJob | null>(null);
41+ const [confirm, setConfirm] = useState<ConfirmRequest | null>(null);
42+ const [zoom, setZoom] = useState(1);
43+ const [saveState, setSaveState] = useState<'saved' | 'saving' | 'error'>('saved');
44+ const player = useMemo(() => new TimelinePlayer(), []);
45+
46+ const setTab = (t: Tab) => {
47+ setTabState(t);
48+ savePref('takes.editor', { tab: t });
49+ };
50+
51+ useEffect(() => () => player.destroy(), [player]);
52+
53+ useEffect(() => {
54+ api
55+ .video(videoId)
56+ .then((v) => {
57+ setVideo(v);
58+ document.title = `${v.title} · Takes`;
59+ })
60+ .catch((e) => setError(e.message));
61+ api.config().then(setConfig).catch(() => {});
62+ api.exportStatus(videoId).then(setJob).catch(() => {});
63+ }, [videoId]);
64+
65+ useEffect(() => {
66+ if (video) player.setData(video.timeline, video.takes);
67+ }, [video?.timeline, video?.takes, player]);
68+
69+ // ---- saving (debounced PATCH of edited fields) ----
70+ const pending = useRef<Partial<Video>>({});
71+ const timer = useRef(0);
72+ const flush = useCallback(async () => {
73+ clearTimeout(timer.current);
74+ const p = pending.current;
75+ pending.current = {};
76+ if (Object.keys(p).length === 0) return;
77+ setSaveState('saving');
78+ try {
79+ await api.patchVideo(videoId, p);
80+ setSaveState('saved');
81+ } catch (e) {
82+ setSaveState('error');
83+ setError(`Could not save: ${(e as Error).message}`);
84+ }
85+ }, [videoId]);
86+
87+ const patch = useCallback(
88+ (p: Partial<Video>, delay = 500) => {
89+ setVideo((v) => (v ? { ...v, ...p } : v));
90+ pending.current = { ...pending.current, ...p };
91+ clearTimeout(timer.current);
92+ timer.current = window.setTimeout(flush, delay);
93+ },
94+ [flush],
95+ );
96+
97+ useEffect(() => {
98+ const onUnload = () => {
99+ if (Object.keys(pending.current).length === 0) return;
100+ fetch(`/api/videos/${videoId}`, {
101+ method: 'PATCH',
102+ keepalive: true,
103+ headers: { 'Content-Type': 'application/json', 'X-Takes': '1' },
104+ body: JSON.stringify(pending.current),
105+ });
106+ };
107+ window.addEventListener('pagehide', onUnload);
108+ return () => {
109+ window.removeEventListener('pagehide', onUnload);
110+ flush();
111+ };
112+ }, [videoId, flush]);
113+
114+ // ---- peaks and transcripts ----
115+ const requested = useRef(new Map<string, string>());
116+ useEffect(() => {
117+ if (!video) return;
118+ for (const t of video.takes) {
119+ if (t.status !== 'ready' || !t.audio) continue;
120+ const pk = `peaks/${t.id}`;
121+ if (!requested.current.has(pk)) {
122+ requested.current.set(pk, '1');
123+ api.peaks(videoId, t.id).then((p) => p && setPeaks((m) => ({ ...m, [t.id]: { rate: p.rate, values: decodePeaks(p.data) } })));
124+ }
125+ const tk = `transcript/${t.id}`;
126+ if (t.transcript?.status === 'done' && requested.current.get(tk) !== t.transcript.updatedAt) {
127+ requested.current.set(tk, t.transcript.updatedAt);
128+ api.transcript(videoId, t.id).then((tr) => tr && setTranscripts((m) => ({ ...m, [t.id]: tr })));
129+ }
130+ }
131+ }, [video?.takes, videoId]);
132+
133+ // ---- polling while the server is working ----
134+ const busy =
135+ !!video &&
136+ (video.takes.some((t) => t.status === 'processing' || t.transcript?.status === 'pending' || t.matte?.status === 'queued' || t.matte?.status === 'running') ||
137+ job?.status === 'running');
138+
139+ // With a background chosen, make sure every camera take gets processed (including takes recorded later).
140+ const bgMode = video ? backgroundOf(video.layout).mode : 'none';
141+ const unprocessed = video?.takes.filter((t) => t.status === 'ready' && t.tracks.camera && !t.matte).map((t) => t.id).join(',') ?? '';
142+ useEffect(() => {
143+ if (bgMode === 'none' || !unprocessed) return;
144+ api
145+ .requestMattes(videoId)
146+ .then((fresh) => setVideo((v) => (v ? mergeServer(v, fresh) : v)))
147+ .catch((e) => setError(e.message));
148+ }, [bgMode, unprocessed, videoId]);
149+ useEffect(() => {
150+ if (!busy) return;
151+ const iv = setInterval(async () => {
152+ try {
153+ const j = await api.exportStatus(videoId);
154+ const fresh = await api.video(videoId);
155+ setJob(j);
156+ setVideo((v) => (v ? mergeServer(v, fresh) : v));
157+ } catch {
158+ // try again on the next tick
159+ }
160+ }, 1000);
161+ return () => clearInterval(iv);
162+ }, [busy, videoId]);
163+
164+ const flashNotice = (msg: string) => {
165+ setNotice(msg);
166+ setTimeout(() => setNotice((n) => (n === msg ? null : n)), 2500);
167+ };
168+
169+ // ---- timeline editing ----
170+ const tl = video?.timeline ?? [];
171+ const now = () => player.getSnapshot().time;
172+
173+ const commit = (next: Segment[]) => {
174+ if (!video || next === video.timeline) return;
175+ setHistory((h) => [...h.slice(-199), video.timeline]);
176+ setFuture([]);
177+ patch({ timeline: next }, 300);
178+ };
179+ const undo = () => {
180+ if (!video || history.length === 0) return;
181+ setFuture((f) => [video.timeline, ...f]);
182+ patch({ timeline: history[history.length - 1] }, 300);
183+ setHistory((h) => h.slice(0, -1));
184+ };
185+ const redo = () => {
186+ if (!video || future.length === 0) return;
187+ setHistory((h) => [...h, video.timeline]);
188+ patch({ timeline: future[0] }, 300);
189+ setFuture((f) => f.slice(1));
190+ };
191+
192+ const hasRange = marks.in !== null && marks.out !== null && marks.out > marks.in;
193+ const split = () => commit(splitAt(tl, now()));
194+ const cutMarked = () => {
195+ if (!hasRange) return;
196+ commit(cutRange(tl, marks.in!, marks.out!));
197+ setMarks(NO_MARKS);
198+ setTimeout(() => player.seek(marks.in!), 0);
199+ };
200+ const currentSeg = () => {
201+ const loc = locate(tl, now());
202+ return loc ? { index: loc.index, start: segmentStarts(tl)[loc.index], seg: tl[loc.index] } : null;
203+ };
204+ const trimStart = () => {
205+ const c = currentSeg();
206+ if (!c) return;
207+ commit(cutRange(tl, c.start, now()));
208+ setTimeout(() => player.seek(c.start), 0);
209+ };
210+ const trimEnd = () => {
211+ const c = currentSeg();
212+ if (c) commit(cutRange(tl, now(), c.start + segDuration(c.seg)));
213+ };
214+ const deleteSelected = () => {
215+ if (!selectedId) return;
216+ commit(tl.filter((s) => s.id !== selectedId));
217+ setSelectedId(null);
218+ };
219+ const moveSelected = (delta: number) => {
220+ const i = tl.findIndex((s) => s.id === selectedId);
221+ if (i >= 0) commit(moveSegment(tl, i, delta));
222+ };
223+ const markIn = () => setMarks((m) => ({ in: now(), out: m.out !== null && m.out > now() ? m.out : null }));
224+ const markOut = () => setMarks((m) => ({ in: m.in !== null && m.in < now() ? m.in : null, out: now() }));
225+
226+ const setThumbnail = async () => {
227+ if (!video) return;
228+ const blob = await captureFrame(video, player);
229+ if (!blob) return;
230+ try {
231+ const fresh = await api.uploadThumbnail(videoId, blob);
232+ setVideo((v) => (v ? mergeServer(v, fresh) : v));
233+ flashNotice('Thumbnail updated');
234+ } catch (e) {
235+ setError((e as Error).message);
236+ }
237+ };
238+
239+ // ---- keyboard shortcuts ----
240+ const keyHandler = useRef<(e: KeyboardEvent) => void>(() => {});
241+ keyHandler.current = (e: KeyboardEvent) => {
242+ const el = e.target as HTMLElement;
243+ if (el.closest('input, textarea, select, [contenteditable]') || confirm) return;
244+ const mod = e.ctrlKey || e.metaKey;
245+ const k = e.key;
246+ const step = e.shiftKey ? 5 : 1;
247+ if (mod && k.toLowerCase() === 'z') (e.shiftKey ? redo : undo)();
248+ else if (mod && k.toLowerCase() === 'y') redo();
249+ else if (mod) return;
250+ else if (k === ' ') player.toggle();
251+ else if (k === 'ArrowLeft') player.seek(now() - step);
252+ else if (k === 'ArrowRight') player.seek(now() + step);
253+ else if (k === ',') player.seek(now() - FRAME);
254+ else if (k === '.') player.seek(now() + FRAME);
255+ else if (k === 'Home') player.seek(0);
256+ else if (k === 'End') player.seek(player.duration);
257+ else if (k === 's' || k === 'S') split();
258+ else if (k === 'i' || k === '[') markIn();
259+ else if (k === 'o' || k === ']') markOut();
260+ else if (k === 'x' || k === 'X') cutMarked();
261+ else if (k === 'Delete' || k === 'Backspace') (hasRange ? cutMarked : deleteSelected)();
262+ else if (k === 'Escape') {
263+ setMarks(NO_MARKS);
264+ setSelectedId(null);
265+ } else return;
266+ e.preventDefault();
267+ };
268+ useEffect(() => {
269+ const on = (e: KeyboardEvent) => keyHandler.current(e);
270+ window.addEventListener('keydown', on);
271+ return () => window.removeEventListener('keydown', on);
272+ }, []);
273+
274+ if (error && !video)
275+ return (
276+ <div className="page">
277+ <div className="banner error">{error}</div>
278+ <a href="#/">Back to videos</a>
279+ </div>
280+ );
281+ if (!video) return <div className="page loading">Loading…</div>;
282+
283+ const readyTakes = video.takes.filter((t) => t.status === 'ready');
284+ const takeNumber = (id: string) => video.takes.findIndex((t) => t.id === id) + 1;
285+ const duration = timelineDuration(video.timeline);
286+ const stale = !!video.export && video.export.signature !== exportSignature(video);
287+
288+ // ---- take actions ----
289+ const refresh = (fresh: Video) => setVideo((v) => (v ? mergeServer(v, fresh) : v));
290+ const replaceFromServer = async (op: () => Promise<Video>) => {
291+ await flush();
292+ try {
293+ const fresh = await op();
294+ setVideo(fresh);
295+ setHistory([]);
296+ setFuture([]);
297+ } catch (e) {
298+ setError((e as Error).message);
299+ }
300+ };
301+ const addToTimeline = (t: Take) => commit([...tl, { id: randomId(), takeId: t.id, start: 0, end: t.duration }]);
302+ const askDeleteTake = (t: Take) =>
303+ setConfirm({
304+ title: `Delete take ${takeNumber(t.id)}?`,
305+ message: 'The recording will be permanently deleted from disk, and any parts of it in the timeline will be removed.',
306+ confirmLabel: 'Delete take',
307+ danger: true,
308+ onConfirm: () => replaceFromServer(() => api.deleteTake(videoId, t.id)),
309+ });
310+ const transcribe = async (takeId: string) => {
311+ try {
312+ refresh(await api.transcribe(videoId, takeId));
313+ } catch (e) {
314+ setError((e as Error).message);
315+ }
316+ };
317+ const startExport = async () => {
318+ await flush();
319+ try {
320+ setJob(await api.startExport(videoId));
321+ } catch (e) {
322+ setError((e as Error).message);
323+ }
324+ };
325+ const setLayout = (l: Partial<Layout>) => patch({ layout: { ...video.layout, ...l } }, 200);
326+ const setOutput = (o: Partial<OutputSettings>) => patch({ output: { ...video.output, ...o } }, 200);
327+ const background = backgroundOf(video.layout);
328+ const setBackground = (b: Partial<Background>) => setLayout({ background: { ...background, ...b } });
329+ const retryMatte = async (takeId: string) => {
330+ try {
331+ refresh(await api.requestMattes(videoId, takeId));
332+ } catch (e) {
333+ setError((e as Error).message);
334+ }
335+ };
336+ const cameraTakes = readyTakes.filter((t) => t.tracks.camera);
337+ // Takes in the timeline whose camera appears in the output but has not had its background removed yet.
338+ const matteBlocking =
339+ background.mode === 'none'
340+ ? []
341+ : [...new Set(video.timeline.map((s) => s.takeId))]
342+ .map((id) => video.takes.find((t) => t.id === id)!)
343+ .filter((t) => t && t.matte?.status !== 'done' && computeGeometry(video.layout, video.output, t.tracks.screen, t.tracks.camera).camera);
344+
345+ const anyBoth = readyTakes.some((t) => t.tracks.screen && t.tracks.camera);
346+
347+ return (
348+ <div className="page editor">
349+ <header className="topbar">
350+ <a className="btn ghost" href="#/" onClick={() => flush()}>
351+ <Icon name="back" /> Videos
352+ </a>
353+ <input className="title-input" value={video.title} onChange={(e) => patch({ title: e.target.value })} aria-label="Title" />
354+ <span className={`save-state ${saveState}`}>{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Not saved' : 'Saved'}</span>
355+ <div className="spacer" />
356+ <button className="btn ghost" onClick={() => api.openFolder(videoId).catch((e) => setError(e.message))} title="Open this video's folder">
357+ <Icon name="folder" /> Folder
358+ </button>
359+ <button
360+ className="btn record"
361+ onClick={async () => {
362+ await flush();
363+ navigate(`video/${videoId}/record`);
364+ }}
365+ >
366+ <Icon name="record" /> Record take
367+ </button>
368+ </header>
369+
370+ {error && (
371+ <div className="banner error" onClick={() => setError(null)}>
372+ {error} <span className="muted">(click to dismiss)</span>
373+ </div>
374+ )}
375+ {notice && <div className="toast">{notice}</div>}
376+
377+ <div className="editor-grid">
378+ <section className="editor-main">
379+ {video.takes.length === 0 ? (
380+ <div className="empty">
381+ <h2>No takes yet</h2>
382+ <p className="muted">Record your camera, your screen, or both. You can record several takes and combine them.</p>
383+ <button className="btn record big" onClick={() => navigate(`video/${videoId}/record`)}>
384+ <Icon name="record" /> Record first take
385+ </button>
386+ </div>
387+ ) : (
388+ <>
389+ <Stage video={video} player={player} />
390+ <div className="transport">
391+ <PlayButton player={player} />
392+ <TimeDisplay player={player} duration={duration} />
393+ <div className="sep" />
394+ <button className="icon-btn" title="Undo (Ctrl+Z)" disabled={history.length === 0} onClick={undo}>
395+ <Icon name="undo" />
396+ </button>
397+ <button className="icon-btn" title="Redo (Ctrl+Shift+Z)" disabled={future.length === 0} onClick={redo}>
398+ <Icon name="redo" />
399+ </button>
400+ <div className="sep" />
401+ <button className="icon-btn" title="Split at playhead (S)" onClick={split}>
402+ <Icon name="split" />
403+ </button>
404+ <button className="icon-btn" title="Trim: cut from start of this segment to playhead" onClick={trimStart}>
405+ <Icon name="trimStart" />
406+ </button>
407+ <button className="icon-btn" title="Trim: cut from playhead to end of this segment" onClick={trimEnd}>
408+ <Icon name="trimEnd" />
409+ </button>
410+ <div className="sep" />
411+ <button className="btn small" title="Mark in (I)" onClick={markIn}>
412+ In
413+ </button>
414+ <button className="btn small" title="Mark out (O)" onClick={markOut}>
415+ Out
416+ </button>
417+ <button className="btn small danger" title="Cut marked range (X)" disabled={!hasRange} onClick={cutMarked}>
418+ <Icon name="scissors" /> Cut {hasRange ? fmtTimecode(marks.out! - marks.in!) : ''}
419+ </button>
420+ <div className="sep" />
421+ <button className="icon-btn" title="Move selected segment earlier" disabled={!selectedId} onClick={() => moveSelected(-1)}>
422+ <Icon name="left" />
423+ </button>
424+ <button className="icon-btn" title="Move selected segment later" disabled={!selectedId} onClick={() => moveSelected(1)}>
425+ <Icon name="right" />
426+ </button>
427+ <button className="icon-btn danger" title="Remove selected segment (Delete)" disabled={!selectedId} onClick={deleteSelected}>
428+ <Icon name="trash" />
429+ </button>
430+ <div className="spacer" />
431+ <label className="zoom" title="Timeline zoom">
432+ <span className="muted small">Zoom</span>
433+ <input type="range" min={1} max={20} step={0.5} value={zoom} onChange={(e) => setZoom(parseFloat(e.target.value))} />
434+ </label>
435+ <button className="icon-btn" title="Use current frame as thumbnail" onClick={setThumbnail}>
436+ <Icon name="image" />
437+ </button>
438+ </div>
439+ <Timeline segments={video.timeline} takeNumber={takeNumber} player={player} selectedId={selectedId} onSelect={setSelectedId} marks={marks} peaks={peaks} zoom={zoom} />
440+ <p className="shortcuts muted small">
441+ Space play/pause · ←/→ 1 s (Shift 5 s) · , / . one frame · S split · I / O mark in/out · X cut marked · Delete remove segment · Ctrl+Z undo
442+ </p>
443+
444+ <h3 className="section-title">Takes</h3>
445+ <div className="takes">
446+ {video.takes.map((t) => (
447+ <TakeCard
448+ key={t.id}
449+ take={t}
450+ n={takeNumber(t.id)}
451+ inTimeline={video.timeline.some((s) => s.takeId === t.id)}
452+ onAdd={() => addToTimeline(t)}
453+ onDelete={() => askDeleteTake(t)}
454+ onRecover={() => replaceFromServer(() => api.finalizeTake(videoId, t.id))}
455+ />
456+ ))}
457+ </div>
458+ </>
459+ )}
460+ </section>
461+
462+ <aside className="editor-side">
463+ <div className="tabs">
464+ <button className={tab === 'details' ? 'active' : ''} onClick={() => setTab('details')}>
465+ Details
466+ </button>
467+ <button className={tab === 'transcript' ? 'active' : ''} onClick={() => setTab('transcript')}>
468+ Transcript
469+ </button>
470+ <button className={tab === 'export' ? 'active' : ''} onClick={() => setTab('export')}>
471+ Layout & export
472+ </button>
473+ </div>
474+
475+ {tab === 'details' && (
476+ <div className="panel">
477+ <label className="field">
478+ <span>Description</span>
479+ <textarea rows={4} value={video.description} onChange={(e) => patch({ description: e.target.value })} />
480+ </label>
481+ <label className="field grow">
482+ <span>Notes (teleprompter)</span>
483+ <textarea className="notes" value={video.notes} onChange={(e) => patch({ notes: e.target.value })} />
484+ </label>
485+ <p className="muted small">Created {fmtDateTime(video.createdAt)}</p>
486+ </div>
487+ )}
488+
489+ {tab === 'transcript' && (
490+ <div className="panel">
491+ <TranscriptPanel
492+ segments={video.timeline}
493+ takes={video.takes}
494+ transcripts={transcripts}
495+ takeNumber={takeNumber}
496+ player={player}
497+ marks={marks}
498+ onMarks={setMarks}
499+ onCut={cutMarked}
500+ onTranscribe={transcribe}
501+ hasKey={!!config?.hasDeepgramKey}
502+ />
503+ </div>
504+ )}
505+
506+ {tab === 'export' && (
507+ <div className="panel">
508+ <h4>Camera and screen layout</h4>
509+ {!anyBoth && <p className="muted small">These apply to takes that have both camera and screen.</p>}
510+ <div className="seg-control">
511+ {(
512+ [
513+ ['pip', 'Picture in picture'],
514+ ['side-by-side', 'Side by side'],
515+ ['screen-only', 'Screen'],
516+ ['camera-only', 'Camera'],
517+ ] as const
518+ ).map(([m, label]) => (
519+ <button key={m} className={video.layout.mode === m ? 'active' : ''} onClick={() => setLayout({ mode: m })}>
520+ {label}
521+ </button>
522+ ))}
523+ </div>
524+ {(video.layout.mode === 'pip' || video.layout.mode === 'side-by-side') && (
525+ <div className="field">
526+ <span>{video.layout.mode === 'pip' ? 'Corner' : 'Camera side'}</span>
527+ <div className="seg-control">
528+ {(video.layout.mode === 'pip'
529+ ? ([
530+ ['tl', 'Top left'],
531+ ['tr', 'Top right'],
532+ ['bl', 'Bottom left'],
533+ ['br', 'Bottom right'],
534+ ] as const)
535+ : ([
536+ ['bl', 'Left'],
537+ ['br', 'Right'],
538+ ] as const)
539+ ).map(([c, label]) => (
540+ <button
541+ key={c}
542+ className={(video.layout.mode === 'pip' ? video.layout.corner === c : isLeft(video.layout.corner) === isLeft(c)) ? 'active' : ''}
543+ onClick={() => setLayout({ corner: c })}
544+ >
545+ {label}
546+ </button>
547+ ))}
548+ </div>
549+ </div>
550+ )}
551+ {video.layout.mode === 'pip' && (
552+ <>
553+ <label className="field">
554+ <span>Camera size ({Math.round(video.layout.size * 100)}% of width)</span>
555+ <input type="range" min={0.12} max={0.45} step={0.01} value={video.layout.size} onChange={(e) => setLayout({ size: parseFloat(e.target.value) })} />
556+ </label>
557+ <div className="field">
558+ <span>Shape</span>
559+ <div className="seg-control">
560+ {(
561+ [
562+ ['rect', 'Rectangle'],
563+ ['rounded', 'Rounded'],
564+ ['circle', 'Circle'],
565+ ] as const
566+ ).map(([s, label]) => (
567+ <button key={s} className={video.layout.shape === s ? 'active' : ''} onClick={() => setLayout({ shape: s })}>
568+ {label}
569+ </button>
570+ ))}
571+ </div>
572+ </div>
573+ </>
574+ )}
575+
576+ {cameraTakes.length > 0 && (
577+ <>
578+ <h4>Background behind you</h4>
579+ <div className="seg-control">
580+ {(
581+ [
582+ ['none', 'Original'],
583+ ['blur', 'Blur'],
584+ ['color', 'Color'],
585+ ['cutout', 'Cutout'],
586+ ] as const
587+ ).map(([m, label]) => (
588+ <button
589+ key={m}
590+ className={background.mode === m ? 'active' : ''}
591+ title={m === 'cutout' ? 'Picture in picture only: just you, over the screen' : undefined}
592+ onClick={() => setBackground({ mode: m })}
593+ >
594+ {label}
595+ </button>
596+ ))}
597+ </div>
598+ {background.mode === 'cutout' && video.layout.mode !== 'pip' && (
599+ <p className="muted small">Cutout applies to picture in picture; with this layout the color below is used instead.</p>
600+ )}
601+ {(background.mode === 'color' || (background.mode === 'cutout' && video.layout.mode !== 'pip')) && (
602+ <label className="row">
603+ <span className="small muted">Color</span>
604+ <input type="color" value={background.color} onChange={(e) => setBackground({ color: e.target.value })} />
605+ </label>
606+ )}
607+ {background.mode === 'blur' && (
608+ <label className="field">
609+ <span>Blur strength</span>
610+ <input type="range" min={4} max={50} step={1} value={background.blur} onChange={(e) => setBackground({ blur: parseInt(e.target.value) })} />
611+ </label>
612+ )}
613+ {background.mode !== 'none' && (
614+ <div className="matte-status">
615+ {cameraTakes.map((t) => (
616+ <div key={t.id} className="row small">
617+ <span>Take {takeNumber(t.id)}:</span>
618+ <MatteStatus take={t} onRetry={() => retryMatte(t.id)} />
619+ </div>
620+ ))}
621+ <p className="muted small">Background removal runs on this computer (Robust Video Matting) and is done once per take.</p>
622+ </div>
623+ )}
624+ </>
625+ )}
626+
627+ <h4>Output</h4>
628+ <div className="field">
629+ <span>Resolution</span>
630+ <div className="seg-control">
631+ {([720, 1080] as const).map((h) => (
632+ <button key={h} className={video.output.height === h ? 'active' : ''} onClick={() => setOutput({ height: h })}>
633+ {h}p
634+ </button>
635+ ))}
636+ </div>
637+ </div>
638+ <label className="check">
639+ <input type="checkbox" checked={video.output.normalizeAudio} onChange={(e) => setOutput({ normalizeAudio: e.target.checked })} />
640+ Normalize loudness (−16 LUFS)
641+ </label>
642+
643+ <div className="export-box">
644+ {job?.status === 'running' ? (
645+ <>
646+ <div className="progress">
647+ <div style={{ width: `${Math.round(job.progress * 100)}%` }} />
648+ </div>
649+ <p className="small muted">Exporting… {Math.round(job.progress * 100)}%</p>
650+ </>
651+ ) : (
652+ <button
653+ className="btn primary"
654+ disabled={video.timeline.length === 0 || matteBlocking.length > 0}
655+ title={matteBlocking.length > 0 ? 'Waiting for background removal to finish' : undefined}
656+ onClick={startExport}
657+ >
658+ <Icon name="film" /> {video.export ? 'Export again' : 'Export MP4'}
659+ </button>
660+ )}
661+ {job?.status !== 'running' && matteBlocking.length > 0 && (
662+ <p className="small muted">Export will be available once background removal has finished for the takes in the timeline.</p>
663+ )}
664+ {job?.status === 'failed' && <p className="small error-text">Export failed: {job.error}</p>}
665+ {video.export && (
666+ <div className="export-info">
667+ <div>
668+ <a href={`/media/${videoId}/${video.export.file}`} target="_blank" rel="noreferrer">
669+ {video.export.file}
670+ </a>{' '}
671+ <span className="muted small">
672+ {fmtDuration(video.export.duration)} · {fmtBytes(video.export.bytes)} · {fmtDateTime(video.export.exportedAt)}
673+ </span>
674+ </div>
675+ {stale && <p className="small warn-text">The timeline or layout changed since this export.</p>}
676+ <button className="btn small" onClick={() => api.openFolder(videoId).catch((e) => setError(e.message))}>
677+ <Icon name="folder" /> Show in folder
678+ </button>
679+ </div>
680+ )}
681+ </div>
682+ </div>
683+ )}
684+ </aside>
685+ </div>
686+
687+ {confirm && <ConfirmDialog req={confirm} onClose={() => setConfirm(null)} />}
688+ </div>
689+ );
690+}
691+
692+function MatteStatus({ take, onRetry }: { take: Take; onRetry: () => void }) {
693+ const m = take.matte;
694+ if (!m) return <span className="muted">waiting</span>;
695+ if (m.status === 'done') return <span className="ok-text">done</span>;
696+ if (m.status === 'queued') return <span className="muted">queued</span>;
697+ if (m.status === 'running')
698+ return (
699+ <>
700+ <div className="progress mini">
701+ <div style={{ width: `${Math.round((m.progress ?? 0) * 100)}%` }} />
702+ </div>
703+ <span className="muted">{Math.round((m.progress ?? 0) * 100)}%</span>
704+ </>
705+ );
706+ return (
707+ <>
708+ <span className="error-text" title={m.error}>
709+ failed
710+ </span>
711+ <button className="btn small" onClick={onRetry}>
712+ Retry
713+ </button>
714+ </>
715+ );
716+}
717+
718+function PlayButton({ player }: { player: TimelinePlayer }) {
719+ const playing = usePlayerPlaying(player);
720+ return (
721+ <button className="icon-btn play" title="Play/pause (Space)" onClick={() => player.toggle()}>
722+ <Icon name={playing ? 'pause' : 'play'} size={18} />
723+ </button>
724+ );
725+}
726+
727+function TimeDisplay({ player, duration }: { player: TimelinePlayer; duration: number }) {
728+ const time = usePlayerTime(player);
729+ return (
730+ <span className="timecode">
731+ {fmtTimecode(time)} <span className="muted">/ {fmtTimecode(duration)}</span>
732+ </span>
733+ );
734+}
735+
736+function TakeCard({ take, n, inTimeline, onAdd, onDelete, onRecover }: { take: Take; n: number; inTimeline: boolean; onAdd: () => void; onDelete: () => void; onRecover: () => void }) {
737+ return (
738+ <div className="take" style={{ ['--hue' as string]: takeHue(n) }}>
739+ <div className="take-head">
740+ <span className="take-swatch" />
741+ <strong>Take {n}</strong>
742+ <span className="kinds">
743+ {take.tracks.camera && <Icon name="camera" size={14} />}
744+ {take.tracks.screen && <Icon name="screen" size={14} />}
745+ {take.audio && <Icon name="mic" size={14} />}
746+ </span>
747+ </div>
748+ <div className="small muted">
749+ {fmtDateTime(take.createdAt)}
750+ {take.status === 'ready' && ` · ${fmtDuration(take.duration)}`}
751+ {take.transcript?.status === 'done' && ' · transcribed'}
752+ {take.transcript?.status === 'pending' && ' · transcribing…'}
753+ {take.matte?.status === 'done' && ' · background removed'}
754+ {(take.matte?.status === 'queued' || take.matte?.status === 'running') && ' · removing background…'}
755+ </div>
756+ {take.status === 'processing' && <div className="small">Processing…</div>}
757+ {(take.status === 'recording' || take.status === 'failed') && (
758+ <div className="small warn-text">{take.status === 'failed' ? `Failed: ${take.error}` : 'Interrupted recording (not finalized)'}</div>
759+ )}
760+ <div className="take-actions">
761+ {take.status === 'ready' && (
762+ <button className="btn small" onClick={onAdd} title="Append the whole take to the end of the timeline">
763+ <Icon name="plus" /> {inTimeline ? 'Add again' : 'Add to timeline'}
764+ </button>
765+ )}
766+ {(take.status === 'recording' || take.status === 'failed') && (
767+ <button className="btn small" onClick={onRecover} title="Try to finalize whatever was recorded">
768+ Recover
769+ </button>
770+ )}
771+ <button className="icon-btn danger" onClick={onDelete} title="Delete take">
772+ <Icon name="trash" />
773+ </button>
774+ </div>
775+ </div>
776+ );
777+}
src/styles.cssadded+1049−0View file
@@ -0,0 +1,1049 @@
1+:root {
2+ --bg: #121418;
3+ --panel: #1a1d23;
4+ --panel-2: #22262e;
5+ --border: #2e333d;
6+ --text: #e6e8eb;
7+ --muted: #8b929e;
8+ --accent: #5b9dff;
9+ --accent-2: #3d7fe0;
10+ --red: #ef4444;
11+ --red-2: #d53434;
12+ --green: #3fb67a;
13+ --amber: #e0a33a;
14+ --radius: 8px;
15+ color-scheme: dark;
16+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, sans-serif;
17+ font-size: 14px;
18+ line-height: 1.45;
19+ color: var(--text);
20+ background: var(--bg);
21+}
22+
23+* {
24+ box-sizing: border-box;
25+}
26+body {
27+ margin: 0;
28+ background: var(--bg);
29+}
30+a {
31+ color: var(--accent);
32+ text-decoration: none;
33+}
34+a:hover {
35+ text-decoration: underline;
36+}
37+code {
38+ background: var(--panel-2);
39+ padding: 1px 5px;
40+ border-radius: 4px;
41+ font-size: 0.92em;
42+}
43+h2 {
44+ margin: 0 0 12px;
45+ font-size: 18px;
46+ font-weight: 600;
47+}
48+h3,
49+h4 {
50+ margin: 0 0 10px;
51+ font-weight: 600;
52+}
53+h4 {
54+ font-size: 13px;
55+ text-transform: uppercase;
56+ letter-spacing: 0.04em;
57+ color: var(--muted);
58+ margin-top: 6px;
59+}
60+
61+.muted {
62+ color: var(--muted);
63+}
64+.small {
65+ font-size: 12px;
66+}
67+.spacer {
68+ flex: 1;
69+}
70+.grow {
71+ flex: 1;
72+}
73+.row {
74+ display: flex;
75+ align-items: center;
76+ gap: 8px;
77+ flex-wrap: wrap;
78+}
79+.error-text {
80+ color: #ff8a8a;
81+}
82+.warn-text {
83+ color: var(--amber);
84+}
85+
86+input,
87+textarea,
88+select {
89+ font: inherit;
90+ color: var(--text);
91+ background: var(--bg);
92+ border: 1px solid var(--border);
93+ border-radius: 6px;
94+ padding: 6px 9px;
95+}
96+input:focus,
97+textarea:focus,
98+select:focus {
99+ outline: none;
100+ border-color: var(--accent);
101+}
102+textarea {
103+ resize: vertical;
104+}
105+input[type='range'] {
106+ padding: 0;
107+ accent-color: var(--accent);
108+}
109+input[type='checkbox'] {
110+ accent-color: var(--accent);
111+}
112+
113+/* ---- buttons ---- */
114+.btn {
115+ display: inline-flex;
116+ align-items: center;
117+ gap: 6px;
118+ font: inherit;
119+ font-weight: 500;
120+ color: var(--text);
121+ background: var(--panel-2);
122+ border: 1px solid var(--border);
123+ border-radius: 6px;
124+ padding: 6px 12px;
125+ cursor: pointer;
126+ white-space: nowrap;
127+ text-decoration: none !important;
128+}
129+.btn:hover:not(:disabled) {
130+ background: #2a2f38;
131+}
132+.btn:disabled {
133+ opacity: 0.45;
134+ cursor: default;
135+}
136+.btn.primary {
137+ background: var(--accent-2);
138+ border-color: var(--accent-2);
139+ color: white;
140+}
141+.btn.primary:hover:not(:disabled) {
142+ background: var(--accent);
143+}
144+.btn.record {
145+ background: var(--red-2);
146+ border-color: var(--red-2);
147+ color: white;
148+}
149+.btn.record:hover:not(:disabled) {
150+ background: var(--red);
151+}
152+.btn.danger {
153+ color: #ff8a8a;
154+}
155+.btn.danger:hover:not(:disabled) {
156+ background: rgba(239, 68, 68, 0.15);
157+}
158+.btn.ghost {
159+ background: transparent;
160+ border-color: transparent;
161+}
162+.btn.ghost:hover:not(:disabled) {
163+ background: var(--panel-2);
164+}
165+.btn.small {
166+ padding: 3px 8px;
167+ font-size: 12px;
168+}
169+.btn.big {
170+ padding: 10px 20px;
171+ font-size: 15px;
172+}
173+.icon-btn {
174+ display: inline-flex;
175+ align-items: center;
176+ justify-content: center;
177+ width: 30px;
178+ height: 30px;
179+ color: var(--text);
180+ background: transparent;
181+ border: 1px solid transparent;
182+ border-radius: 6px;
183+ cursor: pointer;
184+}
185+.icon-btn:hover:not(:disabled) {
186+ background: var(--panel-2);
187+ border-color: var(--border);
188+}
189+.icon-btn:disabled {
190+ opacity: 0.35;
191+ cursor: default;
192+}
193+.icon-btn.danger:hover:not(:disabled) {
194+ color: #ff8a8a;
195+}
196+.icon-btn.play {
197+ width: 36px;
198+ height: 36px;
199+ background: var(--panel-2);
200+ border-color: var(--border);
201+}
202+
203+/* ---- page chrome ---- */
204+.page {
205+ min-height: 100vh;
206+ display: flex;
207+ flex-direction: column;
208+}
209+.page.loading {
210+ align-items: center;
211+ justify-content: center;
212+ color: var(--muted);
213+}
214+.topbar {
215+ display: flex;
216+ align-items: center;
217+ gap: 10px;
218+ padding: 10px 16px;
219+ border-bottom: 1px solid var(--border);
220+ background: var(--panel);
221+ position: sticky;
222+ top: 0;
223+ z-index: 10;
224+}
225+.topbar-title {
226+ font-weight: 600;
227+ font-size: 15px;
228+}
229+.brand {
230+ display: flex;
231+ align-items: center;
232+ gap: 8px;
233+ font-weight: 700;
234+ font-size: 17px;
235+ margin-right: 12px;
236+}
237+.brand-dot {
238+ width: 12px;
239+ height: 12px;
240+ border-radius: 50%;
241+ background: var(--red);
242+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25);
243+}
244+.search {
245+ width: 260px;
246+}
247+.title-input {
248+ font-size: 16px;
249+ font-weight: 600;
250+ background: transparent;
251+ border-color: transparent;
252+ min-width: 200px;
253+ width: 40vw;
254+}
255+.title-input:hover {
256+ border-color: var(--border);
257+}
258+.save-state {
259+ font-size: 12px;
260+ color: var(--muted);
261+}
262+.save-state.error {
263+ color: #ff8a8a;
264+}
265+.banner {
266+ margin: 10px 16px 0;
267+ padding: 8px 12px;
268+ border-radius: 6px;
269+ background: var(--panel-2);
270+ border: 1px solid var(--border);
271+}
272+.banner.error {
273+ background: rgba(239, 68, 68, 0.12);
274+ border-color: rgba(239, 68, 68, 0.4);
275+ color: #ffb3b3;
276+ cursor: pointer;
277+}
278+.toast {
279+ position: fixed;
280+ bottom: 20px;
281+ left: 50%;
282+ transform: translateX(-50%);
283+ background: var(--panel-2);
284+ border: 1px solid var(--border);
285+ padding: 8px 16px;
286+ border-radius: 20px;
287+ z-index: 50;
288+}
289+.panel {
290+ background: var(--panel);
291+ border: 1px solid var(--border);
292+ border-radius: var(--radius);
293+ padding: 14px;
294+ display: flex;
295+ flex-direction: column;
296+ gap: 10px;
297+}
298+.field {
299+ display: flex;
300+ flex-direction: column;
301+ gap: 4px;
302+}
303+.field > span {
304+ font-size: 12px;
305+ color: var(--muted);
306+}
307+.field.grow {
308+ flex: 1;
309+}
310+.check {
311+ display: inline-flex;
312+ align-items: center;
313+ gap: 7px;
314+ cursor: pointer;
315+}
316+.empty {
317+ text-align: center;
318+ padding: 70px 20px;
319+}
320+.empty p {
321+ margin-bottom: 20px;
322+}
323+
324+/* ---- modal ---- */
325+.modal-backdrop {
326+ position: fixed;
327+ inset: 0;
328+ background: rgba(0, 0, 0, 0.6);
329+ display: flex;
330+ align-items: center;
331+ justify-content: center;
332+ z-index: 100;
333+}
334+.modal {
335+ background: var(--panel);
336+ border: 1px solid var(--border);
337+ border-radius: 10px;
338+ padding: 20px;
339+ width: min(440px, calc(100vw - 32px));
340+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
341+}
342+.modal.wide {
343+ width: min(640px, calc(100vw - 32px));
344+}
345+.modal-body {
346+ display: flex;
347+ flex-direction: column;
348+ gap: 12px;
349+}
350+.modal-body p {
351+ margin: 0;
352+}
353+.modal-footer {
354+ display: flex;
355+ justify-content: flex-end;
356+ gap: 8px;
357+ margin-top: 18px;
358+}
359+
360+/* ---- library ---- */
361+.library {
362+ padding: 20px 16px 40px;
363+}
364+.grid {
365+ display: grid;
366+ grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
367+ gap: 16px;
368+}
369+.card {
370+ position: relative;
371+ background: var(--panel);
372+ border: 1px solid var(--border);
373+ border-radius: var(--radius);
374+ overflow: hidden;
375+ cursor: pointer;
376+ transition: border-color 0.12s, transform 0.12s;
377+}
378+.card:hover {
379+ border-color: #454c59;
380+}
381+.card.dragging {
382+ opacity: 0.4;
383+}
384+.thumb {
385+ position: relative;
386+ aspect-ratio: 16 / 9;
387+ background: #0b0c0f;
388+}
389+.thumb img {
390+ width: 100%;
391+ height: 100%;
392+ object-fit: cover;
393+ display: block;
394+}
395+.thumb-empty {
396+ width: 100%;
397+ height: 100%;
398+ display: flex;
399+ align-items: center;
400+ justify-content: center;
401+ color: #3a404b;
402+}
403+.duration {
404+ position: absolute;
405+ right: 6px;
406+ bottom: 6px;
407+ background: rgba(0, 0, 0, 0.75);
408+ padding: 1px 6px;
409+ border-radius: 4px;
410+ font-size: 12px;
411+ font-variant-numeric: tabular-nums;
412+}
413+.thumb .kinds {
414+ position: absolute;
415+ left: 6px;
416+ bottom: 6px;
417+ background: rgba(0, 0, 0, 0.6);
418+ border-radius: 4px;
419+ padding: 2px 4px;
420+}
421+.kinds {
422+ display: inline-flex;
423+ gap: 5px;
424+ color: var(--muted);
425+}
426+.thumb .kinds {
427+ color: #dfe3e8;
428+}
429+.card-body {
430+ padding: 10px 12px 12px;
431+}
432+.card-title {
433+ font-weight: 600;
434+ margin-bottom: 3px;
435+}
436+.card-desc {
437+ color: var(--muted);
438+ font-size: 13px;
439+ display: -webkit-box;
440+ -webkit-line-clamp: 2;
441+ -webkit-box-orient: vertical;
442+ overflow: hidden;
443+ margin-bottom: 6px;
444+}
445+.card-meta {
446+ display: flex;
447+ gap: 10px;
448+ align-items: center;
449+ font-size: 12px;
450+ color: var(--muted);
451+}
452+.tag {
453+ font-size: 11px;
454+ padding: 0 6px;
455+ border-radius: 10px;
456+ border: 1px solid;
457+}
458+.tag.ok {
459+ color: var(--green);
460+}
461+.tag.warn {
462+ color: var(--amber);
463+}
464+.card-actions {
465+ position: absolute;
466+ top: 6px;
467+ right: 6px;
468+ display: none;
469+ gap: 4px;
470+}
471+.card-actions .icon-btn {
472+ background: rgba(15, 17, 21, 0.85);
473+}
474+.card-actions .icon-btn:first-child {
475+ color: var(--red);
476+}
477+.card:hover .card-actions {
478+ display: flex;
479+}
480+.grip {
481+ position: absolute;
482+ top: 8px;
483+ left: 6px;
484+ color: #dfe3e8;
485+ background: rgba(15, 17, 21, 0.7);
486+ border-radius: 4px;
487+ padding: 2px;
488+ display: none;
489+ cursor: grab;
490+}
491+.card:hover .grip {
492+ display: flex;
493+}
494+
495+/* ---- editor ---- */
496+.editor-grid {
497+ display: grid;
498+ grid-template-columns: minmax(0, 1fr) 400px;
499+ gap: 16px;
500+ padding: 16px;
501+ align-items: start;
502+}
503+@media (max-width: 1100px) {
504+ .editor-grid {
505+ grid-template-columns: 1fr;
506+ }
507+}
508+.editor-main {
509+ display: flex;
510+ flex-direction: column;
511+ gap: 10px;
512+ min-width: 0;
513+}
514+.editor-side {
515+ position: sticky;
516+ top: 70px;
517+ display: flex;
518+ flex-direction: column;
519+ gap: 0;
520+ max-height: calc(100vh - 86px);
521+}
522+.editor-side .panel {
523+ border-top-left-radius: 0;
524+ border-top-right-radius: 0;
525+ overflow: auto;
526+ min-height: 300px;
527+ max-height: calc(100vh - 124px);
528+}
529+.tabs {
530+ display: flex;
531+ gap: 2px;
532+}
533+.tabs button {
534+ flex: 1;
535+ font: inherit;
536+ color: var(--muted);
537+ background: var(--bg);
538+ border: 1px solid var(--border);
539+ border-bottom: none;
540+ border-radius: 8px 8px 0 0;
541+ padding: 7px 8px;
542+ cursor: pointer;
543+ white-space: nowrap;
544+}
545+.tabs button.active {
546+ color: var(--text);
547+ background: var(--panel);
548+}
549+textarea.notes {
550+ min-height: 320px;
551+ flex: 1;
552+ font-size: 13px;
553+}
554+
555+.stage {
556+ position: relative;
557+ width: 100%;
558+ background: #000;
559+ border-radius: var(--radius);
560+ overflow: hidden;
561+ cursor: pointer;
562+}
563+.stage-inner {
564+ position: absolute;
565+ left: 0;
566+ top: 0;
567+ transform-origin: 0 0;
568+}
569+.stage-inner .layer {
570+ position: absolute;
571+ background: #000;
572+}
573+.stage-empty {
574+ position: absolute;
575+ inset: 0;
576+ display: flex;
577+ align-items: center;
578+ justify-content: center;
579+ color: var(--muted);
580+}
581+
582+.transport {
583+ display: flex;
584+ align-items: center;
585+ gap: 4px;
586+ flex-wrap: wrap;
587+}
588+.transport .sep {
589+ width: 1px;
590+ height: 22px;
591+ background: var(--border);
592+ margin: 0 6px;
593+}
594+.timecode {
595+ font-variant-numeric: tabular-nums;
596+ font-family: ui-monospace, 'SF Mono', Menlo, monospace;
597+ font-size: 13px;
598+ margin-left: 6px;
599+}
600+.zoom {
601+ display: inline-flex;
602+ align-items: center;
603+ gap: 6px;
604+}
605+.zoom input {
606+ width: 100px;
607+}
608+
609+.timeline-scroll {
610+ overflow-x: auto;
611+ background: var(--panel);
612+ border: 1px solid var(--border);
613+ border-radius: var(--radius);
614+}
615+.timeline {
616+ position: relative;
617+ min-width: 100%;
618+ height: 104px;
619+ user-select: none;
620+ cursor: text;
621+ touch-action: none;
622+}
623+.tl-ruler {
624+ position: relative;
625+ height: 20px;
626+ border-bottom: 1px solid var(--border);
627+}
628+.tl-tick {
629+ position: absolute;
630+ top: 3px;
631+ font-size: 10px;
632+ color: var(--muted);
633+ padding-left: 3px;
634+ border-left: 1px solid var(--border);
635+ line-height: 14px;
636+ font-variant-numeric: tabular-nums;
637+}
638+.tl-track {
639+ position: absolute;
640+ top: 28px;
641+ left: 0;
642+ right: 0;
643+ height: 68px;
644+}
645+.tl-seg {
646+ position: absolute;
647+ top: 0;
648+ bottom: 0;
649+ background: hsl(var(--hue) 45% 30%);
650+ border: 1px solid hsl(var(--hue) 50% 45%);
651+ border-radius: 5px;
652+ overflow: hidden;
653+ box-sizing: border-box;
654+}
655+.tl-seg.selected {
656+ border: 2px solid #fff;
657+}
658+.tl-wave {
659+ position: absolute;
660+ inset: 0;
661+ width: 100%;
662+ height: 100%;
663+}
664+.tl-label {
665+ position: absolute;
666+ left: 5px;
667+ top: 2px;
668+ font-size: 10px;
669+ color: rgba(255, 255, 255, 0.85);
670+ pointer-events: none;
671+ white-space: nowrap;
672+}
673+.tl-range {
674+ position: absolute;
675+ top: 20px;
676+ bottom: 0;
677+ background: rgba(239, 68, 68, 0.28);
678+ border-left: 1px solid var(--red);
679+ border-right: 1px solid var(--red);
680+ pointer-events: none;
681+}
682+.tl-mark {
683+ position: absolute;
684+ top: 20px;
685+ bottom: 0;
686+ width: 0;
687+ border-left: 2px dashed var(--red);
688+ pointer-events: none;
689+}
690+.tl-playhead {
691+ position: absolute;
692+ top: 0;
693+ bottom: 0;
694+ width: 2px;
695+ margin-left: -1px;
696+ background: #fff;
697+ pointer-events: none;
698+ box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
699+}
700+.tl-playhead::before {
701+ content: '';
702+ position: absolute;
703+ top: 0;
704+ left: -5px;
705+ border: 6px solid transparent;
706+ border-top-color: #fff;
707+}
708+.shortcuts {
709+ margin: 0;
710+}
711+.section-title {
712+ margin-top: 10px;
713+ font-size: 14px;
714+}
715+.takes {
716+ display: grid;
717+ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
718+ gap: 10px;
719+}
720+.take {
721+ background: var(--panel);
722+ border: 1px solid var(--border);
723+ border-radius: var(--radius);
724+ padding: 10px 12px;
725+ display: flex;
726+ flex-direction: column;
727+ gap: 4px;
728+}
729+.take-head {
730+ display: flex;
731+ align-items: center;
732+ gap: 8px;
733+}
734+.take-head .kinds {
735+ margin-left: auto;
736+}
737+.take-swatch {
738+ width: 10px;
739+ height: 10px;
740+ border-radius: 3px;
741+ background: hsl(var(--hue) 50% 45%);
742+}
743+.take-actions {
744+ display: flex;
745+ align-items: center;
746+ gap: 6px;
747+ margin-top: 4px;
748+}
749+.take-actions .icon-btn {
750+ margin-left: auto;
751+}
752+
753+.seg-control {
754+ display: flex;
755+ flex-wrap: wrap;
756+ gap: 0;
757+ border: 1px solid var(--border);
758+ border-radius: 6px;
759+ overflow: hidden;
760+ width: fit-content;
761+}
762+.seg-control button {
763+ font: inherit;
764+ font-size: 12px;
765+ color: var(--muted);
766+ background: var(--bg);
767+ border: none;
768+ border-right: 1px solid var(--border);
769+ padding: 5px 10px;
770+ cursor: pointer;
771+}
772+.seg-control button:last-child {
773+ border-right: none;
774+}
775+.seg-control button.active {
776+ background: var(--accent-2);
777+ color: white;
778+}
779+.export-box {
780+ margin-top: 8px;
781+ padding-top: 12px;
782+ border-top: 1px solid var(--border);
783+ display: flex;
784+ flex-direction: column;
785+ gap: 8px;
786+ align-items: flex-start;
787+}
788+.export-box p {
789+ margin: 0;
790+}
791+.export-info {
792+ display: flex;
793+ flex-direction: column;
794+ gap: 6px;
795+ align-items: flex-start;
796+}
797+.progress {
798+ width: 100%;
799+ height: 8px;
800+ background: var(--bg);
801+ border-radius: 4px;
802+ overflow: hidden;
803+}
804+.progress > div {
805+ height: 100%;
806+ background: var(--accent);
807+ transition: width 0.3s;
808+}
809+
810+/* ---- transcript ---- */
811+.transcript {
812+ display: flex;
813+ flex-direction: column;
814+ gap: 10px;
815+}
816+.transcript-bar {
817+ display: flex;
818+ align-items: center;
819+ gap: 10px;
820+ justify-content: space-between;
821+}
822+.transcript-status {
823+ display: flex;
824+ flex-direction: column;
825+ gap: 4px;
826+}
827+.transcript-words {
828+ line-height: 1.8;
829+ font-size: 14px;
830+}
831+.word {
832+ cursor: pointer;
833+ border-radius: 3px;
834+ padding: 1px 1px;
835+}
836+.word:hover {
837+ background: var(--panel-2);
838+}
839+.word.current {
840+ background: rgba(91, 157, 255, 0.35);
841+}
842+.word.selected {
843+ background: rgba(239, 68, 68, 0.35);
844+ text-decoration: line-through;
845+ text-decoration-color: rgba(255, 255, 255, 0.5);
846+}
847+.seg-break {
848+ display: inline-block;
849+ width: 2px;
850+ height: 1em;
851+ vertical-align: middle;
852+ background: var(--amber);
853+ margin: 0 5px;
854+}
855+
856+/* ---- recorder ---- */
857+.recorder-grid {
858+ display: grid;
859+ grid-template-columns: minmax(0, 1fr) minmax(300px, 0.8fr);
860+ gap: 16px;
861+ padding: 16px;
862+ align-items: start;
863+}
864+@media (max-width: 1000px) {
865+ .recorder-grid {
866+ grid-template-columns: 1fr;
867+ }
868+}
869+.source {
870+ display: flex;
871+ flex-direction: column;
872+ gap: 6px;
873+ padding-bottom: 10px;
874+ border-bottom: 1px solid var(--border);
875+}
876+.source > .check {
877+ font-weight: 500;
878+}
879+.source-opts {
880+ display: flex;
881+ gap: 8px;
882+ flex-wrap: wrap;
883+ align-items: center;
884+ padding-left: 24px;
885+}
886+.source-opts select {
887+ max-width: 340px;
888+}
889+.meter {
890+ margin-left: 24px;
891+ height: 6px;
892+ background: var(--bg);
893+ border-radius: 3px;
894+ overflow: hidden;
895+ max-width: 340px;
896+}
897+.meter-bar {
898+ height: 100%;
899+ width: 0;
900+ background: var(--green);
901+ transition: width 0.05s linear;
902+}
903+.meter-bar.hot {
904+ background: var(--red);
905+}
906+.previews {
907+ display: grid;
908+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
909+ gap: 10px;
910+}
911+.preview {
912+ aspect-ratio: 16 / 9;
913+ background: #000;
914+ border-radius: 6px;
915+ overflow: hidden;
916+}
917+.preview video {
918+ width: 100%;
919+ height: 100%;
920+ object-fit: contain;
921+ display: block;
922+}
923+.preview-empty {
924+ height: 100%;
925+ display: flex;
926+ align-items: center;
927+ justify-content: center;
928+ color: var(--muted);
929+ font-size: 13px;
930+}
931+.rec-controls {
932+ display: flex;
933+ align-items: center;
934+ gap: 10px;
935+ flex-wrap: wrap;
936+ padding: 8px 0;
937+}
938+.rec-indicator {
939+ display: inline-flex;
940+ align-items: center;
941+ gap: 8px;
942+ font-weight: 600;
943+ margin-right: 8px;
944+}
945+.rec-dot {
946+ width: 12px;
947+ height: 12px;
948+ border-radius: 50%;
949+ background: var(--red);
950+ animation: blink 1.2s infinite;
951+}
952+.rec-indicator.paused .rec-dot {
953+ background: var(--amber);
954+ animation: none;
955+}
956+@keyframes blink {
957+ 50% {
958+ opacity: 0.3;
959+ }
960+}
961+.notes-panel {
962+ position: sticky;
963+ top: 70px;
964+ max-height: calc(100vh - 90px);
965+}
966+.notes-view {
967+ white-space: pre-wrap;
968+ font-size: 18px;
969+ line-height: 1.6;
970+ overflow: auto;
971+}
972+.countdown-overlay {
973+ position: fixed;
974+ inset: 0;
975+ background: rgba(0, 0, 0, 0.55);
976+ display: flex;
977+ align-items: center;
978+ justify-content: center;
979+ z-index: 200;
980+ pointer-events: none;
981+}
982+.countdown-num {
983+ font-size: 180px;
984+ font-weight: 700;
985+ color: white;
986+ text-shadow: 0 4px 30px rgba(0, 0, 0, 0.6);
987+}
988+
989+/* ---- prompter ---- */
990+.prompter {
991+ height: 100vh;
992+ display: flex;
993+ flex-direction: column;
994+}
995+.prompter-bar {
996+ display: flex;
997+ align-items: center;
998+ gap: 8px;
999+ padding: 10px 14px;
1000+ background: var(--panel);
1001+ border-bottom: 1px solid var(--border);
1002+}
1003+.prompter-tools {
1004+ display: flex;
1005+ align-items: center;
1006+ gap: 8px;
1007+ padding: 6px 14px;
1008+ border-bottom: 1px solid var(--border);
1009+}
1010+.prompter-body {
1011+ flex: 1;
1012+ overflow: auto;
1013+ padding: 24px 28px;
1014+ white-space: pre-wrap;
1015+ line-height: 1.5;
1016+}
1017+
1018+/* ---- settings ---- */
1019+.settings {
1020+ max-width: 760px;
1021+ width: 100%;
1022+ margin: 0 auto;
1023+ padding: 20px 16px;
1024+ display: flex;
1025+ flex-direction: column;
1026+ gap: 16px;
1027+}
1028+.settings p {
1029+ margin: 0;
1030+}
1031+.settings .row > span {
1032+ min-width: 240px;
1033+}
1034+
1035+.ok-text {
1036+ color: var(--green);
1037+}
1038+.matte-status {
1039+ display: flex;
1040+ flex-direction: column;
1041+ gap: 4px;
1042+}
1043+.matte-status p {
1044+ margin: 4px 0 0;
1045+}
1046+.progress.mini {
1047+ width: 120px;
1048+ height: 6px;
1049+}
src/vite-env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
tsconfig.jsonadded+18−0View file
@@ -0,0 +1,18 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5+ "module": "ESNext",
6+ "moduleResolution": "Bundler",
7+ "jsx": "react-jsx",
8+ "strict": true,
9+ "noEmit": true,
10+ "allowImportingTsExtensions": true,
11+ "verbatimModuleSyntax": true,
12+ "isolatedModules": true,
13+ "skipLibCheck": true,
14+ "noUnusedLocals": true,
15+ "types": []
16+ },
17+ "include": ["src", "shared"]
18+}
tsconfig.server.jsonadded+17−0View file
@@ -0,0 +1,17 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2023",
4+ "lib": ["ES2023"],
5+ "module": "NodeNext",
6+ "moduleResolution": "NodeNext",
7+ "types": ["node"],
8+ "strict": true,
9+ "noEmit": true,
10+ "allowImportingTsExtensions": true,
11+ "verbatimModuleSyntax": true,
12+ "erasableSyntaxOnly": true,
13+ "skipLibCheck": true,
14+ "noUnusedLocals": true
15+ },
16+ "include": ["server", "shared"]
17+}
vite.config.tsadded+14−0View file
@@ -0,0 +1,14 @@
1+import { defineConfig } from 'vite';
2+import react from '@vitejs/plugin-react';
3+
4+// In development the UI is served by Vite on :5173 and API/media requests go to the server on :4455.
5+export default defineConfig({
6+ plugins: [react()],
7+ server: {
8+ port: 5173,
9+ proxy: {
10+ '/api': 'http://localhost:4455',
11+ '/media': 'http://localhost:4455',
12+ },
13+ },
14+});