embed meetus

This commit is contained in:
Mariano Gabriel
2026-07-05 20:52:37 -03:00
parent 8606520ef2
commit a92615d6bc
6 changed files with 217 additions and 15 deletions

View File

@@ -106,9 +106,91 @@ export function doocusApi(opts: Options): Plugin {
return true
}
// GET /api/meeting?path=<rel> meetus review data for a meeting
// GET /api/meeting/frame?path=<rel>&file=<f> 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<void> {
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<string, number>()
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<void> {
const index = readIndex()
if (!index) { sendJson(res, 404, { error: 'no index' }); return }

View File

@@ -29,25 +29,38 @@ const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
<main class="body">
<div v-if="store.error" class="err">{{ store.error }}</div>
<!-- expanded: resizable tree / viewer split -->
<SplitPane v-else-if="!store.detailCollapsed" direction="vertical" :initial-size="0.9" :min="0.2" :max="4">
<!-- both expanded: resizable split -->
<SplitPane v-else-if="!store.treeCollapsed && !store.detailCollapsed"
direction="vertical" :initial-size="0.9" :min="0.2" :max="4">
<template #first>
<Panel title="Tree"><FileTree :items="store.tree" /></Panel>
<Panel title="Tree">
<template #actions><button class="chev" title="Collapse tree" @click="store.treeCollapsed = true"></button></template>
<FileTree :items="store.tree" />
</Panel>
</template>
<template #second>
<Panel :title="detailTitle">
<template #actions>
<button class="chev" title="Collapse viewer" @click="store.detailCollapsed = true"></button>
</template>
<template #actions><button class="chev" title="Collapse viewer" @click="store.detailCollapsed = true"></button></template>
<DocDetail />
</Panel>
</template>
</SplitPane>
<!-- collapsed: tree fills, viewer is a bar docked at the bottom -->
<!-- one or both collapsed: stack of panels + bars -->
<div v-else class="stack">
<Panel title="Tree" class="grow"><FileTree :items="store.tree" /></Panel>
<button class="expand-bar" title="Expand viewer" @click="store.detailCollapsed = false">
<button v-if="store.treeCollapsed" class="bar" title="Expand tree" @click="store.treeCollapsed = false">
<span>Tree · {{ store.counts.total }} files</span>
</button>
<Panel v-else title="Tree" class="grow">
<template #actions><button class="chev" title="Collapse tree" @click="store.treeCollapsed = true"></button></template>
<FileTree :items="store.tree" />
</Panel>
<Panel v-if="!store.detailCollapsed" :title="detailTitle" class="grow">
<template #actions><button class="chev" title="Collapse viewer" @click="store.detailCollapsed = true"></button></template>
<DocDetail />
</Panel>
<button v-else class="bar" title="Expand viewer" @click="store.detailCollapsed = false">
<span>{{ barLabel }}</span>
</button>
</div>
@@ -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; }
</style>

View File

