embedded meetus
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
* GET /api/original?path=<rel> stream the original file (native viewer / packaging)
|
||||
*/
|
||||
import type { Plugin } from 'vite'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
@@ -80,7 +80,7 @@ export function doocusApi(opts: Options): Plugin {
|
||||
server.middlewares.use('/api', (req, res, next) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost')
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
handle(res, parts, url.searchParams, req.headers.range).catch((err) => {
|
||||
handle(req, res, parts, url.searchParams).catch((err) => {
|
||||
server.config.logger.error(`[doocus-api] ${String(err)}`)
|
||||
sendJson(res, 500, { error: String(err) })
|
||||
}).then((handled) => { if (!handled) next() })
|
||||
@@ -88,7 +88,8 @@ export function doocusApi(opts: Options): Plugin {
|
||||
},
|
||||
}
|
||||
|
||||
async function handle(res: ServerResponse, parts: string[], q: URLSearchParams, range?: string): Promise<boolean> {
|
||||
async function handle(req: IncomingMessage, res: ServerResponse, parts: string[], q: URLSearchParams): Promise<boolean> {
|
||||
const range = req.headers.range
|
||||
if (parts[0] === 'tree') {
|
||||
const index = readIndex()
|
||||
if (!index) { sendJson(res, 404, { error: `index.json not found in ${outputDir}` }); return true }
|
||||
@@ -117,6 +118,14 @@ export function doocusApi(opts: Options): Plugin {
|
||||
return true
|
||||
}
|
||||
|
||||
// meetus-shaped run API (id = meeting rel path), so the composed meetus
|
||||
// review store works unchanged when embedded in doocus.
|
||||
if (parts[0] === 'runs' && parts.length >= 2) {
|
||||
const id = decodeURIComponent(parts[1])
|
||||
if (parts.length === 2 && req.method === 'GET') { await getRunShaped(res, id); return true }
|
||||
if (parts.length === 3 && parts[2] === 'review' && req.method === 'PUT') { await saveRunReview(req, res, id); return true }
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -125,10 +134,16 @@ export function doocusApi(opts: Options): Plugin {
|
||||
return { dir: path.join(outputDir, rel + '.meetus'), stem: path.parse(rel).name }
|
||||
}
|
||||
|
||||
async function getMeeting(res: ServerResponse, rel: string): Promise<void> {
|
||||
interface MeetingData {
|
||||
segments: Array<{ start: number; end: number; text: string; speaker: string | null }>
|
||||
frames: Array<{ file: string; url: string; time: number | null; size: number }>
|
||||
enhancedAvailable: boolean
|
||||
}
|
||||
|
||||
/** Read a meeting's .meetus output into the shared review shape. */
|
||||
async function meetingData(rel: string): Promise<MeetingData> {
|
||||
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 segments: MeetingData['segments'] = []
|
||||
const whisper = path.join(dir, `${stem}.json`)
|
||||
if (fs.existsSync(whisper)) {
|
||||
try {
|
||||
@@ -159,28 +174,66 @@ export function doocusApi(opts: Options): Plugin {
|
||||
} catch { /* none */ }
|
||||
}
|
||||
|
||||
const frames: Array<{ file: string; url: string; time: number | null }> = []
|
||||
const frames: MeetingData['frames'] = []
|
||||
try {
|
||||
for (const file of (await fsp.readdir(path.join(dir, 'frames'))).filter((f) => f.toLowerCase().endsWith('.jpg')).sort()) {
|
||||
const framesDir = path.join(dir, 'frames')
|
||||
for (const file of (await fsp.readdir(framesDir)).filter((f) => f.toLowerCase().endsWith('.jpg')).sort()) {
|
||||
const fny = file.match(/_(\d+(?:\.\d+)?)s\.jpg$/i)
|
||||
let size = 0
|
||||
try { size = (await fsp.stat(path.join(framesDir, file))).size } catch { /* */ }
|
||||
frames.push({
|
||||
file,
|
||||
url: `/api/meeting/frame?path=${encodeURIComponent(rel)}&file=${encodeURIComponent(file)}`,
|
||||
time: fny ? Number(fny[1]) : times.get(file) ?? null,
|
||||
size,
|
||||
})
|
||||
}
|
||||
} catch { /* no frames */ }
|
||||
|
||||
return { segments, frames, enhancedAvailable }
|
||||
}
|
||||
|
||||
async function getMeeting(res: ServerResponse, rel: string): Promise<void> {
|
||||
const { dir } = meetusDirFor(rel)
|
||||
const data = await meetingData(rel)
|
||||
sendJson(res, 200, {
|
||||
path: rel,
|
||||
hasOutput: fs.existsSync(dir),
|
||||
segments,
|
||||
frames,
|
||||
enhancedAvailable,
|
||||
...data,
|
||||
videoUrl: `/api/original?path=${encodeURIComponent(rel)}`,
|
||||
})
|
||||
}
|
||||
|
||||
/** meetus store's /api/runs/:id shape (id = meeting rel path). */
|
||||
async function getRunShaped(res: ServerResponse, id: string): Promise<void> {
|
||||
const { dir } = meetusDirFor(id)
|
||||
const data = await meetingData(id)
|
||||
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: { video: { name: path.basename(id) } },
|
||||
segments: data.segments,
|
||||
frames: data.frames,
|
||||
enhancedAvailable: data.enhancedAvailable,
|
||||
hasVideo: true,
|
||||
videoUrl: `/api/original?path=${encodeURIComponent(id)}`,
|
||||
review,
|
||||
})
|
||||
}
|
||||
|
||||
/** Persist the review sidecar next to the meeting's .meetus output (atomic). */
|
||||
async function saveRunReview(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
||||
const { dir } = meetusDirFor(id)
|
||||
if (!fs.existsSync(dir)) { sendJson(res, 404, { error: 'no meetus output for this meeting' }); return }
|
||||
let parsed: unknown
|
||||
try { parsed = JSON.parse((await readBody(req)) || '{}') } catch { sendJson(res, 400, { error: 'invalid JSON' }); return }
|
||||
const tmp = path.join(dir, `.review.${process.pid}.tmp`)
|
||||
await fsp.writeFile(tmp, JSON.stringify(parsed, null, 2), 'utf-8')
|
||||
await fsp.rename(tmp, path.join(dir, 'review.json'))
|
||||
sendJson(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
function serveMeetingFrame(res: ServerResponse, rel: string, file: string): void {
|
||||
const { dir } = meetusDirFor(rel)
|
||||
const safe = path.basename(file)
|
||||
@@ -262,3 +315,12 @@ function sendJson(res: ServerResponse, status: number, obj: unknown): void {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,93 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import VideoPlayer from '@framework/components/VideoPlayer.vue'
|
||||
import SplitPane from '@framework/components/SplitPane.vue'
|
||||
/**
|
||||
* Embeds the meetus review for a specific meeting — pure composition of meetus's
|
||||
* own ReviewBody (single source, via the @meetus alias). doocus serves a
|
||||
* meetus-shaped /api/runs/:id from the meeting's .meetus/ output, so the meetus
|
||||
* store + components run unchanged here. Read-only (allowEdit=false).
|
||||
*/
|
||||
import { watch } from 'vue'
|
||||
import ReviewBody from '@meetus/components/ReviewBody.vue'
|
||||
import { useStore } from '@meetus/store'
|
||||
|
||||
const props = defineProps<{ path: string }>()
|
||||
const { openRun, state } = useStore()
|
||||
|
||||
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)
|
||||
// The meeting's rel path IS the run id; openRun hits doocus's /api/runs/:id.
|
||||
watch(() => props.path, (p) => { if (p) openRun(p) }, { immediate: true })
|
||||
</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 class="meeting-review">
|
||||
<div v-if="state.error" class="note">{{ state.error }}</div>
|
||||
<div v-else-if="!state.currentRunId" class="note">Loading meeting…</div>
|
||||
<ReviewBody v-else :allow-edit="false" :embedded="true" />
|
||||
</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); }
|
||||
.meeting-review { height: 100%; }
|
||||
.note { padding: var(--space-4); color: var(--text-secondary); }
|
||||
</style>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@framework/*": ["../framework/src/*"],
|
||||
"@meetus/*": ["../meetus-app/src/*"],
|
||||
"@/*": ["src/*"],
|
||||
"vue": ["node_modules/vue"]
|
||||
}
|
||||
|
||||
@@ -13,15 +13,18 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@framework': fileURLToPath(new URL('../framework/src', import.meta.url)),
|
||||
// Compose meetus's own review components (single source; portable imports).
|
||||
'@meetus': fileURLToPath(new URL('../meetus-app/src', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
// Allow the dev server to read the framework source and the docs-output tree.
|
||||
// Allow the dev server to read the framework + meetus-app source and the output tree.
|
||||
allow: [
|
||||
fileURLToPath(new URL('.', import.meta.url)),
|
||||
fileURLToPath(new URL('../framework', import.meta.url)),
|
||||
fileURLToPath(new URL('../meetus-app', import.meta.url)),
|
||||
outputDir,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import SplitPane from '@framework/components/SplitPane.vue'
|
||||
import Panel from '@framework/components/Panel.vue'
|
||||
import VideoPlayer from '@framework/components/VideoPlayer.vue'
|
||||
import RunPicker from '@/components/RunPicker.vue'
|
||||
import TranscriptEditor from '@/components/TranscriptEditor.vue'
|
||||
import FrameSelector from '@/components/FrameSelector.vue'
|
||||
import ExportBar from '@/components/ExportBar.vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
const { state } = useStore()
|
||||
import ReviewBody from '@/components/ReviewBody.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,34 +13,7 @@ const { state } = useStore()
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<SplitPane direction="horizontal" :initialSize="1.4" sizeMode="ratio" :min="0.4" :max="4">
|
||||
<template #first>
|
||||
<SplitPane direction="vertical" :initialSize="1.3" sizeMode="ratio" :min="0.3" :max="4">
|
||||
<template #first>
|
||||
<Panel title="Video" :status="state.playing ? 'live' : 'idle'">
|
||||
<VideoPlayer
|
||||
v-if="state.videoUrl"
|
||||
:src="state.videoUrl"
|
||||
v-model:currentTime="state.currentTime"
|
||||
v-model:playing="state.playing"
|
||||
@duration="state.duration = $event"
|
||||
/>
|
||||
<div v-else class="placeholder">
|
||||
{{ state.currentRunId
|
||||
? 'Source video not available at the path recorded in manifest.json.'
|
||||
: 'Select a meeting to begin.' }}
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
<template #second>
|
||||
<FrameSelector />
|
||||
</template>
|
||||
</SplitPane>
|
||||
</template>
|
||||
<template #second>
|
||||
<TranscriptEditor />
|
||||
</template>
|
||||
</SplitPane>
|
||||
<ReviewBody :allow-edit="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import Panel from '@framework/components/Panel.vue'
|
||||
import { useStore } from '@/store'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const { state, activeFrameIndex, selectedFrames, seek, setSelectAll } = useStore()
|
||||
|
||||
|
||||
56
ui/meetus-app/src/components/ReviewBody.vue
Normal file
56
ui/meetus-app/src/components/ReviewBody.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The meetus review — pure composition of video + frames + transcript over the
|
||||
* shared store. Uses only relative + @framework imports so it is portable: the
|
||||
* doocus app embeds this same component (pointed at its own meetus-shaped API).
|
||||
* Config is by prop; all logic lives in the store and child components.
|
||||
*/
|
||||
import SplitPane from '@framework/components/SplitPane.vue'
|
||||
import Panel from '@framework/components/Panel.vue'
|
||||
import VideoPlayer from '@framework/components/VideoPlayer.vue'
|
||||
import TranscriptEditor from './TranscriptEditor.vue'
|
||||
import FrameSelector from './FrameSelector.vue'
|
||||
import { useStore } from '../store'
|
||||
|
||||
withDefaults(defineProps<{ allowEdit?: boolean; embedded?: boolean }>(), {
|
||||
allowEdit: true, embedded: false,
|
||||
})
|
||||
const { state } = useStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="review-body">
|
||||
<SplitPane direction="horizontal" :initialSize="1.4" sizeMode="ratio" :min="0.4" :max="4">
|
||||
<template #first>
|
||||
<SplitPane direction="vertical" :initialSize="1.3" sizeMode="ratio" :min="0.3" :max="4">
|
||||
<template #first>
|
||||
<Panel title="Video" :status="state.playing ? 'live' : 'idle'">
|
||||
<VideoPlayer
|
||||
v-if="state.videoUrl"
|
||||
:src="state.videoUrl"
|
||||
v-model:currentTime="state.currentTime"
|
||||
v-model:playing="state.playing"
|
||||
@duration="state.duration = $event"
|
||||
/>
|
||||
<div v-else class="placeholder">
|
||||
{{ state.currentRunId
|
||||
? 'Source video not available at the path recorded in manifest.json.'
|
||||
: 'Select a meeting to begin.' }}
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
<template #second><FrameSelector /></template>
|
||||
</SplitPane>
|
||||
</template>
|
||||
<template #second><TranscriptEditor :allow-edit="allowEdit" /></template>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review-body { height: 100%; }
|
||||
.placeholder {
|
||||
display: flex; align-items: center; justify-content: center; height: 100%;
|
||||
padding: var(--space-6); text-align: center; color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed, onMounted } from 'vue'
|
||||
import Panel from '@framework/components/Panel.vue'
|
||||
import { useStore } from '@/store'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const props = withDefaults(defineProps<{ allowEdit?: boolean }>(), { allowEdit: true })
|
||||
|
||||
const { state, activeSegmentIndex, seek } = useStore()
|
||||
|
||||
// View preferences (persisted).
|
||||
// View preferences (persisted). When edit is disallowed (e.g. embedded review),
|
||||
// the transcript is read-only regardless of the saved preference.
|
||||
type ViewMode = 'edit' | 'read'
|
||||
const viewMode = ref<ViewMode>((localStorage.getItem('meetus.viewMode') as ViewMode) || 'edit')
|
||||
const effectiveMode = computed<ViewMode>(() => (props.allowEdit ? viewMode.value : 'read'))
|
||||
const compact = ref(localStorage.getItem('meetus.compact') === '1')
|
||||
const showTimes = ref(localStorage.getItem('meetus.readTimes') !== '0')
|
||||
watch(viewMode, (v) => localStorage.setItem('meetus.viewMode', v))
|
||||
@@ -83,17 +87,17 @@ watch(activeSegmentIndex, async (i) => {
|
||||
<template>
|
||||
<Panel title="Transcript">
|
||||
<template #actions>
|
||||
<div class="seg-control">
|
||||
<button :class="{ on: viewMode === 'edit' }" @click="viewMode = 'edit'">Edit</button>
|
||||
<button :class="{ on: viewMode === 'read' }" @click="viewMode = 'read'">Read</button>
|
||||
<div v-if="allowEdit" class="seg-control">
|
||||
<button :class="{ on: effectiveMode === 'edit' }" @click="viewMode = 'edit'">Edit</button>
|
||||
<button :class="{ on: effectiveMode === 'read' }" @click="viewMode = 'read'">Read</button>
|
||||
</div>
|
||||
<button v-if="viewMode === 'edit'" :class="{ on: compact }" @click="compact = !compact">Compact</button>
|
||||
<button v-if="allowEdit && effectiveMode === 'edit'" :class="{ on: compact }" @click="compact = !compact">Compact</button>
|
||||
<button v-else :class="{ on: showTimes }" @click="showTimes = !showTimes">Times</button>
|
||||
<span class="count">{{ state.segments.length }} segments</span>
|
||||
</template>
|
||||
|
||||
<!-- EDIT: per-segment blocks (comfortable or compact density) -->
|
||||
<div v-if="viewMode === 'edit'" class="seg-list" :class="{ compact }">
|
||||
<div v-if="effectiveMode === 'edit'" class="seg-list" :class="{ compact }">
|
||||
<p v-if="!state.segments.length" class="empty">No transcript for this run.</p>
|
||||
<div
|
||||
v-for="(seg, i) in state.segments"
|
||||
@@ -139,7 +143,7 @@ watch(activeSegmentIndex, async (i) => {
|
||||
class="rseg"
|
||||
:class="{ active: it.i === activeSegmentIndex }"
|
||||
@click="seek(it.start)"
|
||||
>{{ it.text }} </span>
|
||||
>{{ it.text.trim() + ' ' }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user