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:
2026-07-24 20:19:47 +08:00
parent 299f58e883
commit 5951aca086
38 changed files with 12107 additions and 1666 deletions

View File

@@ -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">&larr; 上一個</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>