Library: - 添加 checkbox 和鼠标框选功能 - Unregistered 卡片添加快速注册按钮 - 状态标签移至左下方,人脸图标移至左侧中间 - Deep Scan 改为 unregister -> register -> process - 完整国际化(日语、繁体中文、英语) PeopleView: - 修复卡片不显示问题(使用 get_file_traces API) - 移除对 identity_id 字段的依赖 其他: - 添加日语语言包 - 添加 useDragSelect composable - 新增文档:Face Bbox Null Bug Report
1813 lines
61 KiB
Vue
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">×</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">← {{ $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') }} →</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> |