ui
This commit is contained in:
340
ui/app/server/meetusApi.ts
Normal file
340
ui/app/server/meetusApi.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user