// Takes: local server for recording, organizing, and editing videos. // // Usage: takes [--dir ~/Videos/takes] [--port 4455] [--no-open] [--browser] import express from 'express'; import type { NextFunction, Request, Response } from 'express'; import { spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Crop, PublicConfig, TakeCrop, Video } from '../shared/types.ts'; import { MATTE_RESOLUTIONS, backgroundOf, isFullFrame, isOutputHeight } from '../shared/types.ts'; import { getExportJob, startExport } from './export.ts'; import { HttpError, checkId, createVideo, getConfig, getVideo, initStore, libraryDir, listVideos, setOrder, trashVideo, updateConfig, updateVideo, videoDir } from './store.ts'; import { appendChunk, backfillScreenSizes, createTake, deleteTake, finalizeTake, setThumbnail } from './takes.ts'; import { systemCaptureEvent } from './sysaudio.ts'; import { transcribeTake } from './transcribe.ts'; import { cancelMattes, requestMattes, withMatteStatus } from './matte.ts'; import { getMicGain, setMicGain } from './micgain.ts'; function parseArgs(argv: string[]) { const opts = { dir: path.join(os.homedir(), 'Videos', 'takes'), port: 4455, open: true, browser: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--dir') opts.dir = path.resolve(argv[++i].replace(/^~(?=$|\/)/, os.homedir())); else if (a === '--port') opts.port = parseInt(argv[++i]); else if (a === '--no-open') opts.open = false; else if (a === '--browser') opts.browser = true; else if (a === '-h' || a === '--help') { 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'); process.exit(0); } } return opts; } const opts = parseArgs(process.argv.slice(2)); const here = path.dirname(fileURLToPath(import.meta.url)); const distDir = path.join(here, '..', 'dist'); for (const tool of ['ffmpeg', 'ffprobe']) { if (spawnSync(tool, ['-version']).error) { console.error(`${tool} was not found on PATH; please install ffmpeg.`); process.exit(1); } } await initStore(opts.dir); const app = express(); // Only accept requests addressed to localhost (guards against DNS rebinding), and require a // custom header on mutating API calls so that other web pages cannot issue them (CSRF). app.use((req, res, next) => { const host = (req.headers.host ?? '').replace(/:\d+$/, ''); if (!['localhost', '127.0.0.1', '[::1]'].includes(host)) return res.status(403).send('Forbidden host'); if (req.path.startsWith('/api/') && req.method !== 'GET' && req.headers['x-takes'] !== '1') return res.status(403).json({ error: 'Missing X-Takes header' }); next(); }); const json = express.json({ limit: '5mb' }); const raw = express.raw({ type: () => true, limit: '500mb' }); const p = (req: Request, name: string) => checkId(String(req.params[name])); app.get('/api/ping', (_req, res) => { res.json({ app: 'takes' }); }); app.get('/api/config', async (_req, res) => { const { deepgramApiKey, ...rest } = await getConfig(); const pub: PublicConfig = { ...rest, hasDeepgramKey: !!deepgramApiKey, libraryDir: libraryDir() }; res.json(pub); }); app.put('/api/config', json, async (req, res) => { const b = req.body ?? {}; const patch: Record = {}; if (typeof b.deepgramApiKey === 'string') patch.deepgramApiKey = b.deepgramApiKey.trim(); if (typeof b.deepgramModel === 'string') patch.deepgramModel = b.deepgramModel.trim() || 'nova-3'; if (typeof b.autoTranscribe === 'boolean') patch.autoTranscribe = b.autoTranscribe; if (typeof b.defaultCountdown === 'number') patch.defaultCountdown = Math.max(0, Math.min(10, Math.round(b.defaultCountdown))); if (b.defaultCameraHeight === 720 || b.defaultCameraHeight === 1080) patch.defaultCameraHeight = b.defaultCameraHeight; if (isOutputHeight(b.defaultOutputHeight)) patch.defaultOutputHeight = b.defaultOutputHeight; if (typeof b.matteHost === 'string') patch.matteHost = b.matteHost.trim(); if (typeof b.matteSetup === 'string') patch.matteSetup = b.matteSetup.trim(); await updateConfig(patch); res.json({ ok: true }); }); // Input gain of the microphone with this browser label (see micgain.ts). app.get('/api/mic-gain', async (req, res) => { res.json(await getMicGain(String(req.query.label ?? ''))); }); app.put('/api/mic-gain', json, async (req, res) => { const b = req.body ?? {}; res.json(await setMicGain(String(b.label ?? ''), Number(b.volume))); }); app.get('/api/videos', async (_req, res) => { res.json(await listVideos()); }); app.post('/api/videos', json, async (req, res) => { res.json(await createVideo(req.body ?? {})); }); app.put('/api/order', json, async (req, res) => { if (!Array.isArray(req.body?.order)) throw new HttpError(400, 'order must be an array'); await setOrder(req.body.order); res.json({ ok: true }); }); app.get('/api/videos/:id', async (req, res) => { res.json(withMatteStatus(await backfillScreenSizes(p(req, 'id')))); }); // Editable fields only; takes are managed through their own endpoints. app.patch('/api/videos/:id', json, async (req, res) => { const b = req.body ?? {}; const v = await updateVideo(p(req, 'id'), (v: Video) => { if (typeof b.title === 'string') v.title = b.title; if (typeof b.description === 'string') v.description = b.description; if (typeof b.notes === 'string') v.notes = b.notes; if (b.layout) { v.layout = { ...v.layout, ...b.layout }; const bg = v.layout.background; if ( bg && (!['none', 'blur', 'color', 'cutout'].includes(bg.mode) || !/^#[0-9a-fA-F]{6}$/.test(bg.color) || !(bg.blur >= 0 && bg.blur <= 100) || (bg.resolution !== undefined && !MATTE_RESOLUTIONS.includes(bg.resolution))) ) { throw new HttpError(400, 'Invalid background settings'); } } if (b.output) { v.output = { ...v.output, ...b.output }; if (!isOutputHeight(v.output.height)) throw new HttpError(400, 'Invalid output resolution'); const sv = v.output.systemVolume; if (sv !== undefined && !(typeof sv === 'number' && sv >= 0 && sv <= 1)) throw new HttpError(400, 'Invalid computer audio level'); } if (b.crops !== undefined) v.crops = parseCrops(b.crops, new Set(v.takes.map((t) => t.id))); if (Array.isArray(b.timeline)) { const takes = new Map(v.takes.map((t) => [t.id, t])); for (const s of b.timeline) { const t = takes.get(s?.takeId); 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'); } 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 })); } }); // A running background removal job is restarted if the processing resolution changed. if (b.layout?.background && backgroundOf(v.layout).mode !== 'none') await requestMattes(v.id); res.json(v); }); // Crops by take id, each track's rectangle inside the unit square; full-frame crops are dropped. function parseCrops(raw: unknown, takeIds: Set): Record { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new HttpError(400, 'Invalid crops'); const out: Record = {}; for (const [id, tc] of Object.entries(raw as Record>)) { if (!takeIds.has(id) || !tc || typeof tc !== 'object') throw new HttpError(400, 'Invalid crops'); const kept: TakeCrop = {}; for (const track of ['screen', 'camera'] as const) { const c = tc[track]; if (!c) continue; const ok = [c.x, c.y, c.w, c.h].every((n) => typeof n === 'number' && Number.isFinite(n)); 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'); 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) }; } if (kept.screen || kept.camera) out[id] = kept; } return out; } app.delete('/api/videos/:id', async (req, res) => { await cancelMattes(p(req, 'id')); await trashVideo(p(req, 'id')); res.json({ ok: true }); }); app.post('/api/videos/:id/takes', json, async (req, res) => { res.json(await createTake(p(req, 'id'), req.body ?? {})); }); app.post('/api/videos/:id/takes/:takeId/chunk', raw, async (req, res) => { await appendChunk(p(req, 'id'), p(req, 'takeId'), String(req.query.track), req.body as Buffer); res.json({ ok: true }); }); // Recorder start/pause/resume times (for aligning computer audio), or a keep-alive with no type. app.post('/api/videos/:id/takes/:takeId/events', json, async (req, res) => { const b = req.body ?? {}; const ev = ['start', 'pause', 'resume'].includes(b.type) && Number.isFinite(b.at) ? { type: b.type, at: b.at } : null; await systemCaptureEvent(checkId(p(req, 'id')), checkId(p(req, 'takeId')), ev); res.json({ ok: true }); }); app.post('/api/videos/:id/takes/:takeId/finalize', async (req, res) => { res.json(withMatteStatus(await finalizeTake(p(req, 'id'), p(req, 'takeId')))); }); app.delete('/api/videos/:id/takes/:takeId', async (req, res) => { await cancelMattes(p(req, 'id'), p(req, 'takeId')); res.json(withMatteStatus(await deleteTake(p(req, 'id'), p(req, 'takeId')))); }); app.post('/api/videos/:id/takes/:takeId/transcribe', async (req, res) => { await transcribeTake(p(req, 'id'), p(req, 'takeId')); res.json(withMatteStatus(await getVideo(p(req, 'id')))); }); // Queue background removal for the video's camera takes (all unprocessed ones, or one take to // retry or redo), restarting a running job if the processing resolution has changed. app.post('/api/videos/:id/matte', json, async (req, res) => { const takeId = typeof req.body?.takeId === 'string' ? checkId(req.body.takeId) : undefined; await requestMattes(p(req, 'id'), { takeId }); res.json(withMatteStatus(await getVideo(p(req, 'id')))); }); // Cancel queued and running background removal for the video (or one take). app.post('/api/videos/:id/matte/cancel', json, async (req, res) => { const takeId = typeof req.body?.takeId === 'string' ? checkId(req.body.takeId) : undefined; await cancelMattes(p(req, 'id'), takeId); res.json(withMatteStatus(await getVideo(p(req, 'id')))); }); app.post('/api/videos/:id/thumbnail', raw, async (req, res) => { res.json(withMatteStatus(await setThumbnail(p(req, 'id'), req.body as Buffer))); }); app.post('/api/videos/:id/export', async (req, res) => { res.json(await startExport(p(req, 'id'))); }); app.get('/api/videos/:id/export', (req, res) => { res.json(getExportJob(p(req, 'id'))); }); app.post('/api/videos/:id/open-folder', async (req, res) => { const dir = videoDir(p(req, 'id')); spawn('xdg-open', [dir], { detached: true, stdio: 'ignore' }).unref(); res.json({ ok: true, dir }); }); // Media files inside a video directory (supports range requests for seeking). app.get('/media/:id/*rest', (req, res, next) => { const id = p(req, 'id'); const rest = (req.params as unknown as { rest: string[] }).rest.join('/'); res.sendFile(rest, { root: videoDir(id), dotfiles: 'deny', headers: { 'Cache-Control': 'no-cache' } }, (err) => { if (err && !res.headersSent) next(new HttpError(404, 'Not found')); }); }); if (fs.existsSync(path.join(distDir, 'index.html'))) { app.use(express.static(distDir)); app.get('/{*any}', (_req, res) => res.sendFile(path.join(distDir, 'index.html'))); } else { app.get('/', (_req, res) => res.send('UI not built. Run npm run build, or use npm run dev and open http://localhost:5173.')); } app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { const status = err instanceof HttpError ? err.status : 500; if (status === 500) console.error(err); res.status(status).json({ error: err.message }); }); const url = `http://localhost:${opts.port}/`; function openWindow() { if (!opts.open) return; const chrome = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'].find((c) => !spawnSync('which', [c]).status); const [cmd, args] = chrome && !opts.browser ? [chrome, [`--app=${url}`]] : ['xdg-open', [url]]; spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); } const server = app.listen(opts.port, '127.0.0.1', () => { console.log(`Takes is running at ${url}`); console.log(`Library: ${libraryDir()}`); openWindow(); }); server.on('error', async (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { try { const r = await fetch(`${url}api/ping`); if (((await r.json()) as { app?: string }).app === 'takes') { console.log(`Takes is already running at ${url}`); openWindow(); process.exit(0); } } catch { // fall through } console.error(`Port ${opts.port} is in use. Try --port.`); process.exit(1); } throw err; });