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