From a92615d6bccbafcef287a4036273d3b85370c280 Mon Sep 17 00:00:00 2001 From: Mariano Gabriel Date: Sun, 5 Jul 2026 20:52:37 -0300 Subject: [PATCH] embed meetus --- ui/doocus-app/server/doocusApi.ts | 82 ++++++++++++++++ ui/doocus-app/src/App.vue | 37 +++++--- ui/doocus-app/src/components/DocDetail.vue | 15 ++- ui/doocus-app/src/components/FileTree.vue | 2 +- .../src/components/MeetingReview.vue | 93 +++++++++++++++++++ ui/doocus-app/src/store.ts | 3 + 6 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 ui/doocus-app/src/components/MeetingReview.vue diff --git a/ui/doocus-app/server/doocusApi.ts b/ui/doocus-app/server/doocusApi.ts index c182b20..72c8dc7 100644 --- a/ui/doocus-app/server/doocusApi.ts +++ b/ui/doocus-app/server/doocusApi.ts @@ -106,9 +106,91 @@ export function doocusApi(opts: Options): Plugin { return true } + // GET /api/meeting?path= meetus review data for a meeting + // GET /api/meeting/frame?path=&file= stream a frame from its .meetus/ + if (parts[0] === 'meeting' && parts[1] === 'frame') { + serveMeetingFrame(res, q.get('path') ?? '', q.get('file') ?? '') + return true + } + if (parts[0] === 'meeting') { + await getMeeting(res, q.get('path') ?? '') + return true + } + return false } + /** The .meetus sidecar dir + the video stem for a meeting node's rel path. */ + function meetusDirFor(rel: string): { dir: string; stem: string } { + return { dir: path.join(outputDir, rel + '.meetus'), stem: path.parse(rel).name } + } + + async function getMeeting(res: ServerResponse, rel: string): Promise { + const { dir, stem } = meetusDirFor(rel) + // Whisper segments (may be null-flagged in manifest but present on disk). + const segments: Array<{ start: number; end: number; text: string; speaker: string | null }> = [] + const whisper = path.join(dir, `${stem}.json`) + if (fs.existsSync(whisper)) { + try { + const data = JSON.parse(await fsp.readFile(whisper, 'utf-8')) + for (const s of (Array.isArray(data) ? data : data.segments ?? [])) { + segments.push({ + start: Number(s.start ?? 0), end: Number(s.end ?? s.start ?? 0), + text: String(s.text ?? '').trim(), speaker: s.speaker ?? null, + }) + } + } catch { /* none */ } + } + + // Frame → time from enhanced.txt ([MM:SS] before each `Frame: frames/…`). + const enhanced = path.join(dir, `${stem}_enhanced.txt`) + const times = new Map() + let enhancedAvailable = false + if (fs.existsSync(enhanced)) { + try { + enhancedAvailable = true + let last: number | null = null + for (const line of (await fsp.readFile(enhanced, 'utf-8')).split('\n')) { + const ts = line.match(/^\[(\d+):(\d+)\]/) + if (ts) last = Number(ts[1]) * 60 + Number(ts[2]) + const fm = line.match(/Frame:\s*\S*?([^/\\\s]+\.jpg)/i) + if (fm && last != null) times.set(fm[1], last) + } + } catch { /* none */ } + } + + const frames: Array<{ file: string; url: string; time: number | null }> = [] + try { + for (const file of (await fsp.readdir(path.join(dir, 'frames'))).filter((f) => f.toLowerCase().endsWith('.jpg')).sort()) { + const fny = file.match(/_(\d+(?:\.\d+)?)s\.jpg$/i) + frames.push({ + file, + url: `/api/meeting/frame?path=${encodeURIComponent(rel)}&file=${encodeURIComponent(file)}`, + time: fny ? Number(fny[1]) : times.get(file) ?? null, + }) + } + } catch { /* no frames */ } + + sendJson(res, 200, { + path: rel, + hasOutput: fs.existsSync(dir), + segments, + frames, + enhancedAvailable, + videoUrl: `/api/original?path=${encodeURIComponent(rel)}`, + }) + } + + function serveMeetingFrame(res: ServerResponse, rel: string, file: string): void { + const { dir } = meetusDirFor(rel) + const safe = path.basename(file) + const full = path.join(dir, 'frames', 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) + } + async function getDetail(res: ServerResponse, rel: string): Promise { const index = readIndex() if (!index) { sendJson(res, 404, { error: 'no index' }); return } diff --git a/ui/doocus-app/src/App.vue b/ui/doocus-app/src/App.vue index fbdb2c4..939fcb2 100644 --- a/ui/doocus-app/src/App.vue +++ b/ui/doocus-app/src/App.vue @@ -29,25 +29,38 @@ const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
{{ store.error }}
- - + + - +
- - + + + + + + + + + +
@@ -69,12 +82,12 @@ const barLabel = computed(() => store.detail?.node.name ?? 'viewer') .err { color: var(--status-error, #e0a030); padding: var(--space-4); } .stack { display: flex; flex-direction: column; height: 100%; gap: var(--space-2); } .grow { flex: 1; min-height: 0; } -.expand-bar { +.bar { flex-shrink: 0; display: flex; align-items: center; gap: var(--space-2); justify-content: flex-start; text-align: left; width: 100%; background: var(--surface-2); border: var(--panel-border); border-radius: var(--panel-radius); padding: var(--space-1) var(--space-3); color: var(--text-secondary); } -.expand-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.chev { padding: 0 var(--space-2); background: transparent; border: 0; color: var(--text-secondary); } +.bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chev { padding: 0 var(--space-2); background: transparent; border: 0; color: var(--text-secondary); cursor: pointer; } diff --git a/ui/doocus-app/src/components/DocDetail.vue b/ui/doocus-app/src/components/DocDetail.vue index 4345d4e..0932477 100644 --- a/ui/doocus-app/src/components/DocDetail.vue +++ b/ui/doocus-app/src/components/DocDetail.vue @@ -1,10 +1,16 @@ + + + + diff --git a/ui/doocus-app/src/store.ts b/ui/doocus-app/src/store.ts index 4bb1409..c2359f9 100644 --- a/ui/doocus-app/src/store.ts +++ b/ui/doocus-app/src/store.ts @@ -62,6 +62,7 @@ interface State { loading: boolean selected: Set collapsed: Set // collapsed folder paths (folders default open) + treeCollapsed: boolean // top tree pane collapsed to a bar detailCollapsed: boolean // bottom viewer collapsed to a bar targetKey: string error: string @@ -77,6 +78,7 @@ export const store = reactive({ loading: false, selected: new Set(), collapsed: new Set(), + treeCollapsed: false, detailCollapsed: false, targetKey: TARGETS[0].key, error: '', @@ -134,6 +136,7 @@ export async function loadTree(): Promise { export async function select(path: string): Promise { store.selectedPath = path + store.detailCollapsed = false // opening a file expands the viewer store.detail = null const res = await fetch(`/api/detail?path=${encodeURIComponent(path)}`) if (res.ok) store.detail = await res.json()