392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
/**
|
|
* 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<string, string> = {
|
|
'.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/<run>") 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<string[]> {
|
|
const found: string[] = []
|
|
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, number>()
|
|
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<void> {
|
|
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<string> {
|
|
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)
|
|
})
|
|
}
|