feat: add ASRX subtitle and OCR bbox overlay to VideoPlayer

Features:
- ASRX subtitle bar (below video)
- OCR bbox overlay (on video frame)
- Multi-track timeline for asrx/ocr/face/pose
- Real-time mark matching (frame-based)

Implementation:
- Add showAsrx/showOcr props
- Add loadProcessorMarks() to fetch processor JSON
- Add currentAsrxMark/currentOcrMarks computed
- Add OCR bbox overlay with text label
- Add ASRX subtitle bar with frame range

Data format (expected):
- ASRX: { segments: [{ start_frame, end_frame, text }] }
- OCR: { frames: [{ frame, text, bbox }] }

Colors:
- face: #4285f4 (blue)
- asrx: #34a853 (green)
- ocr: #fbbc04 (orange)
- pose: #9c27b0 (purple)
This commit is contained in:
2026-07-24 23:11:05 +08:00
parent ab1399b853
commit b1a9c44778

View File

@@ -24,6 +24,29 @@
<video v-if="videoSrc && !videoLoading" ref="videoEl" :src="videoSrc" class="video" controls autoplay playsinline preload="auto" @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate" @error="onVideoError"></video>
<!-- OCR bbox overlay -->
<div v-if="currentOcrMarks.length" class="ms-video-ocr-overlay">
<div
v-for="mark in currentOcrMarks"
:key="mark.id"
class="ms-ocr-bbox"
:style="{
left: (mark.metadata?.bbox?.x || 0) + 'px',
top: (mark.metadata?.bbox?.y || 0) + 'px',
width: (mark.metadata?.bbox?.width || 100) + 'px',
height: (mark.metadata?.bbox?.height || 40) + 'px'
}"
>
<span class="ms-ocr-text">{{ mark.note }}</span>
</div>
</div>
<!-- ASRX subtitle bar -->
<div v-if="currentAsrxMark" class="ms-video-subtitle-bar">
<div class="ms-subtitle-text">{{ currentAsrxMark.note }}</div>
<div class="ms-subtitle-meta">F{{ currentAsrxMark.startFrame }}-F{{ currentAsrxMark.endFrame }}</div>
</div>
<!-- Main Timeline bar -->
<div class="ms-video-timeline-wrap" v-if="!videoLoading">
<div class="ms-video-tl-bar ms-video-tl-main" @click="onMainTimelineClick">
@@ -168,6 +191,8 @@ const props = withDefaults(defineProps<{
title?: string
simple?: boolean
useOriginal?: boolean
showAsrx?: boolean
showOcr?: boolean
}>(), {
startFrame: 0,
endFrame: 0,
@@ -177,6 +202,8 @@ const props = withDefaults(defineProps<{
initialSegmentIdx: 0,
simple: false,
useOriginal: false,
showAsrx: false,
showOcr: false,
})
const emit = defineEmits(['close', 'trace-change', 'segment-change'])
@@ -205,7 +232,8 @@ interface Mark {
endFrame?: number
tag: string
note?: string
source?: 'user' | 'face' | 'ocr' | 'auto'
source?: 'user' | 'face' | 'ocr' | 'asrx' | 'pose' | 'auto'
metadata?: any
}
const marks = ref<Mark[]>([])
const markingStart = ref<number | null>(null)
@@ -236,6 +264,49 @@ function loadTracesAsMarks() {
}
}
// Load ASRX/OCR data as marks
async function loadProcessorMarks() {
if (!props.fileUuid || props.simple) return
try {
// Load ASRX
if (props.showAsrx) {
const asrxData: any = await apiCall('get_processor_json', { fileHash: props.fileUuid, processor: 'asrx' })
if (asrxData && asrxData.segments) {
for (const seg of asrxData.segments) {
marks.value.push({
id: nextMarkId++,
startFrame: seg.start_frame || 0,
endFrame: seg.end_frame,
tag: 'asrx',
note: seg.text,
source: 'asrx'
})
}
}
}
// Load OCR
if (props.showOcr) {
const ocrData: any = await apiCall('get_processor_json', { fileHash: props.fileUuid, processor: 'ocr' })
if (ocrData && ocrData.frames) {
for (const frame of ocrData.frames) {
marks.value.push({
id: nextMarkId++,
startFrame: frame.frame,
tag: 'ocr',
note: frame.text,
source: 'ocr',
metadata: { bbox: frame.bbox }
})
}
}
}
} catch (e) {
console.error('[loadProcessorMarks] Error:', e)
}
}
// Tag colors
const tagColors: Record<string, string> = {}
const colorPalette = ['#4285f4', '#ea4335', '#fbbc04', '#34a853', '#9c27b0', '#ff5722', '#00bcd4', '#e91e63']
@@ -260,6 +331,22 @@ function marksByTag(tag: string): Mark[] {
return marks.value.filter(m => m.tag === tag)
}
const currentAsrxMark = computed(() => {
const frame = currentFrame.value
return marks.value.find(m =>
m.tag === 'asrx' &&
m.startFrame <= frame &&
(m.endFrame || m.startFrame) >= frame
)
})
const currentOcrMarks = computed(() => {
const frame = currentFrame.value
return marks.value.filter(m =>
m.tag === 'ocr' && m.startFrame === frame
)
})
function startMark() {
markingStart.value = currentFrame.value
}
@@ -877,6 +964,9 @@ onMounted(async () => {
// Load face traces as marks
loadTracesAsMarks()
// Load ASRX/OCR marks
loadProcessorMarks()
if (props.allTraces.length || props.mergedSegments.length) {
if (playMode.value === 'continuous') {
await loadContinuous()
@@ -948,7 +1038,7 @@ onUnmounted(() => {
<style scoped>
.ms-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.3); z-index: 999; display: flex; justify-content: center; align-items: flex-start; padding-top: 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 { max-width: 720px; width: 95%; padding: 20px 24px 24px; text-align: left; background: #1a1a1a; position: relative; }
.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; }
@@ -958,6 +1048,59 @@ onUnmounted(() => {
.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-ocr-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 60px;
pointer-events: none;
z-index: 10;
}
.ms-ocr-bbox {
position: absolute;
border: 2px solid #fbbc04;
background: rgba(251, 188, 4, 0.15);
border-radius: 4px;
}
.ms-ocr-text {
position: absolute;
bottom: -22px;
left: 0;
background: rgba(0, 0, 0, 0.85);
color: #fbbc04;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.ms-video-subtitle-bar {
background: rgba(0, 0, 0, 0.92);
padding: 10px 20px;
border-radius: 0 0 12px 12px;
margin-top: 8px;
}
.ms-subtitle-text {
color: #fff;
font-size: 13px;
line-height: 1.5;
text-align: center;
}
.ms-subtitle-meta {
color: rgba(255, 255, 255, 0.5);
font-size: 10px;
text-align: right;
margin-top: 3px;
}
.ms-video-loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 20px; color: #5f6368; font-size: 13px; }
.ms-video-loading-spinner { width: 18px; height: 18px; border: 2px solid #e8eaed; border-top-color: #1a56db; border-radius: 50%; animation: ms-spin .7s linear infinite; }
@keyframes ms-spin { to { transform: rotate(360deg); } }
@@ -1034,6 +1177,10 @@ onUnmounted(() => {
.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[data-tag="face"] { background: #4285f4; }
.ms-video-mark-tl-mark[data-tag="asrx"] { background: #34a853; }
.ms-video-mark-tl-mark[data-tag="ocr"] { background: #fbbc04; }
.ms-video-mark-tl-mark[data-tag="pose"] { background: #9c27b0; }
.ms-video-mark-tl-mark:hover { opacity: 1; transform: scaleY(1.3); }
.ms-video-mark-tl-mark.ms-mark-active { opacity: 1; box-shadow: 0 0 6px currentColor; }