feat: pending identity sorting, face detail modal, video player fps, processor counts

This commit is contained in:
2026-06-24 02:02:33 +08:00
parent 7855983dc1
commit f915aaf794
9 changed files with 1479 additions and 324 deletions

View File

@@ -7,6 +7,13 @@
<button class="ms-modal-video-close" @click="close">&times;</button>
</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')">追蹤</button>
<button :class="{ active: playMode === 'segment' }" @click="switchMode('segment')">片段</button>
<button :class="{ active: playMode === 'continuous' }" @click="switchMode('continuous')">連續</button>
</div>
<div v-if="videoError" class="ms-video-loading">
<p style="color:#f44336">{{ videoError }}</p>
</div>
@@ -15,18 +22,18 @@
<p>Loading video...</p>
</div>
<video v-if="videoSrc && !videoLoading" ref="videoEl" :src="videoSrc" class="video" controls autoplay playsinline @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate" @error="onVideoError"></video>
<video v-if="videoSrc && !videoLoading" ref="videoEl" :src="videoSrc" class="video" controls autoplay playsinline preload="auto" @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate" @error="onVideoError"></video>
<!-- Timeline bar (all raw traces) -->
<div class="ms-video-timeline-wrap" v-if="!simple && allTraces.length && !videoLoading">
<!-- Timeline bar -->
<div class="ms-video-timeline-wrap" v-if="!simple && timelineMarkers.length && !videoLoading">
<div class="ms-video-tl-bar">
<div
v-for="(dot, i) in timelineMarkers"
:key="i"
class="ms-video-tl-dot"
:class="{ active: i === currentTraceIdx }"
:class="{ active: isMarkerActive(i) }"
:style="{ left: dot.pct + '%', width: dot.w + '%' }"
@click="loadTrace(dot.idx)"
@click="onTimelineClick(dot)"
>
<span class="ms-video-tl-tip">{{ formatTime(dot.start) }}</span>
</div>
@@ -38,10 +45,10 @@
</div>
<div class="ms-video-nav" v-if="!videoLoading">
<div v-if="!simple && allTraces.length" style="display:flex;align-items:center;gap:10px;flex:1;">
<button class="ms-fm-btn ms-video-nav-btn" @click="prevTrace" :disabled="currentTraceIdx <= 0">&larr; 上一個</button>
<span class="ms-video-seg-info2">{{ currentTraceIdx + 1 }} / {{ allTraces.length }}</span>
<button class="ms-fm-btn ms-video-nav-btn" @click="nextTrace" :disabled="currentTraceIdx >= allTraces.length - 1">下一個 &rarr;</button>
<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; 上一個</button>
<span class="ms-video-seg-info2">{{ currentItemIdx + 1 }} / {{ totalItems }}</span>
<button class="ms-fm-btn ms-video-nav-btn" @click="nextItem" :disabled="!canNext">下一個 &rarr;</button>
</div>
<span v-else class="ms-video-seg-info">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
</div>
@@ -50,7 +57,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { apiCall } from '@/api'
const props = withDefaults(defineProps<{
@@ -58,16 +65,20 @@ const props = withDefaults(defineProps<{
startTime?: number
endTime?: number
allTraces?: any[]
mergedSegments?: any[]
initialTraceIdx?: number
initialSegmentIdx?: number
title?: string
simple?: boolean
}>(), {
allTraces: () => [],
mergedSegments: () => [],
initialTraceIdx: 0,
initialSegmentIdx: 0,
simple: false,
})
const emit = defineEmits(['close'])
const emit = defineEmits(['close', 'trace-change', 'segment-change'])
const videoEl = ref<HTMLVideoElement | null>(null)
const visible = ref(true)
@@ -78,21 +89,62 @@ 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)
// Timeline computation
const tlStart = computed(() => {
const playMode = ref<'trace' | 'segment' | 'continuous'>('trace')
const pendingSeekTime = ref<number | null>(null)
const continuousStart = computed(() => {
if (!props.allTraces.length) return 0
return props.allTraces[0].first_sec || props.allTraces[0].start_time || 0
})
const tlEnd = computed(() => {
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 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
@@ -106,19 +158,171 @@ const timelineMarkers = computed(() => {
})
})
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)
function isMarkerActive(i: number): boolean {
if (playMode.value === 'segment') return i === currentSegmentIdx.value
return i === currentTraceIdx.value
}
function getTraceFps(t: any): number {
const frameDiff = (t.last_frame || 0) - (t.first_frame || 0)
const secDiff = (t.last_sec || 0) - (t.first_sec || 0)
if (secDiff <= 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') {
const firstTrace = props.allTraces[0]
fps = getTraceFps(firstTrace)
absFrame = (firstTrace.first_frame || 0) + Math.round(prevTime * fps)
}
playMode.value = mode
if (mode === 'continuous') {
const firstTrace = props.allTraces[0]
const lastTrace = props.allTraces[props.allTraces.length - 1]
const fps = getTraceFps(firstTrace)
const firstFrame = firstTrace?.first_frame || 0
const lastFrame = lastTrace?.last_frame || firstFrame + 1
const targetIdx = findTraceIdxForFrame(absFrame)
currentTraceIdx.value = targetIdx
const targetTrace = props.allTraces[targetIdx]
const targetFrame = Math.min(absFrame, lastFrame)
curFileUuid.value = continuousFileUuid.value
const prevVolume = videoEl.value?.volume ?? 1
videoLoading.value = true
videoError.value = ''
videoSrc.value = ''
pendingSeekTime.value = null
try {
const data = await apiCall('get_video_stream', {
uuid: continuousFileUuid.value,
startTime: null,
endTime: null,
startFrame: targetFrame,
endFrame: lastFrame,
})
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)
const targetTrace = props.allTraces[targetIdx]
fps = getTraceFps(targetTrace)
const relFrame = absFrame - (targetTrace?.first_frame || 0)
pendingSeekTime.value = Math.max(0, relFrame / fps)
await loadTrace(targetIdx)
} else if (mode === 'segment') {
const targetIdx = findSegmentIdxForFrame(absFrame)
const targetSeg = props.mergedSegments[targetIdx]
if (props.mergedSegments.length && targetSeg) {
fps = getTraceFps(props.allTraces[targetSeg._startIdx || 0])
const relFrame = absFrame - (targetSeg.start_frame || 0)
pendingSeekTime.value = Math.max(0, relFrame / fps)
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 st = t.first_sec || t.start_time || 0
const en = Math.max(t.last_sec || t.end_time || 0, st + 0.1)
const stFrame = t.first_frame || 0
const enFrame = Math.max(t.last_frame || 0, stFrame + 1)
curFileUuid.value = fu
// Preserve volume across reloads
const prevVolume = videoEl.value?.volume ?? 1
videoLoading.value = true
videoError.value = ''
@@ -126,10 +330,10 @@ async function loadTrace(idx: number) {
try {
const data = await apiCall('get_video_stream', {
uuid: fu,
startTime: st,
endTime: en,
startFrame: null,
endFrame: null,
startTime: null,
endTime: null,
startFrame: stFrame,
endFrame: enFrame,
})
if (typeof data === 'string') {
videoSrc.value = data
@@ -152,6 +356,162 @@ async function loadTrace(idx: number) {
}
}
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)
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,
})
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)
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,
})
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
const seekTime = marker.start - continuousStart.value
videoEl.value.currentTime = Math.max(0, seekTime)
currentTraceIdx.value = marker.idx
} else if (playMode.value === 'trace') {
loadTrace(marker.idx)
} else if (playMode.value === 'segment') {
loadSegment(marker.idx)
}
}
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
const seekTime = (t.first_sec || 0) - continuousStart.value
videoEl.value.currentTime = Math.max(0, seekTime)
}
function seekToNextTrace() {
if (!videoEl.value || currentTraceIdx.value >= props.allTraces.length - 1) return
currentTraceIdx.value++
const t = props.allTraces[currentTraceIdx.value]
if (!t) return
const seekTime = (t.first_sec || 0) - continuousStart.value
videoEl.value.currentTime = Math.max(0, seekTime)
}
function close() {
visible.value = false
emit('close')
@@ -160,13 +520,14 @@ function close() {
function onLoaded() {
const el = videoEl.value
if (!el) return
const t = props.allTraces[currentTraceIdx.value]
if (t) {
const off = props.startTime ?? 0
el.currentTime = (t.first_sec || t.start_time || 0) - off
} else if (props.startTime) {
if (pendingSeekTime.value !== null) {
el.currentTime = pendingSeekTime.value
pendingSeekTime.value = null
} else {
el.currentTime = 0
}
el.play().catch(() => {})
}
@@ -174,6 +535,17 @@ function onTimeUpdate() {
if (!videoEl.value) return
currentTime.value = videoEl.value.currentTime
duration.value = videoEl.value.duration || 0
if (playMode.value === 'continuous') {
const absTime = currentTime.value + continuousStart.value
for (let i = props.allTraces.length - 1; i >= 0; i--) {
const t = props.allTraces[i]
if (absTime >= (t.first_sec || 0)) {
currentTraceIdx.value = i
break
}
}
}
}
function onVideoError() {
@@ -186,14 +558,6 @@ function onVideoError() {
}
}
function prevTrace() {
if (currentTraceIdx.value > 0) loadTrace(currentTraceIdx.value - 1)
}
function nextTrace() {
if (currentTraceIdx.value < props.allTraces.length - 1) loadTrace(currentTraceIdx.value + 1)
}
function togglePlay() {
if (videoEl.value) {
if (videoEl.value.paused) { videoEl.value.play(); isPlaying.value = true }
@@ -225,30 +589,56 @@ onMounted(async () => {
videoLoading.value = false
return
}
try {
const st = props.startTime ?? 0
let et = props.endTime ?? st + 1
if (et <= st || et - st < 1) et = st + 1
const data = await apiCall('get_video_stream', {
uuid: props.fileUuid,
startTime: st,
endTime: et,
startFrame: null,
endFrame: null,
})
if (typeof data === 'string') {
videoSrc.value = data
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 {
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
videoSrc.value = URL.createObjectURL(blob)
await loadTrace(currentTraceIdx.value)
}
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()
emit('trace-change', currentTraceIdx.value)
} else {
try {
const st = props.startTime ?? 0
let et = props.endTime ?? st + 1
if (et <= st || et - st < 1) et = st + 1
const data = await apiCall('get_video_stream', {
uuid: props.fileUuid,
startTime: st,
endTime: et,
startFrame: null,
endFrame: null,
})
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(() => props.initialSegmentIdx, (newIdx) => {
if (newIdx === undefined || newIdx === null) return
if (playMode.value === 'segment' && newIdx !== currentSegmentIdx.value) {
loadSegment(newIdx)
}
})
@@ -258,13 +648,17 @@ onUnmounted(() => {
</script>
<style scoped>
.ms-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.3); z-index: 999; display: grid; place-items: center; }
.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: 140px; }
.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; }
.ms-modal-video-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.ms-modal-video-title { font-size: 14px; font-weight: 600; color: #e8eaed; margin: 0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-modal-video-close { border: none; background: transparent; font-size: 18px; color: #9aa0a6; cursor: pointer; padding: 0; line-height: 1; flex-shrink: 0; }
.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: 60vh; border-radius: 8px; display: block; width: 100%; }
.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; }