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:
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user