embed meetus
This commit is contained in:
@@ -106,9 +106,91 @@ export function doocusApi(opts: Options): Plugin {
|
|||||||
return true
|
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
|
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> {
|
async function getDetail(res: ServerResponse, rel: string): Promise<void> {
|
||||||
const index = readIndex()
|
const index = readIndex()
|
||||||
if (!index) { sendJson(res, 404, { error: 'no index' }); return }
|
if (!index) { sendJson(res, 404, { error: 'no index' }); return }
|
||||||
|
|||||||
@@ -29,25 +29,38 @@ const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
|
|||||||
<main class="body">
|
<main class="body">
|
||||||
<div v-if="store.error" class="err">{{ store.error }}</div>
|
<div v-if="store.error" class="err">{{ store.error }}</div>
|
||||||
|
|
||||||
<!-- expanded: resizable tree / viewer split -->
|
<!-- both expanded: resizable split -->
|
||||||
<SplitPane v-else-if="!store.detailCollapsed" direction="vertical" :initial-size="0.9" :min="0.2" :max="4">
|
<SplitPane v-else-if="!store.treeCollapsed && !store.detailCollapsed"
|
||||||
|
direction="vertical" :initial-size="0.9" :min="0.2" :max="4">
|
||||||
<template #first>
|
<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>
|
||||||
<template #second>
|
<template #second>
|
||||||
<Panel :title="detailTitle">
|
<Panel :title="detailTitle">
|
||||||
<template #actions>
|
<template #actions><button class="chev" title="Collapse viewer" @click="store.detailCollapsed = true">▾</button></template>
|
||||||
<button class="chev" title="Collapse viewer" @click="store.detailCollapsed = true">▾</button>
|
|
||||||
</template>
|
|
||||||
<DocDetail />
|
<DocDetail />
|
||||||
</Panel>
|
</Panel>
|
||||||
</template>
|
</template>
|
||||||
</SplitPane>
|
</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">
|
<div v-else class="stack">
|
||||||
<Panel title="Tree" class="grow"><FileTree :items="store.tree" /></Panel>
|
<button v-if="store.treeCollapsed" class="bar" title="Expand tree" @click="store.treeCollapsed = false">
|
||||||
<button class="expand-bar" title="Expand viewer" @click="store.detailCollapsed = 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>
|
▴ <span>{{ barLabel }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,12 +82,12 @@ const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
|
|||||||
.err { color: var(--status-error, #e0a030); padding: var(--space-4); }
|
.err { color: var(--status-error, #e0a030); padding: var(--space-4); }
|
||||||
.stack { display: flex; flex-direction: column; height: 100%; gap: var(--space-2); }
|
.stack { display: flex; flex-direction: column; height: 100%; gap: var(--space-2); }
|
||||||
.grow { flex: 1; min-height: 0; }
|
.grow { flex: 1; min-height: 0; }
|
||||||
.expand-bar {
|
.bar {
|
||||||
flex-shrink: 0; display: flex; align-items: center; gap: var(--space-2);
|
flex-shrink: 0; display: flex; align-items: center; gap: var(--space-2);
|
||||||
justify-content: flex-start; text-align: left; width: 100%;
|
justify-content: flex-start; text-align: left; width: 100%;
|
||||||
background: var(--surface-2); border: var(--panel-border); border-radius: var(--panel-radius);
|
background: var(--surface-2); border: var(--panel-border); border-radius: var(--panel-radius);
|
||||||
padding: var(--space-1) var(--space-3); color: var(--text-secondary);
|
padding: var(--space-1) var(--space-3); color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.expand-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.chev { padding: 0 var(--space-2); background: transparent; border: 0; color: var(--text-secondary); }
|
.chev { padding: 0 var(--space-2); background: transparent; border: 0; color: var(--text-secondary); cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, defineAsyncComponent } from 'vue'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import SplitPane from '@framework/components/SplitPane.vue'
|
import SplitPane from '@framework/components/SplitPane.vue'
|
||||||
import NativeViewer from '@/components/NativeViewer.vue'
|
import NativeViewer from '@/components/NativeViewer.vue'
|
||||||
import { store, humanBytes } from '@/store'
|
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 d = computed(() => store.detail)
|
||||||
const node = computed(() => d.value?.node)
|
const node = computed(() => d.value?.node)
|
||||||
|
|
||||||
@@ -53,7 +59,12 @@ const metaRows = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
</SplitPane>
|
</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">
|
<div v-else class="body single">
|
||||||
<NativeViewer :node="node" :original-url="d!.originalUrl" :content="d?.content" :text="d?.text" />
|
<NativeViewer :node="node" :original-url="d!.originalUrl" :content="d?.content" :text="d?.text" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const MODE_BADGE: Record<string, string> = {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tree { list-style: none; margin: 0; padding: 0; }
|
.tree { list-style: none; margin: 0; padding: 0; }
|
||||||
.tree.root { overflow-y: auto; }
|
.tree.root { height: 100%; overflow-y: auto; overflow-x: hidden; }
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
93
ui/doocus-app/src/components/MeetingReview.vue
Normal file
93
ui/doocus-app/src/components/MeetingReview.vue
Normal 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>
|
||||||
@@ -62,6 +62,7 @@ interface State {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
selected: Set<string>
|
selected: Set<string>
|
||||||
collapsed: Set<string> // collapsed folder paths (folders default open)
|
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
|
detailCollapsed: boolean // bottom viewer collapsed to a bar
|
||||||
targetKey: string
|
targetKey: string
|
||||||
error: string
|
error: string
|
||||||
@@ -77,6 +78,7 @@ export const store = reactive<State>({
|
|||||||
loading: false,
|
loading: false,
|
||||||
selected: new Set<string>(),
|
selected: new Set<string>(),
|
||||||
collapsed: new Set<string>(),
|
collapsed: new Set<string>(),
|
||||||
|
treeCollapsed: false,
|
||||||
detailCollapsed: false,
|
detailCollapsed: false,
|
||||||
targetKey: TARGETS[0].key,
|
targetKey: TARGETS[0].key,
|
||||||
error: '',
|
error: '',
|
||||||
@@ -134,6 +136,7 @@ export async function loadTree(): Promise<void> {
|
|||||||
|
|
||||||
export async function select(path: string): Promise<void> {
|
export async function select(path: string): Promise<void> {
|
||||||
store.selectedPath = path
|
store.selectedPath = path
|
||||||
|
store.detailCollapsed = false // opening a file expands the viewer
|
||||||
store.detail = null
|
store.detail = null
|
||||||
const res = await fetch(`/api/detail?path=${encodeURIComponent(path)}`)
|
const res = await fetch(`/api/detail?path=${encodeURIComponent(path)}`)
|
||||||
if (res.ok) store.detail = await res.json()
|
if (res.ok) store.detail = await res.json()
|
||||||
|
|||||||
Reference in New Issue
Block a user