@@ -1,10 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import { marked } from 'marked'
import SplitPane from '@framework/components/SplitPane.vue'
import NativeViewer from '@/components/NativeViewer.vue'
import { store, humanBytes } from '@/store'
// Meeting review is a large component (video + transcript + frames) — load it
// lazily only when a meeting is opened (Vue-native embed, no iframe).
const MeetingReview = defineAsyncComponent(() => import('@/components/MeetingReview.vue'))
const isMeeting = computed(() => store.detail?.node.mode === 'meeting')
const d = computed(() => store.detail)
const node = computed(() => d.value?.node)
@@ -53,7 +59,12 @@ const metaRows = computed(() => {
</template>
</SplitPane>
<!-- link / pptx / meeting: single native viewer -->
<!-- meeting: embed the meetus review (video + transcript + frames) -->
<div v-else-if="isMeeting" class="body single">
<MeetingReview :path="node.path" />
</div>
<!-- link / pptx: single native viewer -->
<div v-else class="body single">
<NativeViewer :node="node" :original-url="d!.originalUrl" :content="d?.content" :text="d?.text" />
</div>

View File

@@ -47,7 +47,7 @@ const MODE_BADGE: Record<string, string> = {
<style scoped>
.tree { list-style: none; margin: 0; padding: 0; }
.tree.root { overflow-y: auto; }
.tree.root { height: 100%; overflow-y: auto; overflow-x: hidden; }
.row {
display: flex;
align-items: center;

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import VideoPlayer from '@framework/components/VideoPlayer.vue'
import SplitPane from '@framework/components/SplitPane.vue'
const props = defineProps<{ path: string }>()
interface Segment { start: number; end: number; text: string; speaker: string | null }
interface Frame { file: string; url: string; time: number | null }
const loading = ref(false)
const data = ref<{
hasOutput: boolean; segments: Segment[]; frames: Frame[]
enhancedAvailable: boolean; videoUrl: string
} | null>(null)
const currentTime = ref(0)
const playing = ref(false)
async function load(): Promise<void> {
loading.value = true
data.value = null
try {
const res = await fetch(`/api/meeting?path=${encodeURIComponent(props.path)}`)
if (res.ok) data.value = await res.json()
} finally {
loading.value = false
}
}
watch(() => props.path, load, { immediate: true })
function seek(t: number | null): void {
if (t != null) currentTime.value = t
}
function fmt(t: number): string {
const m = Math.floor(t / 60), s = Math.floor(t % 60)
return `${m}:${String(s).padStart(2, '0')}`
}
const activeSeg = computed(() =>
data.value?.segments.findIndex((s) => currentTime.value >= s.start && currentTime.value < s.end) ?? -1)
</script>
<template>
<div class="mr">
<div v-if="loading" class="dim pad">Loading meeting</div>
<div v-else-if="!data?.hasOutput" class="dim pad">
No meetus output yet for this meeting it hasn't been transcribed (or the
<code>.meetus</code> folder isn't merged into this output yet).
<div class="sub">The video still plays below.</div>
<VideoPlayer v-if="data" :src="data.videoUrl" v-model:current-time="currentTime" v-model:playing="playing" />
</div>
<SplitPane v-else direction="horizontal" :initial-size="1.4" :min="0.4" :max="4">
<template #first>
<div class="left">
<VideoPlayer :src="data.videoUrl" v-model:current-time="currentTime" v-model:playing="playing" />
<div v-if="data.frames.length" class="frames">
<img v-for="f in data.frames" :key="f.file" :src="f.url" :title="f.time != null ? fmt(f.time) : f.file"
@click="seek(f.time)" />
</div>
</div>
</template>
<template #second>
<div class="transcript">
<div v-if="!data.segments.length" class="dim pad">No transcript segments.</div>
<p v-for="(s, i) in data.segments" :key="i" class="seg" :class="{ active: i === activeSeg }" @click="seek(s.start)">
<span class="t">{{ fmt(s.start) }}</span>
<span v-if="s.speaker" class="spk">{{ s.speaker }}</span>
<span class="txt">{{ s.text }}</span>
</p>
</div>
</template>
</SplitPane>
</div>
</template>
<style scoped>
.mr { height: 100%; }
.pad { padding: var(--space-4); }
.dim { color: var(--text-secondary); }
.sub { font-size: var(--font-size-sm); margin: var(--space-2) 0; }
.left { height: 100%; display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-2); min-height: 0; }
.frames { display: flex; gap: var(--space-1); overflow-x: auto; flex-shrink: 0; }
.frames img { height: 64px; border: var(--panel-border); border-radius: 3px; cursor: pointer; }
.frames img:hover { border-color: var(--text-secondary); }
.transcript { height: 100%; overflow-y: auto; padding: var(--space-2) var(--space-3); }
.seg { margin: 0 0 var(--space-1); padding: 2px 4px; border-radius: 3px; cursor: pointer; line-height: 1.5; }
.seg:hover { background: var(--surface-2); }
.seg.active { background: var(--surface-3); }
.seg .t { color: var(--text-secondary); font-size: var(--font-size-sm); margin-right: var(--space-2); }
.seg .spk { color: var(--status-live, #2a6); font-weight: 600; margin-right: var(--space-2); }
</style>

View File

@@ -62,6 +62,7 @@ interface State {
loading: boolean
selected: Set<string>
collapsed: Set<string> // 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<State>({
loading: false,
selected: new Set<string>(),
collapsed: new Set<string>(),
treeCollapsed: false,
detailCollapsed: false,
targetKey: TARGETS[0].key,
error: '',
@@ -134,6 +136,7 @@ export async function loadTree(): Promise<void> {
export async function select(path: string): Promise<void> {
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()