feat: pending identity sorting, face detail modal, video player fps, processor counts
This commit is contained in:
@@ -7,6 +7,13 @@
|
||||
<button class="ms-modal-video-close" @click="close">×</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">← 上一個</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">下一個 →</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">← 上一個</button>
|
||||
<span class="ms-video-seg-info2">{{ currentItemIdx + 1 }} / {{ totalItems }}</span>
|
||||
<button class="ms-fm-btn ms-video-nav-btn" @click="nextItem" :disabled="!canNext">下一個 →</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; }
|
||||
|
||||
Reference in New Issue
Block a user