Files
momentry_studio/src/components/VideoPlayer.vue
Momentry Studio f3d75ebce8 feat: Library 批量注册 + PeopleView 修复 + 国际化
Library:
- 添加 checkbox 和鼠标框选功能
- Unregistered 卡片添加快速注册按钮
- 状态标签移至左下方,人脸图标移至左侧中间
- Deep Scan 改为 unregister -> register -> process
- 完整国际化(日语、繁体中文、英语)

PeopleView:
- 修复卡片不显示问题(使用 get_file_traces API)
- 移除对 identity_id 字段的依赖

其他:
- 添加日语语言包
- 添加 useDragSelect composable
- 新增文档:Face Bbox Null Bug Report
2026-10-01 00:42:13 +08:00

1813 lines
61 KiB
Vue

<template>
<div v-if="visible" class="ms-modal-overlay show" :style="{ zIndex: zIndexValue }" @click.self="close">
<div class="ms-modal ms-modal-video" :class="{ 'ms-video-ui-idle': uiIdle }" @mouseenter="showUi" @mousemove="showUi" @mouseleave="hideUi">
<div class="ms-modal-video-header">
<div class="ms-video-header-row">
<h3 v-if="actualFileName" class="ms-video-file-name">{{ actualFileName }}</h3>
<button class="ms-modal-video-close" @click="close">&times;</button>
</div>
<div class="ms-video-header-row ms-video-title-row">
<span class="ms-modal-video-title">{{ title }}</span>
<span class="ms-video-frame-info">F{{ frameOffset }}<template v-if="keyFrameNum != null">-<span class="ms-video-keyframe" title="Seek to key face frame" @click="seekToKeyFrame">[F{{ keyFrameNum }}]</span></template>-F{{ frameOffset + totalFrames }} ({{ formatTime(currentTime) }} / {{ formatTime(duration) }})</span>
</div>
</div>
<!-- Mode toggle (only show when not simple and has traces/segments) -->
<div v-if="!simple && (allTraces.length || mergedSegments.length)" class="ms-video-mode-toggle">
<button :class="{ active: playMode === 'trace' }" @click="switchMode('trace')">{{ t('person.mode_trace', 'Trace') }}</button>
<button :class="{ active: playMode === 'segment' }" @click="switchMode('segment')">{{ t('person.mode_segment', 'Segment') }}</button>
<button :class="{ active: playMode === 'continuous' }" @click="switchMode('continuous')">{{ t('person.mode_continuous', 'Continuous') }}</button>
</div>
<div class="ms-video-container" @mousedown="onVideoMouseDown" @mouseup="onVideoMouseUp" @mouseleave="onVideoMouseLeave">
<div v-if="videoError" class="ms-video-loading">
<p style="color:#f44336">{{ videoError }}</p>
</div>
<div v-else-if="videoLoading" class="ms-video-loading">
<div class="ms-video-loading-spinner"></div>
<p>Loading video...</p>
</div>
<video v-if="videoSrc && !videoLoading" ref="videoEl" :src="videoSrc" class="video" controls autoplay playsinline preload="auto" @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate" @error="onVideoError" @play="onPlay" @pause="onPause"></video>
<!-- OCR bbox overlay -->
<div v-if="currentOcrMarks.length" class="ms-video-ocr-overlay">
<div
v-for="mark in currentOcrMarks"
:key="mark.id"
class="ms-ocr-bbox"
:style="{
left: (mark.metadata?.bbox?.x || 0) + 'px',
top: (mark.metadata?.bbox?.y || 0) + 'px',
width: (mark.metadata?.bbox?.width || 100) + 'px',
height: (mark.metadata?.bbox?.height || 40) + 'px'
}"
>
<span class="ms-ocr-text">{{ mark.note }}</span>
</div>
</div>
<!-- Face bbox overlay -->
<div v-if="currentFaceMarks.length" class="ms-video-face-overlay">
<div
v-for="mark in currentFaceMarks"
:key="mark.id"
class="ms-face-bbox"
:style="{
left: (mark.metadata?.bbox?.x || 0) + 'px',
top: (mark.metadata?.bbox?.y || 0) + 'px',
width: (mark.metadata?.bbox?.width || 100) + 'px',
height: (mark.metadata?.bbox?.height || 100) + 'px'
}"
>
<span class="ms-face-label">{{ mark.note }}</span>
</div>
</div>
<!-- Pose indicator -->
<div v-if="currentPoseMarks.length" class="ms-video-pose-overlay">
<div class="ms-pose-indicator">
<span class="ms-pose-icon">🏃</span>
<span class="ms-pose-text">Pose detected</span>
</div>
</div>
<!-- ASRX subtitle bar -->
<div v-if="currentAsrxMark" class="ms-video-subtitle-bar">
<div class="ms-subtitle-text">{{ currentAsrxMark.note }}</div>
<div class="ms-subtitle-meta">F{{ asrxStartFrame(currentAsrxMark) }}-F{{ asrxEndFrame(currentAsrxMark) }}</div>
</div>
<!-- Fast-forward indicator -->
<div v-if="isFastForward" class="ms-video-fast-forward-indicator">2x</div>
</div>
<!-- Main Timeline bar -->
<div class="ms-video-timeline-wrap" v-if="!videoLoading">
<div class="ms-video-tl-bar ms-video-tl-main" @click="onMainTimelineClick">
<div class="ms-video-tl-progress" :style="{ width: (currentTime / duration * 100) + '%' }"></div>
<div class="ms-video-tl-position" :style="{ left: (currentTime / duration * 100) + '%' }"></div>
</div>
<div class="ms-video-tl-labels">
<span>F{{ currentFrame }}</span>
<span>{{ formatTime(duration) }}</span>
</div>
</div>
<!-- Mark Timelines (Advanced only) -->
<div class="ms-video-mark-timelines" v-if="isAdvanced && !videoLoading && visibleTags.length">
<div v-for="tag in visibleTags" :key="tag" class="ms-video-mark-tl-row">
<span class="ms-video-mark-tl-label">{{ tag }}</span>
<div class="ms-video-mark-tl-bar" @click="onMarkBarClick">
<div
v-for="mark in marksByTag(tag)"
:key="mark.id"
class="ms-video-mark-tl-mark"
:class="{ 'ms-mark-active': isMarkActive(mark) }"
:style="getMarkStyle(mark)"
@click.stop="goToMark(mark)"
:title="`#${mark.id} ${mark.note || ''}`"
></div>
<div v-if="playheadPct !== null" class="ms-video-tl-position" :style="{ left: playheadPct + '%' }">
<span class="ms-video-tl-pos-tip">{{ formatTime(absSec) }}</span>
</div>
</div>
</div>
</div>
<!-- Original segment timeline (keep for traces/segments mode) -->
<div class="ms-video-timeline-wrap" v-if="!simple && timelineMarkers.length && !videoLoading">
<div class="ms-video-tl-bar" @click="onPointBarClick">
<div
v-for="(dot, i) in timelineMarkers"
:key="i"
class="ms-video-tl-dot"
:class="{ active: isMarkerActive(i) }"
:style="{ left: dot.pct + '%', width: dot.w + '%' }"
@click.stop="onTimelineClick(dot)"
>
<span class="ms-video-tl-tip">{{ formatTime(dot.start) }}</span>
</div>
<div v-if="playheadPct !== null" class="ms-video-tl-position" :style="{ left: playheadPct + '%' }">
<span class="ms-video-tl-pos-tip">{{ formatTime(absSec) }}</span>
</div>
</div>
<div class="ms-video-tl-labels">
<span>{{ formatTime(tlStart) }}</span>
<span>{{ formatTime(tlEnd) }}</span>
</div>
</div>
<!-- Mark Controls (Advanced only) -->
<div class="ms-video-mark-controls" v-if="isAdvanced && !videoLoading">
<button class="ms-fm-btn ms-mark-btn" @click="startMark" :class="{ active: markingStart !== null }">
{{ markingStart !== null ? `F${markingStart} → ...` : '+ Mark' }}
</button>
<button v-if="markingStart !== null" class="ms-fm-btn ms-mark-btn ms-mark-end" @click="endMark">End F{{ currentFrame }}</button>
<button v-if="markingStart !== null" class="ms-fm-btn ms-mark-btn ms-mark-cancel" @click="cancelMark">✕</button>
<!-- Processor toggles -->
<div class="ms-proc-toggles">
<label class="ms-proc-toggle">
<input type="checkbox" v-model="showAsrxInPlayer">
<span class="ms-proc-toggle-label" style="color: #34a853;">ASRX</span>
</label>
<label class="ms-proc-toggle">
<input type="checkbox" v-model="showOcrInPlayer">
<span class="ms-proc-toggle-label" style="color: #fbbc04;">OCR</span>
</label>
<label class="ms-proc-toggle">
<input type="checkbox" v-model="showFaceInPlayer">
<span class="ms-proc-toggle-label" style="color: #4285f4;">Face</span>
</label>
<label class="ms-proc-toggle">
<input type="checkbox" v-model="showPoseInPlayer">
<span class="ms-proc-toggle-label" style="color: #9c27b0;">Pose</span>
</label>
</div>
<!-- Tag filter -->
<select v-model="tagFilter" class="ms-mark-filter">
<option value="">All Tags</option>
<option v-for="tag in allTags" :key="tag" :value="tag">{{ tag }}</option>
</select>
<!-- Toggle marks panel -->
<button class="ms-fm-btn ms-mark-panel-toggle" @click="showMarksPanel = !showMarksPanel">
Marks ({{ filteredMarks.length }})
</button>
</div>
<!-- Marks Panel (Advanced only) -->
<div class="ms-video-marks-panel" v-if="isAdvanced && showMarksPanel && !videoLoading">
<div class="ms-marks-header">
<span>Marks</span>
<button class="ms-marks-close" @click="showMarksPanel = false">✕</button>
</div>
<div class="ms-marks-list">
<div v-for="mark in filteredMarks" :key="mark.id" class="ms-mark-item" @click="goToMark(mark)">
<span class="ms-mark-id">#{{ mark.id }}</span>
<span class="ms-mark-tag" :style="{ background: getTagColor(mark.tag) }">{{ mark.tag }}</span>
<span class="ms-mark-range">F{{ mark.startFrame }}{{ mark.endFrame ? '-' + mark.endFrame : '' }}</span>
<span class="ms-mark-note">{{ mark.note || '—' }}</span>
<button class="ms-mark-del" @click.stop="deleteMark(mark.id)">✕</button>
</div>
<div v-if="filteredMarks.length === 0" class="ms-marks-empty">No marks</div>
</div>
<!-- Add mark form -->
<div class="ms-mark-add-form" v-if="showAddMarkForm">
<input v-model="newMarkTag" placeholder="Tag" class="ms-mark-input" list="existing-tags" />
<datalist id="existing-tags">
<option v-for="tag in allTags" :key="tag" :value="tag" />
</datalist>
<input v-model="newMarkNote" placeholder="Note (optional)" class="ms-mark-input" />
<button class="ms-fm-btn" @click="saveMark">Save</button>
<button class="ms-fm-btn" @click="showAddMarkForm = false">Cancel</button>
</div>
</div>
<div class="ms-video-nav" v-if="!videoLoading">
<!-- Frame seek controls (Advanced only) -->
<div v-if="isAdvanced" class="ms-video-frame-seek">
<span class="ms-video-frame-label">Frame:</span>
<input type="number" v-model.number="seekFrame" class="ms-video-frame-input" :placeholder="currentFrame.toString()" @keyup.enter="goToFrame" @focus="seekFrame = currentFrame" />
<button class="ms-fm-btn ms-video-frame-btn" @click="goToFrame">Go</button>
<span class="ms-video-frame-current">F{{ currentFrame }} / {{ totalFrames }}</span>
</div>
<div v-if="!simple && (allTraces.length || mergedSegments.length)" style="display:flex;align-items:center;gap:10px;flex:1;">
<button class="ms-fm-btn ms-video-nav-btn" @click="prevItem" :disabled="!canPrev">&larr; {{ $t('video.prev') }}</button>
<span class="ms-video-seg-info2">{{ currentItemIdx + 1 }} / {{ totalItems }}</span>
<button class="ms-fm-btn ms-video-nav-btn" @click="nextItem" :disabled="!canNext">{{ $t('video.next') }} &rarr;</button>
</div>
<span v-else class="ms-video-seg-info">F{{ currentFrame }} / F{{ totalFrames }} <span class="ms-video-time-aux">({{ formatTime(currentTime) }} / {{ formatTime(duration) }})</span></span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { apiCall } from '@/api'
import { useI18n } from 'vue-i18n'
import { getNextZIndex } from '@/composables/useZIndex'
import { useAdvancedMode } from '@/stores/advancedMode'
import { filesCache } from '@/store'
const { t } = useI18n()
const { isAdvanced } = useAdvancedMode()
const actualFileName = computed(() => {
const clean = props.fileUuid?.replace(/-/g, '')
const f = filesCache.value.find((f: any) => f.file_uuid?.replace(/-/g, '') === clean)
return f?.file_name || ''
})
const zIndexValue = ref(getNextZIndex())
const props = withDefaults(defineProps<{
fileUuid: string
startFrame: number
endFrame: number
allTraces?: any[]
mergedSegments?: any[]
initialTraceIdx?: number
initialSegmentIdx?: number
keyFrame?: number
title?: string
simple?: boolean
useOriginal?: boolean
showAsrx?: boolean
showOcr?: boolean
showFace?: boolean
showPose?: boolean
}>(), {
startFrame: 0,
endFrame: 0,
allTraces: () => [],
mergedSegments: () => [],
initialTraceIdx: 0,
initialSegmentIdx: 0,
keyFrame: null,
simple: false,
useOriginal: false,
showAsrx: false,
showOcr: false,
showFace: false,
showPose: false,
})
const emit = defineEmits(['close', 'trace-change', 'segment-change'])
const videoEl = ref<HTMLVideoElement | null>(null)
const visible = ref(true)
const currentTime = ref(0)
const duration = ref(0)
const isPlaying = ref(false)
const videoSrc = ref('')
const videoLoading = ref(true)
const videoError = ref('')
const currentTraceIdx = ref(props.initialTraceIdx ?? 0)
const currentSegmentIdx = ref(props.initialSegmentIdx ?? 0)
const curFileUuid = ref(props.fileUuid)
const videoFps = ref(30)
const seekFrame = ref<number | null>(null)
const frameOffset = ref(0)
// Long-press fast-forward
const longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const isFastForward = ref(false)
const FAST_FORWARD_SPEED = 2.0
const LONG_PRESS_DELAY = 300
// Auto-hide controls when idle or mouse leaves the player window
const uiIdle = ref(false)
const UI_IDLE_DELAY = 2500
let uiIdleTimer: number | null = null
function showUi() {
uiIdle.value = false
if (uiIdleTimer) clearTimeout(uiIdleTimer)
uiIdleTimer = window.setTimeout(() => {
if (!userInteracting.value) uiIdle.value = true
uiIdleTimer = null
}, UI_IDLE_DELAY)
}
function hideUi() {
if (uiIdleTimer) { clearTimeout(uiIdleTimer); uiIdleTimer = null }
if (!userInteracting.value) uiIdle.value = true
}
function onVideoMouseDown(e: MouseEvent) {
if (e.button !== 0) return
if (!videoEl.value || videoEl.value.paused) return
longPressTimer.value = setTimeout(() => {
if (videoEl.value && !videoEl.value.paused) {
videoEl.value.playbackRate = FAST_FORWARD_SPEED
isFastForward.value = true
}
}, LONG_PRESS_DELAY)
}
function onVideoMouseUp() {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
if (isFastForward.value && videoEl.value) {
videoEl.value.playbackRate = 1.0
isFastForward.value = false
}
}
function onVideoMouseLeave() {
onVideoMouseUp()
}
const userInteracting = computed(() =>
showMarksPanel.value || markingStart.value !== null || showAddMarkForm.value)
const currentFrame = computed(() => Math.round(currentTime.value * videoFps.value) + frameOffset.value)
const totalFrames = computed(() => Math.round(duration.value * videoFps.value))
// Mark system
interface Mark {
id: number
dbId?: number
startFrame: number
endFrame?: number
startSec?: number
endSec?: number
tag: string
note?: string
source?: 'user' | 'face' | 'ocr' | 'asrx' | 'pose' | 'auto'
metadata?: any
}
const marks = ref<Mark[]>([])
const markingStart = ref<number | null>(null)
const showMarksPanel = ref(false)
const showAddMarkForm = ref(false)
const tagFilter = ref('')
const newMarkTag = ref('')
const newMarkNote = ref('')
let nextMarkId = 1
// Processor visibility toggles (advanced mode)
const showAsrxInPlayer = ref(props.showAsrx)
const showOcrInPlayer = ref(props.showOcr)
const showFaceInPlayer = ref(props.showFace)
const showPoseInPlayer = ref(props.showPose)
// Convert face traces to marks
function loadTracesAsMarks() {
if (!props.allTraces.length) return
for (const trace of props.allTraces) {
const startFrame = trace.first_frame || 0
const endFrame = trace.last_frame || startFrame
const identity = trace.identity_name || trace.person_name || `Trace ${trace.trace_id || ''}`
marks.value.push({
id: nextMarkId++,
startFrame,
endFrame: endFrame > startFrame ? endFrame : undefined,
tag: 'face',
note: identity,
source: 'face'
})
}
}
// Load ASRX/OCR/Face/Pose data as marks
async function loadProcessorMarks() {
if (!props.fileUuid) return
try {
// Load ASRX
if (props.showAsrx) {
const asrxData: any = await apiCall('get_processor_json', { fileHash: props.fileUuid, processor: 'asrx' })
if (asrxData && asrxData.segments) {
for (const seg of asrxData.segments) {
const speaker = seg.speaker_id || seg.speaker || 'Speaker'
const text = seg.text || ''
marks.value.push({
id: nextMarkId++,
startFrame: seg.start_frame ?? 0,
endFrame: seg.end_frame ?? undefined,
startSec: seg.start_time,
endSec: seg.end_time,
tag: 'asrx',
note: `${speaker}: ${text}`,
source: 'asrx',
metadata: { speaker: seg.speaker_id || seg.speaker }
})
}
}
}
// Load OCR
if (props.showOcr) {
const ocrData: any = await apiCall('get_processor_json', { fileHash: props.fileUuid, processor: 'ocr' })
if (ocrData && ocrData.frames) {
for (const frame of ocrData.frames) {
marks.value.push({
id: nextMarkId++,
startFrame: frame.frame,
tag: 'ocr',
note: frame.text,
source: 'ocr',
metadata: { bbox: frame.bbox }
})
}
}
}
// Load Face
if (props.showFace) {
const faceData: any = await apiCall('get_processor_json', { fileHash: props.fileUuid, processor: 'face' })
if (faceData && faceData.frames) {
for (const frame of faceData.frames) {
const faces = frame.faces || []
for (const face of faces) {
const bbox = face.bbox
if (!bbox?.width) continue
const name = face.identity_name || face.person_name || (face.face_id != null ? `Face #${face.face_id}` : null)
if (!name) continue
marks.value.push({
id: nextMarkId++,
startFrame: frame.frame,
endFrame: undefined,
tag: 'face',
note: name,
source: 'face',
metadata: {
bbox,
face_id: face.face_id,
confidence: face.confidence
}
})
}
}
}
}
// Load Pose
if (props.showPose) {
const poseData: any = await apiCall('get_processor_json', { fileHash: props.fileUuid, processor: 'pose' })
if (poseData && poseData.frames) {
for (const frame of poseData.frames) {
marks.value.push({
id: nextMarkId++,
startFrame: frame.frame,
tag: 'pose',
note: 'Pose detected',
source: 'pose',
metadata: { pose: frame.pose }
})
}
}
}
} catch (e) {
console.error('[loadProcessorMarks] Error:', e)
}
}
// Tag colors
const tagColors: Record<string, string> = {}
const colorPalette = ['#4285f4', '#ea4335', '#fbbc04', '#34a853', '#9c27b0', '#ff5722', '#00bcd4', '#e91e63']
function getTagColor(tag: string): string {
if (!tagColors[tag]) {
const idx = Object.keys(tagColors).length % colorPalette.length
tagColors[tag] = colorPalette[idx]
}
return tagColors[tag]
}
const allTags = computed(() => [...new Set(marks.value.map(m => m.tag))])
const filteredMarks = computed(() => {
if (!tagFilter.value) return marks.value
return marks.value.filter(m => m.tag === tagFilter.value)
})
function marksByTag(tag: string): Mark[] {
return marks.value.filter(m => m.tag === tag && getMarkStyle(m) !== null)
}
const currentAsrxMark = computed(() => {
const frame = currentFrame.value
const sec = absSec.value
return marks.value.find(m =>
m.tag === 'asrx' &&
(m.startSec != null
? sec >= m.startSec && (m.endSec ?? m.startSec) >= sec
: m.startFrame <= frame && (m.endFrame || m.startFrame) >= frame)
)
})
function asrxStartFrame(mark: Mark): number {
if (mark.startSec != null) return Math.round(mark.startSec * (videoFps.value || 30))
return mark.startFrame
}
function asrxEndFrame(mark: Mark): number {
if (mark.endSec != null) return Math.round(mark.endSec * (videoFps.value || 30))
return mark.endFrame ?? mark.startFrame
}
const currentOcrMarks = computed(() => {
const frame = currentFrame.value
return marks.value.filter(m =>
m.tag === 'ocr' && m.startFrame === frame
)
})
const currentFaceMarks = computed(() => {
const frame = currentFrame.value
return marks.value.filter(m =>
m.tag === 'face' && m.startFrame === frame && m.metadata?.bbox?.width
)
})
const currentPoseMarks = computed(() => {
const frame = currentFrame.value
return marks.value.filter(m =>
m.tag === 'pose' && m.startFrame === frame && showPoseInPlayer.value
)
})
const visibleTags = computed(() => {
const tags = new Set<string>()
for (const mark of marks.value) {
if (mark.tag === 'asrx' && !showAsrxInPlayer.value) continue
if (mark.tag === 'ocr' && !showOcrInPlayer.value) continue
if (mark.tag === 'face' && !showFaceInPlayer.value) continue
if (mark.tag === 'pose' && !showPoseInPlayer.value) continue
tags.add(mark.tag)
}
return Array.from(tags)
})
function startMark() {
markingStart.value = currentFrame.value
}
function endMark() {
if (markingStart.value === null) return
showAddMarkForm.value = true
showMarksPanel.value = true
}
function cancelMark() {
markingStart.value = null
}
function saveMark() {
if (markingStart.value === null) return
const tag = newMarkTag.value.trim() || 'default'
const start = markingStart.value
const end = currentFrame.value > start ? currentFrame.value : undefined
const note = newMarkNote.value.trim() || undefined
const mark: Mark = {
id: nextMarkId++,
startFrame: start,
endFrame: end,
tag,
note,
source: 'user'
}
marks.value.push(mark)
apiCall('save_video_mark', {
fileUuid: props.fileUuid,
tag,
startFrame: start,
endFrame: end,
note
}).then((saved: any) => {
if (saved?.id) mark.dbId = saved.id
}).catch(err => {
console.error('[VideoPlayer] save_video_mark failed:', err)
})
// Reset
markingStart.value = null
newMarkTag.value = ''
newMarkNote.value = ''
showAddMarkForm.value = false
}
function deleteMark(id: number) {
const mark = marks.value.find(m => m.id === id)
marks.value = marks.value.filter(m => m.id !== id)
if (mark?.dbId) {
apiCall('delete_video_mark', { id: mark.dbId }).catch(err => {
console.error('[VideoPlayer] delete_video_mark failed:', err)
})
}
}
async function loadUserMarks() {
if (!props.fileUuid) return
try {
const saved: any = await apiCall('get_video_marks', { fileUuid: props.fileUuid })
if (!Array.isArray(saved)) return
for (const item of saved) {
if (marks.value.some(m => m.dbId === item.id)) continue
marks.value.push({
id: nextMarkId++,
dbId: item.id,
startFrame: item.start_frame,
endFrame: item.end_frame ?? undefined,
tag: item.tag,
note: item.note ?? undefined,
source: 'user'
})
}
} catch (err) {
console.error('[VideoPlayer] get_video_marks failed:', err)
}
}
function goToMark(mark: Mark) {
if (playMode.value === 'trace' && props.allTraces.length) {
const idx = findTraceIdxForFrame(mark.startFrame)
loadTrace(idx)
} else if (playMode.value === 'segment' && props.mergedSegments.length) {
const idx = findSegmentIdxForFrame(mark.startFrame)
loadSegment(idx)
} else if (videoEl.value) {
const fps = videoFps.value || 30
const startFrame = mark.startSec != null ? Math.round(mark.startSec * fps) : mark.startFrame
videoEl.value.currentTime = Math.max(0, Math.min(duration.value, (startFrame - frameOffset.value) / fps))
}
}
function isMarkActive(mark: Mark): boolean {
const f = currentFrame.value
const s = absSec.value
if (mark.startSec != null) {
return s >= mark.startSec && (mark.endSec ?? mark.startSec) >= s
}
if (mark.endFrame) {
return f >= mark.startFrame && f <= mark.endFrame
}
return f === mark.startFrame
}
function getMarkStyle(mark: Mark): any {
const fps = videoFps.value || 30
const startSec = mark.startSec ?? mark.startFrame / fps
const endSec = mark.endSec ?? (mark.endFrame ? mark.endFrame / fps : startSec)
const startPct = ((startSec - tlStart.value) / tlRange.value) * 100
const endPct = ((endSec - tlStart.value) / tlRange.value) * 100
if (endPct < 0 || startPct > 100) return null
const s = Math.max(0, startPct)
const e = Math.min(100, endPct)
return {
left: s + '%',
width: Math.max(0.5, e - s) + '%',
}
}
function onMainTimelineClick(e: MouseEvent) {
if (!videoEl.value) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
videoEl.value.currentTime = pct * duration.value
}
const playMode = ref<'trace' | 'segment' | 'continuous'>('trace')
const continuousStart = computed(() => {
if (!props.allTraces.length) return 0
return props.allTraces[0].first_sec || props.allTraces[0].start_time || 0
})
const continuousEnd = computed(() => {
if (!props.allTraces.length) return 0
const last = props.allTraces[props.allTraces.length - 1]
return last.last_sec || last.end_time || 0
})
const continuousFileUuid = computed(() => {
if (!props.allTraces.length) return ''
return props.allTraces[0].file_uuid || ''
})
const tlStart = computed(() => {
if (playMode.value === 'segment' && props.mergedSegments.length) {
return props.mergedSegments[0].start || 0
}
if (!props.allTraces.length) return 0
return props.allTraces[0].first_sec || props.allTraces[0].start_time || 0
})
const tlEnd = computed(() => {
if (playMode.value === 'segment' && props.mergedSegments.length) {
const last = props.mergedSegments[props.mergedSegments.length - 1]
return last.end || 0
}
if (!props.allTraces.length) return 0
const last = props.allTraces[props.allTraces.length - 1]
return last.last_sec || last.end_time || 0
})
const tlRange = computed(() => Math.max(tlEnd.value - tlStart.value, 1))
const continuousFps = computed(() => {
if (!props.allTraces.length) return 30
return getTraceFps(props.allTraces[0]) || 30
})
const continuousStartSec = computed(() => {
if (!props.allTraces.length) return 0
return props.allTraces[0].first_sec || props.allTraces[0].start_time || 0
})
const continuousEndSec = computed(() => {
if (!props.allTraces.length) return 0
const last = props.allTraces[props.allTraces.length - 1]
return last.last_sec || last.end_time || 0
})
const absSec = computed(() => currentFrame.value / (videoFps.value || 30))
const playheadPct = computed(() => {
if (!videoEl.value || duration.value === 0) return null
const pct = ((absSec.value - tlStart.value) / tlRange.value) * 100
return Math.max(0, Math.min(100, pct))
})
const timelineMarkers = computed(() => {
if (playMode.value === 'segment' && props.mergedSegments.length) {
return props.mergedSegments.map((s: any, i: number) => {
const st = s.start || 0
const en = s.end || 0
return {
idx: i,
start: st,
end: en,
pct: ((st - tlStart.value) / tlRange.value) * 100,
w: Math.max(1, ((en - st) / tlRange.value) * 100),
}
})
}
return props.allTraces.map((t: any, i: number) => {
const st = t.first_sec || t.start_time || 0
const en = t.last_sec || t.end_time || 0
return {
idx: i,
start: st,
end: en,
pct: ((st - tlStart.value) / tlRange.value) * 100,
w: Math.max(0.5, ((en - st) / tlRange.value) * 100),
first_frame: t.first_frame,
}
})
})
const currentItemIdx = computed(() => {
if (playMode.value === 'segment') return currentSegmentIdx.value
return currentTraceIdx.value
})
const totalItems = computed(() => {
if (playMode.value === 'segment') return props.mergedSegments.length || 0
return props.allTraces.length || 0
})
const canPrev = computed(() => currentItemIdx.value > 0)
const canNext = computed(() => currentItemIdx.value < totalItems.value - 1)
const keyFrameNum = computed(() => {
if (videoLoading.value) return null
if (props.allTraces.length) {
const t = currentKeyFrameTrace()
if (t) return t.best_face_frame ?? t.first_frame ?? t.start_frame ?? null
}
return props.keyFrame ?? null
})
function isMarkerActive(i: number): boolean {
if (playMode.value === 'segment') return i === currentSegmentIdx.value
return i === currentTraceIdx.value
}
function getTraceFps(t: any): number {
if (!t) return 30
const lf = t.last_frame !== undefined && t.last_frame !== null ? t.last_frame : 0
const ff = t.first_frame !== undefined && t.first_frame !== null ? t.first_frame : 0
const ls = t.last_sec !== undefined && t.last_sec !== null ? t.last_sec : 0
const fs = t.first_sec !== undefined && t.first_sec !== null ? t.first_sec : 0
const frameDiff = lf - ff
const secDiff = ls - fs
if (secDiff <= 0 || frameDiff <= 0) return 30
return frameDiff / secDiff
}
function findSegmentIdxForFrame(absFrame: number): number {
for (let i = 0; i < props.mergedSegments.length; i++) {
const s = props.mergedSegments[i]
const st = s.start_frame || 0
const en = s.end_frame || 0
if (absFrame >= st && absFrame <= en) {
return i
}
}
for (let i = 0; i < props.mergedSegments.length; i++) {
const s = props.mergedSegments[i]
if (absFrame < (s.start_frame || 0)) {
return Math.max(0, i - 1)
}
}
return Math.max(0, props.mergedSegments.length - 1)
}
function findTraceIdxForFrame(absFrame: number): number {
for (let i = 0; i < props.allTraces.length; i++) {
const t = props.allTraces[i]
const st = t.first_frame || 0
const en = t.last_frame || 0
if (absFrame >= st && absFrame <= en) {
return i
}
}
for (let i = 0; i < props.allTraces.length; i++) {
const t = props.allTraces[i]
if (absFrame < (t.first_frame || 0)) {
return Math.max(0, i - 1)
}
}
return props.allTraces.length - 1
}
async function switchMode(mode: 'trace' | 'segment' | 'continuous') {
if (mode === playMode.value) return
const prevMode = playMode.value
const prevTime = currentTime.value
const prevTraceIdx = currentTraceIdx.value
const prevSegmentIdx = currentSegmentIdx.value
let absFrame = 0
let fps = 30
if (prevMode === 'trace' && props.allTraces[prevTraceIdx]) {
const t = props.allTraces[prevTraceIdx]
fps = getTraceFps(t)
absFrame = (t.first_frame || 0) + Math.round(prevTime * fps)
} else if (prevMode === 'segment' && props.mergedSegments[prevSegmentIdx]) {
const s = props.mergedSegments[prevSegmentIdx]
const sFps = getTraceFps(props.allTraces[s._startIdx || 0])
fps = sFps
absFrame = (s.start_frame || 0) + Math.round(prevTime * fps)
} else if (prevMode === 'continuous') {
fps = videoFps.value
absFrame = Math.round(prevTime * videoFps.value) + frameOffset.value
}
playMode.value = mode
if (mode === 'continuous') {
const firstTrace = props.allTraces[0]
const lastTrace = props.allTraces[props.allTraces.length - 1]
const fps = continuousFps.value
const firstFrame = firstTrace?.first_frame || 0
const lastFrame = lastTrace?.last_frame || firstFrame + 1
const targetIdx = findTraceIdxForFrame(absFrame)
currentTraceIdx.value = targetIdx
seekFrame.value = 0
frameOffset.value = absFrame
curFileUuid.value = continuousFileUuid.value
const prevVolume = videoEl.value?.volume ?? 1
videoLoading.value = true
videoError.value = ''
videoSrc.value = ''
try {
const data = await apiCall('get_video_stream', {
uuid: continuousFileUuid.value,
startTime: null,
endTime: null,
startFrame: absFrame,
endFrame: lastFrame,
original: props.useOriginal,
})
if (typeof data === 'string') {
videoSrc.value = data
} else {
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
videoSrc.value = URL.createObjectURL(blob)
}
await nextTick()
} catch (e: any) {
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
return
} finally {
videoLoading.value = false
await nextTick()
const el = videoEl.value
if (el) {
el.volume = prevVolume
el.load()
}
}
} else if (mode === 'trace') {
const targetIdx = findTraceIdxForFrame(absFrame)
await loadTrace(targetIdx)
} else if (mode === 'segment') {
const targetIdx = findSegmentIdxForFrame(absFrame)
const targetSeg = props.mergedSegments[targetIdx]
if (props.mergedSegments.length && targetSeg) {
await loadSegment(targetIdx)
}
}
}
async function loadTrace(idx: number) {
const traces = props.allTraces
if (idx < 0 || idx >= traces.length) return
currentTraceIdx.value = idx
emit('trace-change', idx)
const t = traces[idx]
const fu = t.file_uuid || ''
if (!fu || fu === 'undefined') return
const stFrame = t.first_frame || 0
const enFrame = Math.max(t.last_frame || 0, stFrame + 1)
seekFrame.value = 0
frameOffset.value = stFrame
curFileUuid.value = fu
const prevVolume = videoEl.value?.volume ?? 1
videoLoading.value = true
videoError.value = ''
videoSrc.value = ''
try {
const data = await apiCall('get_video_stream', {
uuid: fu,
startTime: null,
endTime: null,
startFrame: stFrame,
endFrame: enFrame,
original: props.useOriginal,
})
if (typeof data === 'string') {
videoSrc.value = data
} else {
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
videoSrc.value = URL.createObjectURL(blob)
}
await nextTick()
} catch (e: any) {
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
return
} finally {
videoLoading.value = false
await nextTick()
const el = videoEl.value
if (el) {
el.volume = prevVolume
el.load()
}
}
}
async function loadSegment(idx: number) {
const segments = props.mergedSegments
if (idx < 0 || idx >= segments.length) return
currentSegmentIdx.value = idx
emit('segment-change', idx)
const s = segments[idx]
const fu = s.file_uuid || ''
if (!fu || fu === 'undefined') return
const stFrame = s.start_frame || 0
const enFrame = Math.max(s.end_frame || 0, stFrame + 1)
seekFrame.value = 0
frameOffset.value = stFrame
curFileUuid.value = fu
const prevVolume = videoEl.value?.volume ?? 1
videoLoading.value = true
videoError.value = ''
videoSrc.value = ''
try {
const data = await apiCall('get_video_stream', {
uuid: fu,
startTime: null,
endTime: null,
startFrame: stFrame,
endFrame: enFrame,
original: props.useOriginal,
})
if (typeof data === 'string') {
videoSrc.value = data
} else {
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
videoSrc.value = URL.createObjectURL(blob)
}
await nextTick()
} catch (e: any) {
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
return
} finally {
videoLoading.value = false
await nextTick()
const el = videoEl.value
if (el) {
el.volume = prevVolume
el.load()
}
}
}
async function loadContinuous() {
const fu = continuousFileUuid.value
if (!fu || fu === 'undefined') return
const firstTrace = props.allTraces[0]
const lastTrace = props.allTraces[props.allTraces.length - 1]
const stFrame = firstTrace?.first_frame || 0
const enFrame = Math.max(lastTrace?.last_frame || 0, stFrame + 1)
seekFrame.value = 0
frameOffset.value = stFrame
curFileUuid.value = fu
const prevVolume = videoEl.value?.volume ?? 1
videoLoading.value = true
videoError.value = ''
videoSrc.value = ''
try {
const data = await apiCall('get_video_stream', {
uuid: fu,
startTime: null,
endTime: null,
startFrame: stFrame,
endFrame: enFrame,
original: props.useOriginal,
})
if (typeof data === 'string') {
videoSrc.value = data
} else {
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
videoSrc.value = URL.createObjectURL(blob)
}
await nextTick()
} catch (e: any) {
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
return
} finally {
videoLoading.value = false
await nextTick()
const el = videoEl.value
if (el) {
el.volume = prevVolume
el.load()
}
}
}
function onTimelineClick(marker: any) {
if (playMode.value === 'continuous') {
if (!videoEl.value) return
videoEl.value.currentTime = marker.start - continuousStartSec.value
currentTraceIdx.value = marker.idx
} else if (playMode.value === 'trace') {
loadTrace(marker.idx)
} else if (playMode.value === 'segment') {
loadSegment(marker.idx)
}
}
function seekFromAbsSec(absSecValue: number) {
const absFrame = Math.round(absSecValue * (videoFps.value || 30))
if (playMode.value === 'trace') {
loadTrace(findTraceIdxForFrame(absFrame))
} else if (playMode.value === 'segment') {
loadSegment(findSegmentIdxForFrame(absFrame))
} else if (videoEl.value) {
videoEl.value.currentTime = Math.max(0, Math.min(duration.value, (absFrame - frameOffset.value) / (videoFps.value || 30)))
}
}
function currentKeyFrameTrace(): any {
if (playMode.value === 'segment') {
const idx = findTraceIdxForFrame(currentFrame.value)
const t = props.allTraces[idx]
return t || null
}
return props.allTraces[currentTraceIdx.value] || null
}
function seekToKeyFrame() {
const el = videoEl.value
if (!el) return
let keyFrame: number | null = null
if (props.allTraces.length) {
const t = currentKeyFrameTrace()
keyFrame = t ? (t.best_face_frame ?? t.first_frame ?? t.start_frame ?? null) : null
} else {
keyFrame = props.keyFrame ?? null
}
if (keyFrame == null) return
reloadFromFrame(keyFrame)
}
async function reloadFromFrame(targetFrame: number) {
const fu = curFileUuid.value || props.fileUuid
if (!fu || fu === 'undefined') return
const clipStart = frameOffset.value
const clipEnd = Math.max(clipStart + Math.round(duration.value * videoFps.value), targetFrame + 1)
const enFrame = Math.max(clipEnd, targetFrame + 1)
seekFrame.value = 0
frameOffset.value = targetFrame
curFileUuid.value = fu
const prevVolume = videoEl.value?.volume ?? 1
videoLoading.value = true
videoError.value = ''
videoSrc.value = ''
try {
const data = await apiCall('get_video_stream', {
uuid: fu,
startTime: null,
endTime: null,
startFrame: targetFrame,
endFrame: enFrame,
original: props.useOriginal,
})
if (typeof data === 'string') {
videoSrc.value = data
} else {
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
videoSrc.value = URL.createObjectURL(blob)
}
await nextTick()
} catch (e: any) {
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
return
} finally {
videoLoading.value = false
await nextTick()
const el = videoEl.value
if (el) {
el.volume = prevVolume
el.load()
}
}
}
function onPointBarClick(e: MouseEvent) {
const bar = (e.currentTarget as HTMLElement).getBoundingClientRect()
const pct = Math.max(0, Math.min(100, ((e.clientX - bar.left) / bar.width) * 100))
seekFromAbsSec(tlStart.value + (pct / 100) * tlRange.value)
}
function onMarkBarClick(e: MouseEvent) {
const bar = (e.currentTarget as HTMLElement).getBoundingClientRect()
const pct = Math.max(0, Math.min(100, ((e.clientX - bar.left) / bar.width) * 100))
seekFromAbsSec(tlStart.value + (pct / 100) * tlRange.value)
}
function prevItem() {
if (playMode.value === 'continuous') {
seekToPrevTrace()
} else if (playMode.value === 'trace') {
prevTrace()
} else if (playMode.value === 'segment') {
prevSegment()
}
}
function nextItem() {
if (playMode.value === 'continuous') {
seekToNextTrace()
} else if (playMode.value === 'trace') {
nextTrace()
} else if (playMode.value === 'segment') {
nextSegment()
}
}
function prevTrace() {
if (currentTraceIdx.value > 0) loadTrace(currentTraceIdx.value - 1)
}
function nextTrace() {
if (currentTraceIdx.value < props.allTraces.length - 1) loadTrace(currentTraceIdx.value + 1)
}
function prevSegment() {
if (currentSegmentIdx.value > 0) loadSegment(currentSegmentIdx.value - 1)
}
function nextSegment() {
if (currentSegmentIdx.value < props.mergedSegments.length - 1) loadSegment(currentSegmentIdx.value + 1)
}
function seekToPrevTrace() {
if (!videoEl.value || currentTraceIdx.value <= 0) return
currentTraceIdx.value--
const t = props.allTraces[currentTraceIdx.value]
if (!t) return
videoEl.value.currentTime = Math.max(0, (t.first_sec || t.start_time || 0) - continuousStartSec.value)
}
function seekToNextTrace() {
if (!videoEl.value || currentTraceIdx.value >= props.allTraces.length - 1) return
currentTraceIdx.value++
const t = props.allTraces[currentTraceIdx.value]
if (!t) return
videoEl.value.currentTime = Math.max(0, (t.first_sec || t.start_time || 0) - continuousStartSec.value)
}
function close() {
visible.value = false
emit('close')
}
function goToFrame() {
if (!videoEl.value || seekFrame.value === null) return
const targetSec = (seekFrame.value - frameOffset.value) / videoFps.value
videoEl.value.currentTime = Math.max(0, Math.min(targetSec, duration.value))
}
function resolveKnownFps(): number {
let t = props.allTraces[currentTraceIdx.value]
if (!t && playMode.value === 'segment') {
const s = props.mergedSegments[currentSegmentIdx.value]
t = props.allTraces[s?._startIdx]
}
if (!t) t = props.allTraces[0]
const tf = getTraceFps(t)
return tf >= 1 ? tf : 0
}
async function onLoaded() {
const el = videoEl.value
if (!el) return
// Use the trace-derived fps when available (accurate source fps from trace
// frame/sec data). Fall back to the file's known fps via get_file_info,
// and only last-resort to clip-based detection, since
// getVideoPlaybackQuality().totalVideoFrames reflects frames decoded so far
// and yields an inaccurate fps for seek math.
const knownFps = resolveKnownFps()
if (knownFps >= 1) {
videoFps.value = knownFps
} else {
let fileFps = 0
const fu = curFileUuid.value || props.fileUuid
if (fu && fu !== 'undefined') {
try {
const info: any = await apiCall('get_file_info', { uuid: fu })
fileFps = Number(info?.fps)
} catch { /* fall back below */ }
}
if (fileFps >= 1) {
videoFps.value = fileFps
} else if (el.getVideoPlaybackQuality) {
// @ts-ignore - webkitVideoDecodedByteCount etc. are non-standard
const quality = el.getVideoPlaybackQuality()
if (quality.totalVideoFrames > 0 && el.duration > 0) {
videoFps.value = Math.round(quality.totalVideoFrames / el.duration)
}
}
}
if (seekFrame.value !== null) {
el.currentTime = seekFrame.value / videoFps.value
} else {
el.currentTime = 0
}
el.play().catch(() => {})
}
function onTimeUpdate() {
if (!videoEl.value) return
currentTime.value = videoEl.value.currentTime
duration.value = videoEl.value.duration || 0
if (playMode.value === 'continuous') {
for (let i = props.allTraces.length - 1; i >= 0; i--) {
const t = props.allTraces[i]
if (currentTime.value >= (t.first_sec || 0)) {
currentTraceIdx.value = i
break
}
}
}
}
function onVideoError() {
const el = videoEl.value
if (el) {
const msg = el.error?.message || 'unknown error'
videoError.value = `Video playback error: ${msg}`
} else {
videoError.value = 'Video playback error'
}
}
function togglePlay() {
if (videoEl.value) {
if (videoEl.value.paused) { videoEl.value.play(); isPlaying.value = true }
else { videoEl.value.pause(); isPlaying.value = false }
}
}
function onPlay() {
isPlaying.value = true
showUi()
}
function onPause() {
isPlaying.value = false
uiIdle.value = false
if (uiIdleTimer) { clearTimeout(uiIdleTimer); uiIdleTimer = null }
uiIdleTimer = window.setTimeout(() => {
if (!userInteracting.value) uiIdle.value = true
uiIdleTimer = null
}, UI_IDLE_DELAY)
}
function formatTime(sec: number): string {
if (!sec || isNaN(sec)) return '0:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function onKeydown(e: KeyboardEvent) {
if (!visible.value) return
switch (e.key) {
case 'Escape': close(); break
case ' ': e.preventDefault(); togglePlay(); break
case 'ArrowLeft': if (videoEl.value) videoEl.value.currentTime = Math.max(0, videoEl.value.currentTime - 5); break
case 'ArrowRight': if (videoEl.value) videoEl.value.currentTime = Math.min(duration.value, videoEl.value.currentTime + 5); break
}
}
onMounted(async () => {
seekFrame.value = 0
document.addEventListener('keydown', onKeydown)
if (!props.fileUuid || props.fileUuid === 'undefined') {
videoError.value = 'No file associated with this result'
videoLoading.value = false
return
}
// Load face traces as marks
loadTracesAsMarks()
// Load ASRX/OCR marks
loadProcessorMarks()
// Load user marks from SQLite
loadUserMarks()
if (props.allTraces.length || props.mergedSegments.length) {
if (playMode.value === 'continuous') {
await loadContinuous()
} else if (playMode.value === 'segment' && props.mergedSegments.length) {
await loadSegment(currentSegmentIdx.value)
} else {
await loadTrace(currentTraceIdx.value)
}
emit('trace-change', currentTraceIdx.value)
} else {
seekFrame.value = 0
frameOffset.value = props.startFrame
try {
if (props.startFrame == null || props.endFrame == null) {
videoError.value = 'ERROR: startFrame and endFrame are required'
videoLoading.value = false
return
}
const data = await apiCall('get_video_stream', {
uuid: props.fileUuid,
startTime: null,
endTime: null,
startFrame: props.startFrame,
endFrame: props.endFrame,
original: props.useOriginal,
})
if (typeof data === 'string') {
videoSrc.value = data
} else {
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
videoSrc.value = URL.createObjectURL(blob)
}
await nextTick()
} catch (e: any) {
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
} finally {
videoLoading.value = false
await nextTick()
videoEl.value?.load()
}
}
})
watch(() => props.initialTraceIdx, (newIdx) => {
if (newIdx === undefined || newIdx === null) return
if (playMode.value === 'trace' && newIdx !== currentTraceIdx.value) {
loadTrace(newIdx)
}
})
// Watch for traces changes and reload marks
watch(() => props.allTraces, () => {
// Remove old face marks and reload
marks.value = marks.value.filter(m => m.source !== 'face')
loadTracesAsMarks()
}, { deep: true })
watch(() => props.initialSegmentIdx, (newIdx) => {
if (newIdx === undefined || newIdx === null) return
if (playMode.value === 'segment' && newIdx !== currentSegmentIdx.value) {
loadSegment(newIdx)
}
})
onUnmounted(() => {
if (uiIdleTimer) { clearTimeout(uiIdleTimer); uiIdleTimer = null }
document.removeEventListener('keydown', onKeydown)
})
</script>
<style scoped>
.ms-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.3); z-index: 999; display: flex; justify-content: center; align-items: flex-start; padding-top: 40px; }
.ms-modal { background: #fff; border-radius: 20px; max-width: 380px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,.18); }
.ms-modal-video {
max-width: 720px;
width: 95%;
padding: 20px 24px 24px;
text-align: left;
background: #1a1a1a;
position: relative;
max-height: min(90vh, calc(100vh - 160px));
overflow-y: auto;
}
/* Auto-hide controls when idle / mouse leaves the player window */
.ms-video-ui-idle { cursor: none; }
.ms-video-ui-idle .ms-video-mode-toggle,
.ms-video-ui-idle .ms-video-title-row,
.ms-video-ui-idle .ms-video-timeline-wrap,
.ms-video-ui-idle .ms-video-mark-timelines,
.ms-video-ui-idle .ms-video-mark-controls,
.ms-video-ui-idle .ms-video-marks-panel,
.ms-video-ui-idle .ms-video-nav { opacity: 0; pointer-events: none; transition: opacity .25s; }
.ms-video-ui-idle .video::-webkit-media-controls-enclosure,
.ms-video-ui-idle .video::-webkit-media-controls { visibility: hidden; pointer-events: none; }
.ms-modal-video-advanced {
max-width: min(95vw, 1200px);
max-height: 90vh;
}
.ms-modal-video-advanced .video {
max-height: min(75vh, calc(90vh - 180px));
}
/* Responsive adjustments */
@media (max-width: 768px) {
.ms-modal-video { width: 98%; padding: 12px 16px 16px; }
.ms-modal-video-advanced { max-width: 98%; }
.video { max-height: 50vh; }
.ms-proc-toggles { flex-wrap: wrap; gap: 8px; }
.ms-video-controls { flex-wrap: wrap; gap: 4px; }
.ms-video-controls button, .ms-video-controls select { font-size: 11px; padding: 2px 6px; }
.ms-video-frame-seek { flex-wrap: wrap; gap: 4px; }
.ms-video-frame-seek input { width: 60px; }
.ms-video-marks-panel { position: fixed; bottom: 0; left: 0; right: 0; top: auto; max-height: 40vh; border-radius: 12px 12px 0 0; }
}
@media (min-width: 769px) and (max-width: 1024px) {
.ms-modal-video { max-width: 900px; }
.video { max-height: 65vh; }
}
@media (min-width: 1025px) and (max-width: 1440px) {
.ms-modal-video { max-width: 1000px; }
.video { max-height: 70vh; }
}
@media (min-width: 1441px) {
.ms-modal-video-advanced { max-width: 1200px; }
.ms-modal-video-advanced .video { max-height: 70vh; }
}
/* Ultra-wide screens (21:9) */
@media (min-aspect-ratio: 21/9) {
.ms-modal-video-advanced .video { max-height: 55vh; }
}
.ms-modal-video-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.ms-video-header-row { display: flex; align-items: center; justify-content: space-between; }
.ms-video-file-name { font-size: 12px; font-weight: 500; color: var(--text-secondary); margin: 0; max-width: 70%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-video-frame-info { font-size: 12px; color: var(--text-secondary); white-space: nowrap; flex-shrink: 0; }
.ms-video-keyframe { color: #ff9800; font-weight: 600; cursor: pointer; }
.ms-video-keyframe:hover { text-decoration: underline; }
.ms-video-title-row { gap: 10px; }
.ms-modal-video-close { border: none; background: transparent; font-size: 18px; color: #9aa0a6; cursor: pointer; padding: 0; line-height: 1; flex-shrink: 0; margin-left: auto; }
.ms-modal-video-close:hover { color: #fff; }
.ms-video-mode-toggle { display: flex; gap: 8px; margin-bottom: 12px; }
.ms-video-mode-toggle button { padding: 6px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2); background: transparent; color: rgba(255,255,255,0.6); font-size: 12px; cursor: pointer; transition: background .15s, color .15s, border-color .15s; }
.ms-video-mode-toggle button:hover { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.85); }
.ms-video-mode-toggle button.active { background: rgba(255,255,255,0.15); color: #fff; border-color: rgba(255,255,255,0.4); }
.video { max-width: 100%; max-height: 50vh; border-radius: 8px; display: block; width: 100%; }
.ms-video-container { position: relative; width: 100%; user-select: none; }
.ms-video-fast-forward-indicator { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.7); color: #fff; padding: 12px 24px; border-radius: 8px; font-size: 32px; font-weight: bold; pointer-events: none; z-index: 100; }
.ms-video-ocr-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 60px;
pointer-events: none;
z-index: 10;
}
.ms-ocr-bbox {
position: absolute;
border: 2px solid #fbbc04;
background: rgba(251, 188, 4, 0.15);
border-radius: 4px;
}
.ms-ocr-text {
position: absolute;
bottom: -22px;
left: 0;
background: rgba(0, 0, 0, 0.85);
color: #fbbc04;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.ms-video-face-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 60px;
pointer-events: none;
z-index: 11;
}
.ms-face-bbox {
position: absolute;
border: 2px solid #4285f4;
background: rgba(66, 133, 244, 0.1);
border-radius: 6px;
}
.ms-face-label {
position: absolute;
bottom: -20px;
left: 0;
background: #4285f4;
color: #fff;
padding: 2px 8px;
border-radius: 3px;
font-size: 10px;
white-space: nowrap;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}
.ms-video-pose-overlay {
position: absolute;
top: 12px;
right: 12px;
z-index: 12;
}
.ms-pose-indicator {
background: rgba(156, 39, 176, 0.9);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.ms-pose-icon {
font-size: 14px;
}
.ms-video-subtitle-bar {
background: rgba(0, 0, 0, 0.92);
padding: 10px 20px;
border-radius: 0 0 12px 12px;
margin-top: 8px;
}
.ms-subtitle-text {
color: #fff;
font-size: 13px;
line-height: 1.5;
text-align: center;
}
.ms-subtitle-meta {
color: rgba(255, 255, 255, 0.5);
font-size: 10px;
text-align: right;
margin-top: 3px;
}
.ms-video-loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 20px; color: #5f6368; font-size: 13px; }
.ms-video-loading-spinner { width: 18px; height: 18px; border: 2px solid #e8eaed; border-top-color: #1a56db; border-radius: 50%; animation: ms-spin .7s linear infinite; }
@keyframes ms-spin { to { transform: rotate(360deg); } }
.ms-video-timeline-wrap { padding: 14px 0 6px; }
.ms-video-tl-bar { position: relative; width: 100%; height: 4px; background: rgba(255,255,255,0.15); border-radius: 999px; margin-bottom: 8px; overflow: visible; cursor: pointer; }
.ms-video-tl-dot { position: absolute; top: 50%; transform: translate(0, -50%); height: 8px; border-radius: 4px; background: #9aa0a6; cursor: pointer; transition: height .12s, background .12s; z-index: 2; }
.ms-video-tl-dot:hover { height: 12px; background: #fff; }
.ms-video-tl-dot.active { background: #4285f4; box-shadow: 0 0 0 2.5px rgba(66,133,244,0.35); }
.ms-video-tl-labels { display: flex; justify-content: space-between; font-size: 10px; color: rgba(255,255,255,0.45); }
.ms-video-tl-tip { display: none; position: absolute; bottom: 16px; transform: translateX(-50%); background: rgba(0,0,0,.75); color: #fff; font-size: 10px; padding: 2px 7px; border-radius: 4px; white-space: nowrap; pointer-events: none; z-index: 10; }
.ms-video-tl-dot:hover .ms-video-tl-tip { display: block; }
.ms-video-tl-position { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 2px; height: 16px; background: #1a56db; border-radius: 1px; z-index: 3; pointer-events: none; }
.ms-video-tl-position::after { content: ''; position: absolute; top: -4px; left: 50%; transform: translateX(-50%); width: 8px; height: 8px; background: #1a56db; border-radius: 50%; }
.ms-video-tl-pos-tip { display: none; position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); background: #1a56db; color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 4px; white-space: nowrap; pointer-events: none; z-index: 10; }
.ms-video-tl-position:hover .ms-video-tl-pos-tip { display: block; }
.ms-video-nav { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; gap: 10px; flex-wrap: wrap; }
.ms-video-seg-info { font-size: 12px; color: rgba(255,255,255,0.9); flex-shrink: 0; font-weight: 500; }
.ms-video-time-aux { font-size: 11px; color: rgba(255,255,255,0.5); margin-left: 6px; }
.ms-video-seg-info2 { font-size: 12px; color: #9aa0a6; flex: 1; text-align: center; }
/* Frame seek controls */
.ms-video-frame-seek {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.ms-video-frame-label {
font-size: 12px;
color: var(--text-secondary, #9aa0a6);
}
.ms-video-frame-input {
width: 70px;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(0, 0, 0, 0.3);
color: var(--text-primary, #e8eaed);
font-size: 13px;
}
.ms-video-frame-input:focus {
outline: none;
border-color: #4285f4;
}
.ms-video-frame-btn {
padding: 4px 12px;
font-size: 12px;
background: #4285f4;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
}
.ms-video-frame-btn:hover { background: #5294f5; }
.ms-video-frame-current {
font-size: 12px;
color: var(--text-secondary, #9aa0a6);
font-family: monospace;
}
.ms-video-nav-btn { background: #2c2c2c; border-color: #3c3c3c; color: #e8eaed; }
.ms-video-nav-btn:hover { background: #3c3c3c; }
.ms-video-nav-btn:disabled { opacity: 0.35; cursor: default; }
/* Main Timeline */
.ms-video-tl-main { cursor: pointer; position: relative; height: 8px; background: rgba(255,255,255,0.15); border-radius: 4px; }
.ms-video-tl-progress { position: absolute; left: 0; top: 0; height: 100%; background: #4285f4; border-radius: 4px; transition: width 0.1s; }
/* Mark Timelines */
.ms-video-mark-timelines { margin-top: 8px; }
.ms-video-mark-tl-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.ms-video-mark-tl-label { font-size: 10px; color: var(--text-secondary, #9aa0a6); min-width: 50px; text-align: right; }
.ms-video-mark-tl-bar { flex: 1; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; position: relative; cursor: pointer; }
.ms-video-mark-tl-bar .ms-video-tl-position { height: 6px; }
.ms-video-mark-tl-mark { position: absolute; top: 0; height: 100%; border-radius: 3px; cursor: pointer; opacity: 0.8; transition: opacity 0.2s, transform 0.2s; }
.ms-video-mark-tl-mark[data-tag="face"] { background: #4285f4; }
.ms-video-mark-tl-mark[data-tag="asrx"] { background: #34a853; }
.ms-video-mark-tl-mark[data-tag="ocr"] { background: #fbbc04; }
.ms-video-mark-tl-mark[data-tag="pose"] { background: #9c27b0; }
.ms-video-mark-tl-mark:hover { opacity: 1; transform: scaleY(1.6); }
.ms-video-mark-tl-mark.ms-mark-active { opacity: 1; box-shadow: 0 0 6px currentColor; transform: scaleY(1.6); }
/* Mark Controls */
.ms-video-mark-controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.ms-proc-toggles {
display: flex;
align-items: center;
gap: 12px;
margin-left: 12px;
padding-left: 12px;
border-left: 1px solid rgba(255, 255, 255, 0.2);
}
.ms-proc-toggle {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.ms-proc-toggle input {
width: 14px;
height: 14px;
cursor: pointer;
}
.ms-proc-toggle-label {
font-size: 11px;
font-weight: 500;
user-select: none;
}
.ms-mark-btn-wrap {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
flex-wrap: wrap;
}
.ms-mark-btn { padding: 6px 14px; font-size: 12px; background: #34a853; border: none; border-radius: 6px; color: white; cursor: pointer; }
.ms-mark-btn:hover { background: #3cb85c; }
.ms-mark-btn.active { background: #ea4335; }
.ms-mark-btn.ms-mark-end { background: #4285f4; }
.ms-mark-btn.ms-mark-cancel { background: #666; padding: 6px 10px; }
.ms-mark-filter { padding: 6px 10px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(0,0,0,0.3); color: var(--text-primary, #e8eaed); font-size: 12px; }
.ms-mark-panel-toggle { padding: 6px 14px; font-size: 12px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; color: var(--text-primary, #e8eaed); cursor: pointer; }
/* Marks Panel */
.ms-video-marks-panel { margin-top: 12px; background: rgba(0,0,0,0.3); border-radius: 8px; padding: 12px; max-height: 200px; overflow-y: auto; }
.ms-marks-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-weight: 600; color: var(--text-primary, #e8eaed); }
.ms-marks-close { background: none; border: none; color: var(--text-secondary, #9aa0a6); cursor: pointer; font-size: 16px; }
.ms-marks-list { display: flex; flex-direction: column; gap: 6px; }
.ms-mark-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: rgba(255,255,255,0.05); border-radius: 6px; cursor: pointer; transition: background 0.2s; }
.ms-mark-item:hover { background: rgba(255,255,255,0.1); }
.ms-mark-id { font-size: 11px; color: var(--text-secondary, #9aa0a6); min-width: 30px; }
.ms-mark-tag { font-size: 10px; padding: 2px 8px; border-radius: 10px; color: white; font-weight: 600; }
.ms-mark-range { font-size: 12px; font-family: monospace; color: var(--text-primary, #e8eaed); }
.ms-mark-note { flex: 1; font-size: 12px; color: var(--text-secondary, #9aa0a6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-mark-del { background: none; border: none; color: #ea4335; cursor: pointer; font-size: 12px; padding: 2px 6px; }
.ms-marks-empty { text-align: center; color: var(--text-secondary, #9aa0a6); font-size: 12px; padding: 20px; }
.ms-mark-add-form { display: flex; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1); }
.ms-mark-input { flex: 1; padding: 8px 10px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(0,0,0,0.3); color: var(--text-primary, #e8eaed); font-size: 12px; }
</style>