1// Takes: local server for recording, organizing, and editing videos.
2//
3// Usage: takes [--dir ~/Videos/takes] [--port 4455] [--no-open] [--browser]
5import express from 'express';
6import type { NextFunction, Request, Response } from 'express';
7import { spawn, spawnSync } from 'node:child_process';
8import fs from 'node:fs';
9import os from 'node:os';
10import path from 'node:path';
11import { fileURLToPath } from 'node:url';
12import type { Crop, PublicConfig, TakeCrop, Video } from '../shared/types.ts';
13import { MATTE_RESOLUTIONS, backgroundOf, isFullFrame } from '../shared/types.ts';
14import { getExportJob, startExport } from './export.ts';
15import { HttpError, checkId, createVideo, getConfig, getVideo, initStore, libraryDir, listVideos, setOrder, trashVideo, updateConfig, updateVideo, videoDir } from './store.ts';
16import { appendChunk, createTake, deleteTake, finalizeTake, setThumbnail } from './takes.ts';
17import { systemCaptureEvent } from './sysaudio.ts';
18import { transcribeTake } from './transcribe.ts';
19import { cancelMattes, requestMattes, withMatteStatus } from './matte.ts';
20import { getMicGain, setMicGain } from './micgain.ts';
22function parseArgs(argv: string[]) {
23 const opts = { dir: path.join(os.homedir(), 'Videos', 'takes'), port: 4455, open: true, browser: false };
24 for (let i = 0; i < argv.length; i++) {
25 const a = argv[i];
26 if (a === '--dir') opts.dir = path.resolve(argv[++i].replace(/^~(?=$|\/)/, os.homedir()));
27 else if (a === '--port') opts.port = parseInt(argv[++i]);
28 else if (a === '--no-open') opts.open = false;
29 else if (a === '--browser') opts.browser = true;
30 else if (a === '-h' || a === '--help') {
31 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');
32 process.exit(0);
33 }
34 }
35 return opts;
36}
38const opts = parseArgs(process.argv.slice(2));
39const here = path.dirname(fileURLToPath(import.meta.url));
40const distDir = path.join(here, '..', 'dist');
42for (const tool of ['ffmpeg', 'ffprobe']) {
43 if (spawnSync(tool, ['-version']).error) {
44 console.error(`${tool} was not found on PATH; please install ffmpeg.`);
45 process.exit(1);
46 }
47}
49await initStore(opts.dir);
51const app = express();
53// Only accept requests addressed to localhost (guards against DNS rebinding), and require a
54// custom header on mutating API calls so that other web pages cannot issue them (CSRF).
55app.use((req, res, next) => {
56 const host = (req.headers.host ?? '').replace(/:\d+$/, '');
57 if (!['localhost', '127.0.0.1', '[::1]'].includes(host)) return res.status(403).send('Forbidden host');
58 if (req.path.startsWith('/api/') && req.method !== 'GET' && req.headers['x-takes'] !== '1') return res.status(403).json({ error: 'Missing X-Takes header' });
59 next();
60});
62const json = express.json({ limit: '5mb' });
63const raw = express.raw({ type: () => true, limit: '500mb' });
65const p = (req: Request, name: string) => checkId(String(req.params[name]));
67app.get('/api/ping', (_req, res) => {
68 res.json({ app: 'takes' });
69});
71app.get('/api/config', async (_req, res) => {
72 const { deepgramApiKey, ...rest } = await getConfig();
73 const pub: PublicConfig = { ...rest, hasDeepgramKey: !!deepgramApiKey, libraryDir: libraryDir() };
74 res.json(pub);
75});
77app.put('/api/config', json, async (req, res) => {
78 const b = req.body ?? {};
79 const patch: Record<string, unknown> = {};
80 if (typeof b.deepgramApiKey === 'string') patch.deepgramApiKey = b.deepgramApiKey.trim();
81 if (typeof b.deepgramModel === 'string') patch.deepgramModel = b.deepgramModel.trim() || 'nova-3';
82 if (typeof b.autoTranscribe === 'boolean') patch.autoTranscribe = b.autoTranscribe;
83 if (typeof b.defaultCountdown === 'number') patch.defaultCountdown = Math.max(0, Math.min(10, Math.round(b.defaultCountdown)));
84 if (b.defaultCameraHeight === 720 || b.defaultCameraHeight === 1080) patch.defaultCameraHeight = b.defaultCameraHeight;
85 if (b.defaultOutputHeight === 720 || b.defaultOutputHeight === 1080) patch.defaultOutputHeight = b.defaultOutputHeight;
86 if (typeof b.matteHost === 'string') patch.matteHost = b.matteHost.trim();
87 if (typeof b.matteSetup === 'string') patch.matteSetup = b.matteSetup.trim();
88 await updateConfig(patch);
89 res.json({ ok: true });
90});
92// Input gain of the microphone with this browser label (see micgain.ts).
93app.get('/api/mic-gain', async (req, res) => {
94 res.json(await getMicGain(String(req.query.label ?? '')));
95});
97app.put('/api/mic-gain', json, async (req, res) => {
98 const b = req.body ?? {};
99 res.json(await setMicGain(String(b.label ?? ''), Number(b.volume)));
100});
102app.get('/api/videos', async (_req, res) => {
103 res.json(await listVideos());
104});
106app.post('/api/videos', json, async (req, res) => {
107 res.json(await createVideo(req.body ?? {}));
108});
110app.put('/api/order', json, async (req, res) => {
111 if (!Array.isArray(req.body?.order)) throw new HttpError(400, 'order must be an array');
112 await setOrder(req.body.order);
113 res.json({ ok: true });
114});
116app.get('/api/videos/:id', async (req, res) => {
117 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
118});
120// Editable fields only; takes are managed through their own endpoints.
121app.patch('/api/videos/:id', json, async (req, res) => {
122 const b = req.body ?? {};
123 const v = await updateVideo(p(req, 'id'), (v: Video) => {
124 if (typeof b.title === 'string') v.title = b.title;
125 if (typeof b.description === 'string') v.description = b.description;
126 if (typeof b.notes === 'string') v.notes = b.notes;
127 if (b.layout) {
128 v.layout = { ...v.layout, ...b.layout };
129 const bg = v.layout.background;
130 if (
131 bg &&
132 (!['none', 'blur', 'color', 'cutout'].includes(bg.mode) ||
133 !/^#[0-9a-fA-F]{6}$/.test(bg.color) ||
134 !(bg.blur >= 0 && bg.blur <= 100) ||
135 (bg.resolution !== undefined && !MATTE_RESOLUTIONS.includes(bg.resolution)))
136 ) {
137 throw new HttpError(400, 'Invalid background settings');
138 }
139 }
140 if (b.output) {
141 v.output = { ...v.output, ...b.output };
142 const sv = v.output.systemVolume;
143 if (sv !== undefined && !(typeof sv === 'number' && sv >= 0 && sv <= 1)) throw new HttpError(400, 'Invalid computer audio level');
144 }
145 if (b.crops !== undefined) v.crops = parseCrops(b.crops, new Set(v.takes.map((t) => t.id)));
146 if (Array.isArray(b.timeline)) {
147 const takes = new Map(v.takes.map((t) => [t.id, t]));
148 for (const s of b.timeline) {
149 const t = takes.get(s?.takeId);
150 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');
151 }
152 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 }));
153 }
154 });
155 // A running background removal job is restarted if the processing resolution changed.
156 if (b.layout?.background && backgroundOf(v.layout).mode !== 'none') await requestMattes(v.id);
157 res.json(v);
158});
160// Crops by take id, each track's rectangle inside the unit square; full-frame crops are dropped.
161function parseCrops(raw: unknown, takeIds: Set<string>): Record<string, TakeCrop> {
162 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new HttpError(400, 'Invalid crops');
163 const out: Record<string, TakeCrop> = {};
164 for (const [id, tc] of Object.entries(raw as Record<string, Record<string, Crop | undefined>>)) {
165 if (!takeIds.has(id) || !tc || typeof tc !== 'object') throw new HttpError(400, 'Invalid crops');
166 const kept: TakeCrop = {};
167 for (const track of ['screen', 'camera'] as const) {
168 const c = tc[track];
169 if (!c) continue;
170 const ok = [c.x, c.y, c.w, c.h].every((n) => typeof n === 'number' && Number.isFinite(n));
171 if (!ok || c.x < 0 || c.y < 0 || c.w < 0.02 || c.h < 0.02 || c.x + c.w > 1.0001 || c.y + c.h > 1.0001) throw new HttpError(400, 'Invalid crop rectangle');
172 if (!isFullFrame(c)) kept[track] = { x: c.x, y: c.y, w: Math.min(c.w, 1 - c.x), h: Math.min(c.h, 1 - c.y) };
173 }
174 if (kept.screen || kept.camera) out[id] = kept;
175 }
176 return out;
177}
179app.delete('/api/videos/:id', async (req, res) => {
180 await cancelMattes(p(req, 'id'));
181 await trashVideo(p(req, 'id'));
182 res.json({ ok: true });
183});
185app.post('/api/videos/:id/takes', json, async (req, res) => {
186 res.json(await createTake(p(req, 'id'), req.body ?? {}));
187});
189app.post('/api/videos/:id/takes/:takeId/chunk', raw, async (req, res) => {
190 await appendChunk(p(req, 'id'), p(req, 'takeId'), String(req.query.track), req.body as Buffer);
191 res.json({ ok: true });
192});
194// Recorder start/pause/resume times (for aligning computer audio), or a keep-alive with no type.
195app.post('/api/videos/:id/takes/:takeId/events', json, async (req, res) => {
196 const b = req.body ?? {};
197 const ev = ['start', 'pause', 'resume'].includes(b.type) && Number.isFinite(b.at) ? { type: b.type, at: b.at } : null;
198 await systemCaptureEvent(checkId(p(req, 'id')), checkId(p(req, 'takeId')), ev);
199 res.json({ ok: true });
200});
202app.post('/api/videos/:id/takes/:takeId/finalize', async (req, res) => {
203 res.json(withMatteStatus(await finalizeTake(p(req, 'id'), p(req, 'takeId'))));
204});
206app.delete('/api/videos/:id/takes/:takeId', async (req, res) => {
207 await cancelMattes(p(req, 'id'), p(req, 'takeId'));
208 res.json(withMatteStatus(await deleteTake(p(req, 'id'), p(req, 'takeId'))));
209});
211app.post('/api/videos/:id/takes/:takeId/transcribe', async (req, res) => {
212 await transcribeTake(p(req, 'id'), p(req, 'takeId'));
213 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
214});
216// Queue background removal for the video's camera takes (all unprocessed ones, or one take to
217// retry or redo), restarting a running job if the processing resolution has changed.
218app.post('/api/videos/:id/matte', json, async (req, res) => {
219 const takeId = typeof req.body?.takeId === 'string' ? checkId(req.body.takeId) : undefined;
220 await requestMattes(p(req, 'id'), { takeId });
221 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
222});
224// Cancel queued and running background removal for the video (or one take).
225app.post('/api/videos/:id/matte/cancel', json, async (req, res) => {
226 const takeId = typeof req.body?.takeId === 'string' ? checkId(req.body.takeId) : undefined;
227 await cancelMattes(p(req, 'id'), takeId);
228 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
229});
231app.post('/api/videos/:id/thumbnail', raw, async (req, res) => {
232 res.json(withMatteStatus(await setThumbnail(p(req, 'id'), req.body as Buffer)));
233});
235app.post('/api/videos/:id/export', async (req, res) => {
236 res.json(await startExport(p(req, 'id')));
237});
239app.get('/api/videos/:id/export', (req, res) => {
240 res.json(getExportJob(p(req, 'id')));
241});
243app.post('/api/videos/:id/open-folder', async (req, res) => {
244 const dir = videoDir(p(req, 'id'));
245 spawn('xdg-open', [dir], { detached: true, stdio: 'ignore' }).unref();
246 res.json({ ok: true, dir });
247});
249// Media files inside a video directory (supports range requests for seeking).
250app.get('/media/:id/*rest', (req, res, next) => {
251 const id = p(req, 'id');
252 const rest = (req.params as unknown as { rest: string[] }).rest.join('/');
253 res.sendFile(rest, { root: videoDir(id), dotfiles: 'deny', headers: { 'Cache-Control': 'no-cache' } }, (err) => {
254 if (err && !res.headersSent) next(new HttpError(404, 'Not found'));
255 });
256});
258if (fs.existsSync(path.join(distDir, 'index.html'))) {
259 app.use(express.static(distDir));
260 app.get('/{*any}', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
261} else {
262 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.'));
263}
265app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
266 const status = err instanceof HttpError ? err.status : 500;
267 if (status === 500) console.error(err);
268 res.status(status).json({ error: err.message });
269});
271const url = `http://localhost:${opts.port}/`;
273function openWindow() {
274 if (!opts.open) return;
275 const chrome = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'].find((c) => !spawnSync('which', [c]).status);
276 const [cmd, args] = chrome && !opts.browser ? [chrome, [`--app=${url}`]] : ['xdg-open', [url]];
277 spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
278}
280const server = app.listen(opts.port, '127.0.0.1', () => {
281 console.log(`Takes is running at ${url}`);
282 console.log(`Library: ${libraryDir()}`);
283 openWindow();
284});
286server.on('error', async (err: NodeJS.ErrnoException) => {
287 if (err.code === 'EADDRINUSE') {
288 try {
289 const r = await fetch(`${url}api/ping`);
290 if (((await r.json()) as { app?: string }).app === 'takes') {
291 console.log(`Takes is already running at ${url}`);
292 openWindow();
293 process.exit(0);
294 }
295 } catch {
296 // fall through
297 }
298 console.error(`Port ${opts.port} is in use. Try --port.`);
299 process.exit(1);
300 }
301 throw err;
302});