f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 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';
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 12import type { Crop, PublicConfig, TakeCrop, Video } from '../shared/types.ts';
13import { isFullFrame } from '../shared/types.ts';
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 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 { transcribeTake } from './transcribe.ts';
18import { requestMattes, withMatteStatus } from './matte.ts';
20function parseArgs(argv: string[]) {
21 const opts = { dir: path.join(os.homedir(), 'Videos', 'takes'), port: 4455, open: true, browser: false };
22 for (let i = 0; i < argv.length; i++) {
23 const a = argv[i];
24 if (a === '--dir') opts.dir = path.resolve(argv[++i].replace(/^~(?=$|\/)/, os.homedir()));
25 else if (a === '--port') opts.port = parseInt(argv[++i]);
26 else if (a === '--no-open') opts.open = false;
27 else if (a === '--browser') opts.browser = true;
28 else if (a === '-h' || a === '--help') {
29 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');
30 process.exit(0);
31 }
32 }
33 return opts;
34}
36const opts = parseArgs(process.argv.slice(2));
37const here = path.dirname(fileURLToPath(import.meta.url));
38const distDir = path.join(here, '..', 'dist');
40for (const tool of ['ffmpeg', 'ffprobe']) {
41 if (spawnSync(tool, ['-version']).error) {
42 console.error(`${tool} was not found on PATH; please install ffmpeg.`);
43 process.exit(1);
44 }
45}
47await initStore(opts.dir);
49const app = express();
51// Only accept requests addressed to localhost (guards against DNS rebinding), and require a
52// custom header on mutating API calls so that other web pages cannot issue them (CSRF).
53app.use((req, res, next) => {
54 const host = (req.headers.host ?? '').replace(/:\d+$/, '');
55 if (!['localhost', '127.0.0.1', '[::1]'].includes(host)) return res.status(403).send('Forbidden host');
56 if (req.path.startsWith('/api/') && req.method !== 'GET' && req.headers['x-takes'] !== '1') return res.status(403).json({ error: 'Missing X-Takes header' });
57 next();
58});
60const json = express.json({ limit: '5mb' });
61const raw = express.raw({ type: () => true, limit: '500mb' });
63const p = (req: Request, name: string) => checkId(String(req.params[name]));
65app.get('/api/ping', (_req, res) => {
66 res.json({ app: 'takes' });
67});
69app.get('/api/config', async (_req, res) => {
70 const { deepgramApiKey, ...rest } = await getConfig();
71 const pub: PublicConfig = { ...rest, hasDeepgramKey: !!deepgramApiKey, libraryDir: libraryDir() };
72 res.json(pub);
73});
75app.put('/api/config', json, async (req, res) => {
76 const b = req.body ?? {};
77 const patch: Record<string, unknown> = {};
78 if (typeof b.deepgramApiKey === 'string') patch.deepgramApiKey = b.deepgramApiKey.trim();
79 if (typeof b.deepgramModel === 'string') patch.deepgramModel = b.deepgramModel.trim() || 'nova-3';
80 if (typeof b.autoTranscribe === 'boolean') patch.autoTranscribe = b.autoTranscribe;
81 if (typeof b.defaultCountdown === 'number') patch.defaultCountdown = Math.max(0, Math.min(10, Math.round(b.defaultCountdown)));
82 if (b.defaultCameraHeight === 720 || b.defaultCameraHeight === 1080) patch.defaultCameraHeight = b.defaultCameraHeight;
83 if (b.defaultOutputHeight === 720 || b.defaultOutputHeight === 1080) patch.defaultOutputHeight = b.defaultOutputHeight;
307b4e2Run background removal on a remote GPU over sshJeremy Magland 84 if (typeof b.matteHost === 'string') patch.matteHost = b.matteHost.trim();
85 if (typeof b.matteSetup === 'string') patch.matteSetup = b.matteSetup.trim();
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 86 await updateConfig(patch);
87 res.json({ ok: true });
88});
90app.get('/api/videos', async (_req, res) => {
91 res.json(await listVideos());
92});
94app.post('/api/videos', json, async (req, res) => {
95 res.json(await createVideo(req.body ?? {}));
96});
98app.put('/api/order', json, async (req, res) => {
99 if (!Array.isArray(req.body?.order)) throw new HttpError(400, 'order must be an array');
100 await setOrder(req.body.order);
101 res.json({ ok: true });
102});
104app.get('/api/videos/:id', async (req, res) => {
105 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
106});
108// Editable fields only; takes are managed through their own endpoints.
109app.patch('/api/videos/:id', json, async (req, res) => {
110 const b = req.body ?? {};
111 const v = await updateVideo(p(req, 'id'), (v: Video) => {
112 if (typeof b.title === 'string') v.title = b.title;
113 if (typeof b.description === 'string') v.description = b.description;
114 if (typeof b.notes === 'string') v.notes = b.notes;
115 if (b.layout) {
116 v.layout = { ...v.layout, ...b.layout };
117 const bg = v.layout.background;
118 if (bg && (!['none', 'blur', 'color', 'cutout'].includes(bg.mode) || !/^#[0-9a-fA-F]{6}$/.test(bg.color) || !(bg.blur >= 0 && bg.blur <= 100))) {
119 throw new HttpError(400, 'Invalid background settings');
120 }
121 }
122 if (b.output) v.output = { ...v.output, ...b.output };
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 123 if (b.crops !== undefined) v.crops = parseCrops(b.crops, new Set(v.takes.map((t) => t.id)));
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 124 if (Array.isArray(b.timeline)) {
125 const takes = new Map(v.takes.map((t) => [t.id, t]));
126 for (const s of b.timeline) {
127 const t = takes.get(s?.takeId);
128 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');
129 }
130 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 }));
131 }
132 });
133 res.json(v);
134});
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 136// Crops by take id, each track's rectangle inside the unit square; full-frame crops are dropped.
137function parseCrops(raw: unknown, takeIds: Set<string>): Record<string, TakeCrop> {
138 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new HttpError(400, 'Invalid crops');
139 const out: Record<string, TakeCrop> = {};
140 for (const [id, tc] of Object.entries(raw as Record<string, Record<string, Crop | undefined>>)) {
141 if (!takeIds.has(id) || !tc || typeof tc !== 'object') throw new HttpError(400, 'Invalid crops');
142 const kept: TakeCrop = {};
143 for (const track of ['screen', 'camera'] as const) {
144 const c = tc[track];
145 if (!c) continue;
146 const ok = [c.x, c.y, c.w, c.h].every((n) => typeof n === 'number' && Number.isFinite(n));
147 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');
148 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) };
149 }
150 if (kept.screen || kept.camera) out[id] = kept;
151 }
152 return out;
153}
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 155app.delete('/api/videos/:id', async (req, res) => {
156 await trashVideo(p(req, 'id'));
157 res.json({ ok: true });
158});
160app.post('/api/videos/:id/takes', json, async (req, res) => {
161 res.json(await createTake(p(req, 'id'), req.body ?? {}));
162});
164app.post('/api/videos/:id/takes/:takeId/chunk', raw, async (req, res) => {
165 await appendChunk(p(req, 'id'), p(req, 'takeId'), String(req.query.track), req.body as Buffer);
166 res.json({ ok: true });
167});
169app.post('/api/videos/:id/takes/:takeId/finalize', async (req, res) => {
170 res.json(withMatteStatus(await finalizeTake(p(req, 'id'), p(req, 'takeId'))));
171});
173app.delete('/api/videos/:id/takes/:takeId', async (req, res) => {
174 res.json(withMatteStatus(await deleteTake(p(req, 'id'), p(req, 'takeId'))));
175});
177app.post('/api/videos/:id/takes/:takeId/transcribe', async (req, res) => {
178 await transcribeTake(p(req, 'id'), p(req, 'takeId'));
179 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
180});
182// Queue background removal for the video's camera takes (all unprocessed ones, or one take to retry).
183app.post('/api/videos/:id/matte', json, async (req, res) => {
184 const takeId = typeof req.body?.takeId === 'string' ? checkId(req.body.takeId) : undefined;
185 await requestMattes(p(req, 'id'), { takeId, retry: !!takeId });
186 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
187});
189app.post('/api/videos/:id/thumbnail', raw, async (req, res) => {
190 res.json(withMatteStatus(await setThumbnail(p(req, 'id'), req.body as Buffer)));
191});
193app.post('/api/videos/:id/export', async (req, res) => {
194 res.json(await startExport(p(req, 'id')));
195});
197app.get('/api/videos/:id/export', (req, res) => {
198 res.json(getExportJob(p(req, 'id')));
199});
201app.post('/api/videos/:id/open-folder', async (req, res) => {
202 const dir = videoDir(p(req, 'id'));
203 spawn('xdg-open', [dir], { detached: true, stdio: 'ignore' }).unref();
204 res.json({ ok: true, dir });
205});
207// Media files inside a video directory (supports range requests for seeking).
208app.get('/media/:id/*rest', (req, res, next) => {
209 const id = p(req, 'id');
210 const rest = (req.params as unknown as { rest: string[] }).rest.join('/');
211 res.sendFile(rest, { root: videoDir(id), dotfiles: 'deny', headers: { 'Cache-Control': 'no-cache' } }, (err) => {
212 if (err && !res.headersSent) next(new HttpError(404, 'Not found'));
213 });
214});
216if (fs.existsSync(path.join(distDir, 'index.html'))) {
217 app.use(express.static(distDir));
218 app.get('/{*any}', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
219} else {
220 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.'));
221}
223app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
224 const status = err instanceof HttpError ? err.status : 500;
225 if (status === 500) console.error(err);
226 res.status(status).json({ error: err.message });
227});
229const url = `http://localhost:${opts.port}/`;
231function openWindow() {
232 if (!opts.open) return;
233 const chrome = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'].find((c) => !spawnSync('which', [c]).status);
234 const [cmd, args] = chrome && !opts.browser ? [chrome, [`--app=${url}`]] : ['xdg-open', [url]];
235 spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
236}
238const server = app.listen(opts.port, '127.0.0.1', () => {
239 console.log(`Takes is running at ${url}`);
240 console.log(`Library: ${libraryDir()}`);
241 openWindow();
242});
244server.on('error', async (err: NodeJS.ErrnoException) => {
245 if (err.code === 'EADDRINUSE') {
246 try {
247 const r = await fetch(`${url}api/ping`);
248 if (((await r.json()) as { app?: string }).app === 'takes') {
249 console.log(`Takes is already running at ${url}`);
250 openWindow();
251 process.exit(0);
252 }
253 } catch {
254 // fall through
255 }
256 console.error(`Port ${opts.port} is in use. Try --port.`);
257 process.exit(1);
258 }
259 throw err;
260});