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, isOutputHeight } 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, backfillScreenSizes, 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 (isOutputHeight(b.defaultOutputHeight)) 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 backfillScreenSizes(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 if (!isOutputHeight(v.output.height)) throw new HttpError(400, 'Invalid output resolution');
143 const sv = v.output.systemVolume;
144 if (sv !== undefined && !(typeof sv === 'number' && sv >= 0 && sv <= 1)) throw new HttpError(400, 'Invalid computer audio level');
145 }
146 if (b.crops !== undefined) v.crops = parseCrops(b.crops, new Set(v.takes.map((t) => t.id)));
147 if (Array.isArray(b.timeline)) {
148 const takes = new Map(v.takes.map((t) => [t.id, t]));
149 for (const s of b.timeline) {
150 const t = takes.get(s?.takeId);
151 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');
152 }
153 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 }));
154 }
155 });
156 // A running background removal job is restarted if the processing resolution changed.
157 if (b.layout?.background && backgroundOf(v.layout).mode !== 'none') await requestMattes(v.id);
158 res.json(v);
159});
161// Crops by take id, each track's rectangle inside the unit square; full-frame crops are dropped.
162function parseCrops(raw: unknown, takeIds: Set<string>): Record<string, TakeCrop> {
163 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new HttpError(400, 'Invalid crops');
164 const out: Record<string, TakeCrop> = {};
165 for (const [id, tc] of Object.entries(raw as Record<string, Record<string, Crop | undefined>>)) {
166 if (!takeIds.has(id) || !tc || typeof tc !== 'object') throw new HttpError(400, 'Invalid crops');
167 const kept: TakeCrop = {};
168 for (const track of ['screen', 'camera'] as const) {
169 const c = tc[track];
170 if (!c) continue;
171 const ok = [c.x, c.y, c.w, c.h].every((n) => typeof n === 'number' && Number.isFinite(n));
172 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');
173 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) };
174 }
175 if (kept.screen || kept.camera) out[id] = kept;
176 }
177 return out;
178}
180app.delete('/api/videos/:id', async (req, res) => {
181 await cancelMattes(p(req, 'id'));
182 await trashVideo(p(req, 'id'));
183 res.json({ ok: true });
184});
186app.post('/api/videos/:id/takes', json, async (req, res) => {
187 res.json(await createTake(p(req, 'id'), req.body ?? {}));
188});
190app.post('/api/videos/:id/takes/:takeId/chunk', raw, async (req, res) => {
191 await appendChunk(p(req, 'id'), p(req, 'takeId'), String(req.query.track), req.body as Buffer);
192 res.json({ ok: true });
193});
195// Recorder start/pause/resume times (for aligning computer audio), or a keep-alive with no type.
196app.post('/api/videos/:id/takes/:takeId/events', json, async (req, res) => {
197 const b = req.body ?? {};
198 const ev = ['start', 'pause', 'resume'].includes(b.type) && Number.isFinite(b.at) ? { type: b.type, at: b.at } : null;
199 await systemCaptureEvent(checkId(p(req, 'id')), checkId(p(req, 'takeId')), ev);
200 res.json({ ok: true });
201});
203app.post('/api/videos/:id/takes/:takeId/finalize', async (req, res) => {
204 res.json(withMatteStatus(await finalizeTake(p(req, 'id'), p(req, 'takeId'))));
205});
207app.delete('/api/videos/:id/takes/:takeId', async (req, res) => {
208 await cancelMattes(p(req, 'id'), p(req, 'takeId'));
209 res.json(withMatteStatus(await deleteTake(p(req, 'id'), p(req, 'takeId'))));
210});
212app.post('/api/videos/:id/takes/:takeId/transcribe', async (req, res) => {
213 await transcribeTake(p(req, 'id'), p(req, 'takeId'));
214 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
215});
217// Queue background removal for the video's camera takes (all unprocessed ones, or one take to
218// retry or redo), restarting a running job if the processing resolution has changed.
219app.post('/api/videos/:id/matte', json, async (req, res) => {
220 const takeId = typeof req.body?.takeId === 'string' ? checkId(req.body.takeId) : undefined;
221 await requestMattes(p(req, 'id'), { takeId });
222 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
223});
225// Cancel queued and running background removal for the video (or one take).
226app.post('/api/videos/:id/matte/cancel', json, async (req, res) => {
227 const takeId = typeof req.body?.takeId === 'string' ? checkId(req.body.takeId) : undefined;
228 await cancelMattes(p(req, 'id'), takeId);
229 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
230});
232app.post('/api/videos/:id/thumbnail', raw, async (req, res) => {
233 res.json(withMatteStatus(await setThumbnail(p(req, 'id'), req.body as Buffer)));
234});
236app.post('/api/videos/:id/export', async (req, res) => {
237 res.json(await startExport(p(req, 'id')));
238});
240app.get('/api/videos/:id/export', (req, res) => {
241 res.json(getExportJob(p(req, 'id')));
242});
244app.post('/api/videos/:id/open-folder', async (req, res) => {
245 const dir = videoDir(p(req, 'id'));
246 spawn('xdg-open', [dir], { detached: true, stdio: 'ignore' }).unref();
247 res.json({ ok: true, dir });
248});
250// Media files inside a video directory (supports range requests for seeking).
251app.get('/media/:id/*rest', (req, res, next) => {
252 const id = p(req, 'id');
253 const rest = (req.params as unknown as { rest: string[] }).rest.join('/');
254 res.sendFile(rest, { root: videoDir(id), dotfiles: 'deny', headers: { 'Cache-Control': 'no-cache' } }, (err) => {
255 if (err && !res.headersSent) next(new HttpError(404, 'Not found'));
256 });
257});
259if (fs.existsSync(path.join(distDir, 'index.html'))) {
260 app.use(express.static(distDir));
261 app.get('/{*any}', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
262} else {
263 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.'));
264}
266app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
267 const status = err instanceof HttpError ? err.status : 500;
268 if (status === 500) console.error(err);
269 res.status(status).json({ error: err.message });
270});
272const url = `http://localhost:${opts.port}/`;
274function openWindow() {
275 if (!opts.open) return;
276 const chrome = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'].find((c) => !spawnSync('which', [c]).status);
277 const [cmd, args] = chrome && !opts.browser ? [chrome, [`--app=${url}`]] : ['xdg-open', [url]];
278 spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
279}
281const server = app.listen(opts.port, '127.0.0.1', () => {
282 console.log(`Takes is running at ${url}`);
283 console.log(`Library: ${libraryDir()}`);
284 openWindow();
285});
287server.on('error', async (err: NodeJS.ErrnoException) => {
288 if (err.code === 'EADDRINUSE') {
289 try {
290 const r = await fetch(`${url}api/ping`);
291 if (((await r.json()) as { app?: string }).app === 'takes') {
292 console.log(`Takes is already running at ${url}`);
293 openWindow();
294 process.exit(0);
295 }
296 } catch {
297 // fall through
298 }
299 console.error(`Port ${opts.port} is in use. Try --port.`);
300 process.exit(1);
301 }
302 throw err;
303});