feat: frame-based positioning and mark system foundation
Core Changes: - Fix SearchView to use start_frame/end_frame directly (no time*fps conversion) - Add hard_delete support to delete_trace API - VideoPlayer: Main timeline + Mark system foundation - Proxy: Add local routes for auth, media, identity-matches, cluster-results - Add .gitignore to exclude build artifacts and dependencies Design Documents: - Multi-track Mark system design (.opencode/plans/) - Video editing positioning standards research Files Modified: - src/views/SearchView.vue: Frame positioning, ensureMinDuration (240 frames) - src/views/PeopleView.vue: batchDeleteGroups with hard_delete - src/api/index.ts: delete_trace with hard_delete body - src/components/VideoPlayer.vue: Timeline + Mark UI - src-tauri/src/proxy.rs: New local routes - AGENTS.md: Update documentation
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="visible" class="ms-modal-overlay show" @click.self="close">
|
||||
<div v-if="visible" class="ms-modal-overlay show" :style="{ zIndex: zIndexValue }" @click.self="close">
|
||||
<div class="ms-modal ms-modal-video">
|
||||
<div class="ms-modal-video-header">
|
||||
<h3 class="ms-modal-video-title">{{ title }}</h3>
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
<!-- 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>
|
||||
<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 v-if="videoError" class="ms-video-loading">
|
||||
@@ -24,7 +24,37 @@
|
||||
|
||||
<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 -->
|
||||
<!-- 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>0:00</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">
|
||||
<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="goToMark(mark)"
|
||||
:title="`#${mark.id} ${mark.note || ''}`"
|
||||
></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">
|
||||
<div
|
||||
@@ -37,6 +67,9 @@
|
||||
>
|
||||
<span class="ms-video-tl-tip">{{ formatTime(dot.start) }}</span>
|
||||
</div>
|
||||
<div v-if="positionPct !== null" class="ms-video-tl-position" :style="{ left: positionPct + '%' }">
|
||||
<span class="ms-video-tl-pos-tip">{{ formatTime(currentTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-video-tl-labels">
|
||||
<span>{{ formatTime(tlStart) }}</span>
|
||||
@@ -44,7 +77,63 @@
|
||||
</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>
|
||||
|
||||
<!-- 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">← 上一個</button>
|
||||
<span class="ms-video-seg-info2">{{ currentItemIdx + 1 }} / {{ totalItems }}</span>
|
||||
@@ -59,6 +148,14 @@
|
||||
<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'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isAdvanced } = useAdvancedMode()
|
||||
|
||||
const zIndexValue = ref(getNextZIndex())
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
fileUuid: string
|
||||
@@ -70,12 +167,14 @@ const props = withDefaults(defineProps<{
|
||||
initialSegmentIdx?: number
|
||||
title?: string
|
||||
simple?: boolean
|
||||
useOriginal?: boolean
|
||||
}>(), {
|
||||
allTraces: () => [],
|
||||
mergedSegments: () => [],
|
||||
initialTraceIdx: 0,
|
||||
initialSegmentIdx: 0,
|
||||
simple: false,
|
||||
useOriginal: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'trace-change', 'segment-change'])
|
||||
@@ -91,6 +190,143 @@ 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 currentFrame = computed(() => Math.round(currentTime.value * videoFps.value))
|
||||
const totalFrames = computed(() => Math.round(duration.value * videoFps.value))
|
||||
|
||||
// Mark system
|
||||
interface Mark {
|
||||
id: number
|
||||
startFrame: number
|
||||
endFrame?: number
|
||||
tag: string
|
||||
note?: string
|
||||
source?: 'user' | 'face' | 'ocr' | 'auto'
|
||||
}
|
||||
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
|
||||
|
||||
// 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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 visibleTags = computed(() => tagFilter.value ? [tagFilter.value] : allTags.value)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
marks.value.push({
|
||||
id: nextMarkId++,
|
||||
startFrame: start,
|
||||
endFrame: end,
|
||||
tag,
|
||||
note: newMarkNote.value.trim() || undefined
|
||||
})
|
||||
|
||||
// Reset
|
||||
markingStart.value = null
|
||||
newMarkTag.value = ''
|
||||
newMarkNote.value = ''
|
||||
showAddMarkForm.value = false
|
||||
}
|
||||
|
||||
function deleteMark(id: number) {
|
||||
marks.value = marks.value.filter(m => m.id !== id)
|
||||
}
|
||||
|
||||
function goToMark(mark: Mark) {
|
||||
if (!videoEl.value) return
|
||||
videoEl.value.currentTime = mark.startFrame / videoFps.value
|
||||
}
|
||||
|
||||
function isMarkActive(mark: Mark): boolean {
|
||||
const f = currentFrame.value
|
||||
if (mark.endFrame) {
|
||||
return f >= mark.startFrame && f <= mark.endFrame
|
||||
}
|
||||
return f === mark.startFrame
|
||||
}
|
||||
|
||||
function getMarkStyle(mark: Mark): any {
|
||||
const startPct = (mark.startFrame / totalFrames.value) * 100
|
||||
const endPct = mark.endFrame ? (mark.endFrame / totalFrames.value) * 100 : startPct
|
||||
const width = Math.max(0.5, endPct - startPct)
|
||||
return {
|
||||
left: startPct + '%',
|
||||
width: width + '%',
|
||||
background: getTagColor(mark.tag)
|
||||
}
|
||||
}
|
||||
|
||||
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 pendingSeekTime = ref<number | null>(null)
|
||||
@@ -131,6 +367,33 @@ const tlEnd = computed(() => {
|
||||
|
||||
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 positionPct = computed(() => {
|
||||
if (!videoEl.value || duration.value === 0) return null
|
||||
|
||||
const start = continuousStartSec.value
|
||||
const end = continuousEndSec.value
|
||||
const range = end - start || 1
|
||||
|
||||
const pct = ((currentTime.value - start) / range) * 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) => {
|
||||
@@ -154,6 +417,7 @@ const timelineMarkers = computed(() => {
|
||||
end: en,
|
||||
pct: ((st - tlStart.value) / tlRange.value) * 100,
|
||||
w: Math.max(0.5, ((en - st) / tlRange.value) * 100),
|
||||
first_frame: t.first_frame,
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -177,9 +441,10 @@ function isMarkerActive(i: number): boolean {
|
||||
}
|
||||
|
||||
function getTraceFps(t: any): number {
|
||||
if (!t) return 30
|
||||
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
|
||||
if (secDiff <= 0 || frameDiff <= 0) return 30
|
||||
return frameDiff / secDiff
|
||||
}
|
||||
|
||||
@@ -239,9 +504,8 @@ async function switchMode(mode: 'trace' | 'segment' | 'continuous') {
|
||||
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)
|
||||
fps = continuousFps.value
|
||||
absFrame = Math.round(prevTime * fps)
|
||||
}
|
||||
|
||||
playMode.value = mode
|
||||
@@ -249,28 +513,29 @@ async function switchMode(mode: 'trace' | 'segment' | 'continuous') {
|
||||
if (mode === 'continuous') {
|
||||
const firstTrace = props.allTraces[0]
|
||||
const lastTrace = props.allTraces[props.allTraces.length - 1]
|
||||
const fps = getTraceFps(firstTrace)
|
||||
const fps = continuousFps.value
|
||||
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)
|
||||
|
||||
pendingSeekTime.value = targetTrace?.first_sec || targetTrace?.start_time || 0
|
||||
|
||||
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,
|
||||
startFrame: absFrame,
|
||||
endFrame: lastFrame,
|
||||
original: props.useOriginal,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
@@ -294,17 +559,13 @@ async function switchMode(mode: 'trace' | 'segment' | 'continuous') {
|
||||
} 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)
|
||||
pendingSeekTime.value = targetTrace?.first_sec || targetTrace?.start_time || 0
|
||||
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)
|
||||
pendingSeekTime.value = targetSeg.start || 0
|
||||
await loadSegment(targetIdx)
|
||||
}
|
||||
}
|
||||
@@ -322,6 +583,8 @@ async function loadTrace(idx: number) {
|
||||
const stFrame = t.first_frame || 0
|
||||
const enFrame = Math.max(t.last_frame || 0, stFrame + 1)
|
||||
|
||||
pendingSeekTime.value = t.first_sec || t.start_time || 0
|
||||
|
||||
curFileUuid.value = fu
|
||||
const prevVolume = videoEl.value?.volume ?? 1
|
||||
videoLoading.value = true
|
||||
@@ -334,6 +597,7 @@ async function loadTrace(idx: number) {
|
||||
endTime: null,
|
||||
startFrame: stFrame,
|
||||
endFrame: enFrame,
|
||||
original: props.useOriginal,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
@@ -368,6 +632,8 @@ async function loadSegment(idx: number) {
|
||||
const stFrame = s.start_frame || 0
|
||||
const enFrame = Math.max(s.end_frame || 0, stFrame + 1)
|
||||
|
||||
pendingSeekTime.value = s.start || 0
|
||||
|
||||
curFileUuid.value = fu
|
||||
const prevVolume = videoEl.value?.volume ?? 1
|
||||
videoLoading.value = true
|
||||
@@ -380,6 +646,7 @@ async function loadSegment(idx: number) {
|
||||
endTime: null,
|
||||
startFrame: stFrame,
|
||||
endFrame: enFrame,
|
||||
original: props.useOriginal,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
@@ -411,6 +678,8 @@ async function loadContinuous() {
|
||||
const stFrame = firstTrace?.first_frame || 0
|
||||
const enFrame = Math.max(lastTrace?.last_frame || 0, stFrame + 1)
|
||||
|
||||
pendingSeekTime.value = continuousStartSec.value
|
||||
|
||||
curFileUuid.value = fu
|
||||
const prevVolume = videoEl.value?.volume ?? 1
|
||||
videoLoading.value = true
|
||||
@@ -423,6 +692,7 @@ async function loadContinuous() {
|
||||
endTime: null,
|
||||
startFrame: stFrame,
|
||||
endFrame: enFrame,
|
||||
original: props.useOriginal,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
@@ -448,8 +718,7 @@ async function loadContinuous() {
|
||||
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)
|
||||
videoEl.value.currentTime = marker.start
|
||||
currentTraceIdx.value = marker.idx
|
||||
} else if (playMode.value === 'trace') {
|
||||
loadTrace(marker.idx)
|
||||
@@ -499,8 +768,7 @@ function seekToPrevTrace() {
|
||||
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)
|
||||
videoEl.value.currentTime = t.first_sec || t.start_time || 0
|
||||
}
|
||||
|
||||
function seekToNextTrace() {
|
||||
@@ -508,8 +776,7 @@ function seekToNextTrace() {
|
||||
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)
|
||||
videoEl.value.currentTime = t.first_sec || t.start_time || 0
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -517,10 +784,26 @@ function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function goToFrame() {
|
||||
if (!videoEl.value || seekFrame.value === null) return
|
||||
const targetTime = seekFrame.value / videoFps.value
|
||||
videoEl.value.currentTime = Math.min(Math.max(0, targetTime), duration.value)
|
||||
seekFrame.value = null
|
||||
}
|
||||
|
||||
function onLoaded() {
|
||||
const el = videoEl.value
|
||||
if (!el) return
|
||||
|
||||
// Try to detect fps from video
|
||||
// @ts-ignore - webkitVideoDecodedByteCount etc. are non-standard
|
||||
if (el.getVideoPlaybackQuality) {
|
||||
const quality = el.getVideoPlaybackQuality()
|
||||
if (quality.totalVideoFrames > 0 && el.duration > 0) {
|
||||
videoFps.value = Math.round(quality.totalVideoFrames / el.duration)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingSeekTime.value !== null) {
|
||||
el.currentTime = pendingSeekTime.value
|
||||
pendingSeekTime.value = null
|
||||
@@ -537,10 +820,9 @@ function onTimeUpdate() {
|
||||
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)) {
|
||||
if (currentTime.value >= (t.first_sec || 0)) {
|
||||
currentTraceIdx.value = i
|
||||
break
|
||||
}
|
||||
@@ -590,6 +872,9 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Load face traces as marks
|
||||
loadTracesAsMarks()
|
||||
|
||||
if (props.allTraces.length || props.mergedSegments.length) {
|
||||
if (playMode.value === 'continuous') {
|
||||
await loadContinuous()
|
||||
@@ -602,14 +887,10 @@ onMounted(async () => {
|
||||
} else {
|
||||
try {
|
||||
const st = props.startTime ?? 0
|
||||
let et = props.endTime ?? st + 1
|
||||
if (et <= st || et - st < 1) et = st + 1
|
||||
pendingSeekTime.value = st
|
||||
const data = await apiCall('get_video_stream', {
|
||||
uuid: props.fileUuid,
|
||||
startTime: st,
|
||||
endTime: et,
|
||||
startFrame: null,
|
||||
endFrame: null,
|
||||
original: props.useOriginal,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
@@ -635,6 +916,13 @@ watch(() => props.initialTraceIdx, (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) {
|
||||
@@ -671,10 +959,96 @@ onUnmounted(() => {
|
||||
.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-nav { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; gap: 10px; }
|
||||
.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-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.6); flex-shrink: 0; }
|
||||
.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; }
|
||||
.ms-video-tl-main .ms-video-tl-position { width: 12px; height: 12px; background: #fff; border: 2px solid #4285f4; border-radius: 50%; top: 50%; transform: translate(-50%, -50%); }
|
||||
|
||||
/* 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; }
|
||||
.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:hover { opacity: 1; transform: scaleY(1.3); }
|
||||
.ms-video-mark-tl-mark.ms-mark-active { opacity: 1; box-shadow: 0 0 6px currentColor; }
|
||||
|
||||
/* Mark Controls */
|
||||
.ms-video-mark-controls { 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>
|
||||
Reference in New Issue
Block a user