/** * 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 } 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) /** Resolve a run id to its directory, rejecting traversal. */ function runDir(id: string): string | null { const safe = path.basename(id) if (safe !== id || !safe || safe.startsWith('.')) return null const dir = path.join(outputDir, safe) if (!dir.startsWith(outputDir + path.sep)) return null try { if (fs.statSync(dir).isDirectory()) return dir } catch { /* missing */ } return null } 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.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 { let entries: string[] = [] try { entries = (await fsp.readdir(outputDir, { withFileTypes: true })) .filter((e) => e.isDirectory()) .map((e) => e.name) } catch { sendJson(res, 200, { runs: [], outputDir }) return } const runs = [] for (const name of entries.sort().reverse()) { const dir = path.join(outputDir, name) const manifest = readManifest(dir) if (!manifest) continue 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, name: manifest.video?.name ?? name, processedAt: manifest.processed_at ?? null, frameCount, hasVideo: !!manifest.video?.path && fs.existsSync(manifest.video.path), }) } 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: !!manifest.video?.path && fs.existsSync(manifest.video.path), 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?.video?.path if (!videoPath || !fs.existsSync(videoPath)) { logger.warn(`[meetus-api] source video missing for run: ${dir}`) sendJson(res, 404, { error: 'source video not available at manifest path' }) 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 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) }) }