/** * Vite dev middleware exposing the local meetus output tree to the app. * * Runs in the dev server process (Node), so it can reach the source video by * the absolute path stored in each run's manifest.json — something a browser * cannot do. All reads are local; nothing leaves the machine. * * Routes: * GET /api/runs list runs * GET /api/runs/:id manifest + segments + frames + review * GET /api/runs/:id/frames/:file stream a frame JPEG * GET /api/runs/:id/video stream the source video (Range support) * PUT /api/runs/:id/review write sidecar review.json (atomic) */ import type { Plugin } from 'vite' import type { IncomingMessage, ServerResponse } from 'node:http' import fs from 'node:fs' import fsp from 'node:fs/promises' import path from 'node:path' interface Options { outputDir: string /** Optional directory to look for source videos by name when the absolute * path recorded in the manifest is not reachable on this machine. */ videoDir?: string } interface Manifest { video?: { name?: string; path?: string } processed_at?: string outputs?: { frames?: string enhanced_transcript?: string whisper_transcript?: string | null } } const VIDEO_MIME: Record = { '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', } export function meetusApi(opts: Options): Plugin { const outputDir = path.resolve(opts.outputDir) const videoDir = opts.videoDir ? path.resolve(opts.videoDir) : null /** Locate a run's source video. The manifest records an absolute path from * the machine that processed it, which may not exist here — so fall back to * finding the file by name in MEETUS_VIDEO_DIR, the run dir, its parent, and * the output root (videos are sometimes stored alongside the output). */ function resolveVideoPath(dir: string, manifest: Manifest): string | null { const recorded = manifest.video?.path if (recorded && isFile(recorded)) return recorded const name = manifest.video?.name if (name) { const candidates: string[] = [] if (videoDir) candidates.push(path.join(videoDir, name)) candidates.push( path.join(dir, name), // inside the run dir path.join(path.dirname(dir), name), // sibling of the run dir path.join(outputDir, name), // output root ) for (const c of candidates) if (isFile(c)) return c } return null } /** Resolve a run id (possibly nested, e.g. "batch/2026/team a/") to its * directory, rejecting traversal outside the output root. */ function runDir(id: string): string | null { const dir = path.resolve(outputDir, id) if (dir !== outputDir && !dir.startsWith(outputDir + path.sep)) return null try { if (fs.statSync(dir).isDirectory()) return dir } catch { /* missing */ } return null } const toPosix = (p: string) => p.split(path.sep).join('/') /** Find every run directory (one containing manifest.json) under `base`, * at any depth — batch.sh mirrors the input tree, so runs are nested. */ async function findRunDirs(base: string, maxDepth = 8): Promise { const found: string[] = [] const walk = async (dir: string, depth: number): Promise => { if (depth > maxDepth) return let ents try { ents = await fsp.readdir(dir, { withFileTypes: true }) } catch { return } if (ents.some((e) => e.isFile() && e.name === 'manifest.json')) { found.push(dir) return // a run dir; don't descend into it (frames/ etc.) } for (const e of ents) { if (!e.isDirectory()) continue if (e.name === 'frames' || e.name === 'node_modules' || e.name.startsWith('.')) continue await walk(path.join(dir, e.name), depth + 1) } } await walk(base, 0) return found } function readManifest(dir: string): Manifest | null { try { return JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf-8')) } catch { return null } } return { name: 'meetus-api', configureServer(server) { server.config.logger.info(`[meetus-api] serving meeting runs from: ${outputDir}`) if (videoDir) server.config.logger.info(`[meetus-api] video fallback dir: ${videoDir}`) server.middlewares.use('/api', (req, res, next) => { const url = new URL(req.url ?? '/', 'http://localhost') const parts = url.pathname.split('/').filter(Boolean) // after /api strip // parts: ['runs', ...] (the '/api' prefix is stripped by `use('/api', ...)`) handle(req, res, parts, server.config.logger).catch((err) => { server.config.logger.error(`[meetus-api] ${String(err)}`) sendJson(res, 500, { error: String(err) }) }).then((handled) => { if (!handled) next() }) }) }, } async function handle( req: IncomingMessage, res: ServerResponse, parts: string[], logger: { warn: (m: string) => void }, ): Promise { if (parts[0] !== 'runs') return false // GET /api/runs if (parts.length === 1 && req.method === 'GET') { await listRuns(res) return true } const id = parts[1] ? decodeURIComponent(parts[1]) : '' const dir = id ? runDir(id) : null if (!dir) { sendJson(res, 404, { error: 'run not found' }); return true } // GET /api/runs/:id if (parts.length === 2 && req.method === 'GET') { await getRun(res, id, dir) return true } // GET /api/runs/:id/frames/:file if (parts.length === 4 && parts[2] === 'frames' && req.method === 'GET') { serveFrame(req, res, dir, decodeURIComponent(parts[3])) return true } // GET /api/runs/:id/video if (parts.length === 3 && parts[2] === 'video' && req.method === 'GET') { serveVideo(req, res, dir, logger) return true } // PUT /api/runs/:id/review if (parts.length === 3 && parts[2] === 'review' && req.method === 'PUT') { await saveReview(req, res, dir) return true } sendJson(res, 404, { error: 'not found' }) return true } async function listRuns(res: ServerResponse): Promise { const dirs = await findRunDirs(outputDir) const runs = [] for (const dir of dirs) { const manifest = readManifest(dir) if (!manifest) continue const id = toPosix(path.relative(outputDir, dir)) const framesRel = manifest.outputs?.frames ?? 'frames' let frameCount = 0 try { frameCount = (await fsp.readdir(path.join(dir, framesRel))) .filter((f) => f.toLowerCase().endsWith('.jpg')).length } catch { /* no frames */ } runs.push({ id, name: manifest.video?.name ?? id, processedAt: manifest.processed_at ?? null, frameCount, hasVideo: !!resolveVideoPath(dir, manifest), }) } // Most recent first (by processed time, then id). runs.sort((a, b) => (b.processedAt ?? '').localeCompare(a.processedAt ?? '') || b.id.localeCompare(a.id)) sendJson(res, 200, { runs, outputDir }) } async function getRun(res: ServerResponse, id: string, dir: string): Promise { const manifest = readManifest(dir) if (!manifest) { sendJson(res, 404, { error: 'manifest missing' }); return } // Segments from the Whisper JSON. The manifest sometimes records // `whisper_transcript: null` even though `{stem}.json` exists on disk // (whisper run out-of-band), so fall back to probing for it by stem. const segments: Array<{ start: number; end: number; text: string; speaker: string | null }> = [] let whisperName = manifest.outputs?.whisper_transcript ?? null if (!whisperName) { const stem = manifest.video?.name ? path.parse(manifest.video.name).name : null if (stem && fs.existsSync(path.join(dir, `${stem}.json`))) whisperName = `${stem}.json` } if (whisperName && fs.existsSync(path.join(dir, whisperName))) { try { const data = JSON.parse(await fsp.readFile(path.join(dir, whisperName), 'utf-8')) const segs = Array.isArray(data) ? data : data.segments ?? [] for (const s of segs) { segments.push({ start: Number(s.start ?? s.timestamp ?? 0), end: Number(s.end ?? s.start ?? 0), text: String(s.text ?? '').trim(), speaker: s.speaker ?? null, }) } } catch { /* no/invalid transcript */ } } // Frame → time map from enhanced.txt (universal, MM:SS granularity). const framesRel = manifest.outputs?.frames ?? 'frames' const enhancedName = manifest.outputs?.enhanced_transcript const enhancedTimes = new Map() let enhancedAvailable = false if (enhancedName) { try { const text = await fsp.readFile(path.join(dir, enhancedName), 'utf-8') enhancedAvailable = true let lastTs: number | null = null for (const line of text.split('\n')) { const ts = line.match(/^\[(\d+):(\d+)\]/) if (ts) lastTs = Number(ts[1]) * 60 + Number(ts[2]) const fm = line.match(/Frame:\s*\S*?([^/\\\s]+\.jpg)/i) if (fm && lastTs != null) enhancedTimes.set(fm[1], lastTs) } } catch { /* none */ } } // Frame list with resolved times and byte sizes. const frames: Array<{ file: string; url: string; time: number | null; size: number }> = [] try { const framesAbs = path.join(dir, framesRel) const files = (await fsp.readdir(framesAbs)) .filter((f) => f.toLowerCase().endsWith('.jpg')) .sort() for (const file of files) { // Interval-mode filenames encode an exact float: `..._123.45s.jpg`. const fny = file.match(/_(\d+(?:\.\d+)?)s\.jpg$/i) const time = fny ? Number(fny[1]) : enhancedTimes.has(file) ? enhancedTimes.get(file)! : null let size = 0 try { size = (await fsp.stat(path.join(framesAbs, file))).size } catch { /* ignore */ } frames.push({ file, url: `/api/runs/${encodeURIComponent(id)}/frames/${encodeURIComponent(file)}`, time, size, }) } } catch { /* no frames dir */ } // Existing review sidecar, if any. let review: unknown = null try { review = JSON.parse(await fsp.readFile(path.join(dir, 'review.json'), 'utf-8')) } catch { /* none */ } sendJson(res, 200, { id, manifest, segments, frames, enhancedAvailable, hasVideo: !!resolveVideoPath(dir, manifest), videoUrl: `/api/runs/${encodeURIComponent(id)}/video`, review, }) } function serveFrame(req: IncomingMessage, res: ServerResponse, dir: string, file: string): void { const manifest = readManifest(dir) const framesRel = manifest?.outputs?.frames ?? 'frames' const safe = path.basename(file) const full = path.join(dir, framesRel, safe) if (safe !== file || !fs.existsSync(full)) { sendJson(res, 404, { error: 'frame not found' }); return } res.setHeader('Content-Type', 'image/jpeg') res.setHeader('Cache-Control', 'no-cache') fs.createReadStream(full).pipe(res) } function serveVideo( req: IncomingMessage, res: ServerResponse, dir: string, logger: { warn: (m: string) => void }, ): void { const manifest = readManifest(dir) const videoPath = manifest ? resolveVideoPath(dir, manifest) : null if (!videoPath) { logger.warn(`[meetus-api] source video not found for run: ${dir} (recorded: ${manifest?.video?.path ?? '?'})`) sendJson(res, 404, { error: 'source video not found (recorded path unreachable and no match by name)' }) return } const stat = fs.statSync(videoPath) const mime = VIDEO_MIME[path.extname(videoPath).toLowerCase()] ?? 'application/octet-stream' res.setHeader('Accept-Ranges', 'bytes') res.setHeader('Content-Type', mime) const range = req.headers.range if (range) { const m = range.match(/bytes=(\d*)-(\d*)/) if (m) { const start = m[1] ? parseInt(m[1], 10) : 0 const end = m[2] ? parseInt(m[2], 10) : stat.size - 1 if (start >= stat.size || end >= stat.size || start > end) { res.statusCode = 416 res.setHeader('Content-Range', `bytes */${stat.size}`) res.end() return } res.statusCode = 206 res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`) res.setHeader('Content-Length', String(end - start + 1)) fs.createReadStream(videoPath, { start, end }).pipe(res) return } } res.statusCode = 200 res.setHeader('Content-Length', String(stat.size)) fs.createReadStream(videoPath).pipe(res) } async function saveReview(req: IncomingMessage, res: ServerResponse, dir: string): Promise { const body = await readBody(req) let parsed: unknown try { parsed = JSON.parse(body || '{}') } catch { sendJson(res, 400, { error: 'invalid JSON' }) return } if (typeof parsed !== 'object' || parsed === null) { sendJson(res, 400, { error: 'review must be an object' }) return } const target = path.join(dir, 'review.json') const tmp = path.join(dir, `.review.${process.pid}.tmp`) await fsp.writeFile(tmp, JSON.stringify(parsed, null, 2), 'utf-8') await fsp.rename(tmp, target) // atomic; never touches source artifacts sendJson(res, 200, { ok: true }) } } function sendJson(res: ServerResponse, status: number, obj: unknown): void { const body = JSON.stringify(obj) res.statusCode = status res.setHeader('Content-Type', 'application/json') res.setHeader('Content-Length', Buffer.byteLength(body)) res.end(body) } function isFile(p: string): boolean { try { return fs.statSync(p).isFile() } catch { return false } } function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let data = '' req.on('data', (c) => { data += c; if (data.length > 5_000_000) reject(new Error('body too large')) }) req.on('end', () => resolve(data)) req.on('error', reject) }) }