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>
|
<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 -->
|
<!-- Main Timeline bar -->
|
||||||
<div class="ms-video-timeline-wrap" v-if="!videoLoading">
|
<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-bar ms-video-tl-main" @click="onMainTimelineClick">
|
||||||
@@ -168,6 +191,8 @@ const props = withDefaults(defineProps<{
|
|||||||
title?: string
|
title?: string
|
||||||
simple?: boolean
|
simple?: boolean
|
||||||
useOriginal?: boolean
|
useOriginal?: boolean
|
||||||
|
showAsrx?: boolean
|
||||||
|
showOcr?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
startFrame: 0,
|
startFrame: 0,
|
||||||
endFrame: 0,
|
endFrame: 0,
|
||||||
@@ -177,6 +202,8 @@ const props = withDefaults(defineProps<{
|
|||||||
initialSegmentIdx: 0,
|
initialSegmentIdx: 0,
|
||||||
simple: false,
|
simple: false,
|
||||||
useOriginal: false,
|
useOriginal: false,
|
||||||
|
showAsrx: false,
|
||||||
|
showOcr: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'trace-change', 'segment-change'])
|
const emit = defineEmits(['close', 'trace-change', 'segment-change'])
|
||||||
@@ -205,7 +232,8 @@ interface Mark {
|
|||||||
endFrame?: number
|
endFrame?: number
|
||||||
tag: string
|
tag: string
|
||||||
note?: string
|
note?: string
|
||||||
source?: 'user' | 'face' | 'ocr' | 'auto'
|
source?: 'user' | 'face' | 'ocr' | 'asrx' | 'pose' | 'auto'
|
||||||
|
metadata?: any
|
||||||
}
|
}
|
||||||
const marks = ref<Mark[]>([])
|
const marks = ref<Mark[]>([])
|
||||||
const markingStart = ref<number | null>(null)
|
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
|
// Tag colors
|
||||||
const tagColors: Record<string, string> = {}
|
const tagColors: Record<string, string> = {}
|
||||||
const colorPalette = ['#4285f4', '#ea4335', '#fbbc04', '#34a853', '#9c27b0', '#ff5722', '#00bcd4', '#e91e63']
|
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)
|
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() {
|
function startMark() {
|
||||||
markingStart.value = currentFrame.value
|
markingStart.value = currentFrame.value
|
||||||
}
|
}
|
||||||
@@ -877,6 +964,9 @@ onMounted(async () => {
|
|||||||
// Load face traces as marks
|
// Load face traces as marks
|
||||||
loadTracesAsMarks()
|
loadTracesAsMarks()
|
||||||
|
|
||||||
|
// Load ASRX/OCR marks
|
||||||
|
loadProcessorMarks()
|
||||||
|
|
||||||
if (props.allTraces.length || props.mergedSegments.length) {
|
if (props.allTraces.length || props.mergedSegments.length) {
|
||||||
if (playMode.value === 'continuous') {
|
if (playMode.value === 'continuous') {
|
||||||
await loadContinuous()
|
await loadContinuous()
|
||||||
@@ -948,7 +1038,7 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<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-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 { 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-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-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 { 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: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); }
|
.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%; }
|
.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 { 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; }
|
.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); } }
|
@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-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-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 { 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:hover { opacity: 1; transform: scaleY(1.3); }
|
||||||
.ms-video-mark-tl-mark.ms-mark-active { opacity: 1; box-shadow: 0 0 6px currentColor; }
|
.ms-video-mark-tl-mark.ms-mark-active { opacity: 1; box-shadow: 0 0 6px currentColor; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user