Files
meetus/ui/app/server/meetusApi.ts
Mariano Gabriel 221ed53696 ui
2026-06-30 20:31:27 -03:00

341 lines
12 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
}
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)
/** 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<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> {
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<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: !!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<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 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)
})
}