/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / server / index.ts
239 lines · 9.8 KBBlameHistoryRaw
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 { PublicConfig, Video } from '../shared/types.ts';
13import { getExportJob, startExport } from './export.ts';
14import { HttpError, checkId, createVideo, getConfig, getVideo, initStore, libraryDir, listVideos, setOrder, trashVideo, updateConfig, updateVideo, videoDir } from './store.ts';
15import { appendChunk, createTake, deleteTake, finalizeTake, setThumbnail } from './takes.ts';
16import { transcribeTake } from './transcribe.ts';
17import { requestMattes, withMatteStatus } from './matte.ts';
19function 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;
35const opts = parseArgs(process.argv.slice(2));
36const here = path.dirname(fileURLToPath(import.meta.url));
37const distDir = path.join(here, '..', 'dist');
39for (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 }
46await initStore(opts.dir);
48const app = express();
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).
52app.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});
59const json = express.json({ limit: '5mb' });
60const raw = express.raw({ type: () => true, limit: '500mb' });
62const p = (req: Request, name: string) => checkId(String(req.params[name]));
64app.get('/api/ping', (_req, res) => {
65 res.json({ app: 'takes' });
66});
68app.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});
74app.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 if (typeof b.matteHost === 'string') patch.matteHost = b.matteHost.trim();
84 if (typeof b.matteSetup === 'string') patch.matteSetup = b.matteSetup.trim();
85 await updateConfig(patch);
86 res.json({ ok: true });
87});
89app.get('/api/videos', async (_req, res) => {
90 res.json(await listVideos());
91});
93app.post('/api/videos', json, async (req, res) => {
94 res.json(await createVideo(req.body ?? {}));
95});
97app.put('/api/order', json, async (req, res) => {
98 if (!Array.isArray(req.body?.order)) throw new HttpError(400, 'order must be an array');
99 await setOrder(req.body.order);
100 res.json({ ok: true });
101});
103app.get('/api/videos/:id', async (req, res) => {
104 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
105});
107// Editable fields only; takes are managed through their own endpoints.
108app.patch('/api/videos/:id', json, async (req, res) => {
109 const b = req.body ?? {};
110 const v = await updateVideo(p(req, 'id'), (v: Video) => {
111 if (typeof b.title === 'string') v.title = b.title;
112 if (typeof b.description === 'string') v.description = b.description;
113 if (typeof b.notes === 'string') v.notes = b.notes;
114 if (b.layout) {
115 v.layout = { ...v.layout, ...b.layout };
116 const bg = v.layout.background;
117 if (bg && (!['none', 'blur', 'color', 'cutout'].includes(bg.mode) || !/^#[0-9a-fA-F]{6}$/.test(bg.color) || !(bg.blur >= 0 && bg.blur <= 100))) {
118 throw new HttpError(400, 'Invalid background settings');
119 }
120 }
121 if (b.output) v.output = { ...v.output, ...b.output };
122 if (Array.isArray(b.timeline)) {
123 const takes = new Map(v.takes.map((t) => [t.id, t]));
124 for (const s of b.timeline) {
125 const t = takes.get(s?.takeId);
126 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');
127 }
128 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 }));
129 }
130 });
131 res.json(v);
132});
134app.delete('/api/videos/:id', async (req, res) => {
135 await trashVideo(p(req, 'id'));
136 res.json({ ok: true });
137});
139app.post('/api/videos/:id/takes', json, async (req, res) => {
140 res.json(await createTake(p(req, 'id'), req.body ?? {}));
141});
143app.post('/api/videos/:id/takes/:takeId/chunk', raw, async (req, res) => {
144 await appendChunk(p(req, 'id'), p(req, 'takeId'), String(req.query.track), req.body as Buffer);
145 res.json({ ok: true });
146});
148app.post('/api/videos/:id/takes/:takeId/finalize', async (req, res) => {
149 res.json(withMatteStatus(await finalizeTake(p(req, 'id'), p(req, 'takeId'))));
150});
152app.delete('/api/videos/:id/takes/:takeId', async (req, res) => {
153 res.json(withMatteStatus(await deleteTake(p(req, 'id'), p(req, 'takeId'))));
154});
156app.post('/api/videos/:id/takes/:takeId/transcribe', async (req, res) => {
157 await transcribeTake(p(req, 'id'), p(req, 'takeId'));
158 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
159});
161// Queue background removal for the video's camera takes (all unprocessed ones, or one take to retry).
162app.post('/api/videos/:id/matte', json, async (req, res) => {
163 const takeId = typeof req.body?.takeId === 'string' ? checkId(req.body.takeId) : undefined;
164 await requestMattes(p(req, 'id'), { takeId, retry: !!takeId });
165 res.json(withMatteStatus(await getVideo(p(req, 'id'))));
166});
168app.post('/api/videos/:id/thumbnail', raw, async (req, res) => {
169 res.json(withMatteStatus(await setThumbnail(p(req, 'id'), req.body as Buffer)));
170});
172app.post('/api/videos/:id/export', async (req, res) => {
173 res.json(await startExport(p(req, 'id')));
174});
176app.get('/api/videos/:id/export', (req, res) => {
177 res.json(getExportJob(p(req, 'id')));
178});
180app.post('/api/videos/:id/open-folder', async (req, res) => {
181 const dir = videoDir(p(req, 'id'));
182 spawn('xdg-open', [dir], { detached: true, stdio: 'ignore' }).unref();
183 res.json({ ok: true, dir });
184});
186// Media files inside a video directory (supports range requests for seeking).
187app.get('/media/:id/*rest', (req, res, next) => {
188 const id = p(req, 'id');
189 const rest = (req.params as unknown as { rest: string[] }).rest.join('/');
190 res.sendFile(rest, { root: videoDir(id), dotfiles: 'deny', headers: { 'Cache-Control': 'no-cache' } }, (err) => {
191 if (err && !res.headersSent) next(new HttpError(404, 'Not found'));
192 });
193});
195if (fs.existsSync(path.join(distDir, 'index.html'))) {
196 app.use(express.static(distDir));
197 app.get('/{*any}', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
198} else {
199 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.'));
202app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
203 const status = err instanceof HttpError ? err.status : 500;
204 if (status === 500) console.error(err);
205 res.status(status).json({ error: err.message });
206});
208const url = `http://localhost:${opts.port}/`;
210function openWindow() {
211 if (!opts.open) return;
212 const chrome = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'].find((c) => !spawnSync('which', [c]).status);
213 const [cmd, args] = chrome && !opts.browser ? [chrome, [`--app=${url}`]] : ['xdg-open', [url]];
214 spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
217const server = app.listen(opts.port, '127.0.0.1', () => {
218 console.log(`Takes is running at ${url}`);
219 console.log(`Library: ${libraryDir()}`);
220 openWindow();
221});
223server.on('error', async (err: NodeJS.ErrnoException) => {
224 if (err.code === 'EADDRINUSE') {
225 try {
226 const r = await fetch(`${url}api/ping`);
227 if (((await r.json()) as { app?: string }).app === 'takes') {
228 console.log(`Takes is already running at ${url}`);
229 openWindow();
230 process.exit(0);
231 }
232 } catch {
233 // fall through
234 }
235 console.error(`Port ${opts.port} is in use. Try --port.`);
236 process.exit(1);
237 }
238 throw err;
239});
↑↓ move↵openescclose