Files
robot-command-center/frontend/src/components/VideoPanel.vue
2026-04-09 15:59:53 +08:00

376 lines
8.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { buildVideoFrameUrl, fetchVideoStatus } from '@/lib/api'
import type { VideoStatus } from '@/types'
const props = defineProps<{
video: VideoStatus | null
}>()
const STATUS_REFRESH_MS = 300
const liveVideo = ref<VideoStatus | null>(props.video)
const frameUrl = ref(buildVideoFrameUrl(0))
const displayVideo = computed(() => liveVideo.value ?? props.video)
const currentFps = computed(() => displayVideo.value?.fps ?? 30)
const canRequestFrames = computed(() => displayVideo.value?.available === true)
const modeLabel = computed(() => {
if (!displayVideo.value) {
return '正在获取视频状态'
}
if (displayVideo.value.source_mode === 'omnisocket-jpeg-live') {
return `${displayVideo.value.fps} FPS 实时接收`
}
if (displayVideo.value.source_mode === 'omnisocket-waiting') {
return '未实时获取真实值'
}
return `${displayVideo.value.fps} FPS`
})
const placeholderText = computed(() => {
if (!displayVideo.value) {
return '正在获取视频状态...'
}
return '未实时获取真实值'
})
const latencyLabels = computed(() => {
const sampleWindowSize = displayVideo.value?.timing?.sample_window_size ?? 10
const samples = displayVideo.value?.timing?.delta_samples_ms ?? []
return Array.from({ length: sampleWindowSize }, (_, index) => samples[index] ?? null)
})
const timingHeadline = computed(() => {
const latest = displayVideo.value?.timing?.latest_delta_ms
if (latest == null) {
return '等待帧尾时间'
}
return `最新 ${latest.toFixed(1)} ms`
})
const timingHint = computed(() => {
const timing = displayVideo.value?.timing
if (!timing?.available) {
return '当前还没有从 JPEG 结尾后的尾字节里解析到时间戳,标签会在收到有效帧尾时间后自动填充。'
}
const unitText = timing.timestamp_unit ? `,单位按 ${timing.timestamp_unit}` : ''
const endiannessText = timing.timestamp_endianness
? `,字节序按 ${timing.timestamp_endianness}`
: ''
return `最近 ${timing.sample_window_size} 个差值样本,面板按 ${STATUS_REFRESH_MS} ms 刷新一组${unitText}${endiannessText}`
})
let frameTimer: number | null = null
let statusTimer: number | null = null
let frameKey = 0
let statusRequestPending = false
async function refreshStatus() {
if (statusRequestPending) {
return
}
statusRequestPending = true
try {
liveVideo.value = await fetchVideoStatus()
} catch {
// 保持当前已显示状态,避免短暂请求失败把面板内容清空。
} finally {
statusRequestPending = false
}
}
function refreshFrame() {
if (!canRequestFrames.value) {
return
}
frameKey += 1
frameUrl.value = buildVideoFrameUrl(frameKey)
}
function startFrameLoop() {
if (frameTimer != null) {
window.clearInterval(frameTimer)
frameTimer = null
}
if (!canRequestFrames.value) {
return
}
refreshFrame()
const intervalMs = Math.max(33, Math.round(1000 / currentFps.value))
frameTimer = window.setInterval(() => {
refreshFrame()
}, intervalMs)
}
function startStatusLoop() {
if (statusTimer != null) {
window.clearInterval(statusTimer)
statusTimer = null
}
void refreshStatus()
statusTimer = window.setInterval(() => {
void refreshStatus()
}, STATUS_REFRESH_MS)
}
onMounted(() => {
startStatusLoop()
startFrameLoop()
})
onUnmounted(() => {
if (frameTimer != null) {
window.clearInterval(frameTimer)
}
if (statusTimer != null) {
window.clearInterval(statusTimer)
}
})
watch(
() => props.video,
(nextVideo) => {
liveVideo.value = nextVideo
},
{ immediate: true },
)
watch([currentFps, canRequestFrames], () => {
startFrameLoop()
})
</script>
<template>
<section class="panel video-panel">
<div class="panel-head">
<div>
<p class="eyebrow">Video</p>
<h2>JPEG 视频流</h2>
</div>
<span class="badge" :class="{ bad: !displayVideo?.available }">
{{ displayVideo?.source_mode ?? 'loading' }}
</span>
</div>
<div class="video-shell">
<img
v-if="canRequestFrames"
class="video-frame"
:src="frameUrl"
alt="Robot jpeg frame stream"
/>
<div v-else class="video-placeholder">
{{ placeholderText }}
</div>
</div>
<div class="stats">
<div class="stat-card">
<span>帧源</span>
<strong>{{ displayVideo?.frame_count ?? '--' }} JPEG</strong>
</div>
<div class="stat-card">
<span>当前模式</span>
<strong>{{ modeLabel }}</strong>
</div>
</div>
<div class="timing-panel">
<div class="timing-head">
<span>帧尾时间差</span>
<strong>{{ timingHeadline }}</strong>
</div>
<div class="timing-grid">
<span
v-for="(sample, index) in latencyLabels"
:key="index"
class="timing-label"
:class="{ empty: sample == null }"
>
{{ sample == null ? '--' : `${sample.toFixed(1)} ms` }}
</span>
</div>
<p class="hint subtle">
{{ timingHint }}
</p>
</div>
<p class="hint">
这里只有在后端已经收到 OmniSocket 的真实 JPEG 帧时才会开始逐帧请求并显示画面
如果当前没有真实帧页面会保持占位提示不再回退测试视频流
</p>
<p class="hint subtle">
当前帧源状态{{ displayVideo?.source_detail ?? '暂无' }}
</p>
</section>
</template>
<style scoped>
.video-panel {
display: grid;
gap: 16px;
}
.panel-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.eyebrow {
margin: 0 0 4px;
color: #5b7aff;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
font-weight: 700;
}
h2 {
margin: 0;
font-size: 24px;
}
.badge {
padding: 8px 12px;
border-radius: 999px;
background: rgba(40, 199, 111, 0.16);
color: #63e6a9;
font-size: 12px;
font-weight: 700;
}
.badge.bad {
background: rgba(255, 107, 107, 0.18);
color: #ffb4b4;
}
.video-shell {
position: relative;
overflow: hidden;
border-radius: 20px;
border: 1px solid rgba(133, 147, 169, 0.28);
background: linear-gradient(180deg, #09111f 0%, #050812 100%);
}
.video-frame {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: #02050d;
}
.video-placeholder {
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 16 / 9;
padding: 24px;
color: #a8b4ce;
text-align: center;
line-height: 1.7;
background:
radial-gradient(circle at top, rgba(91, 122, 255, 0.14), transparent 42%),
#02050d;
}
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.stat-card {
padding: 14px;
border-radius: 16px;
background: rgba(7, 14, 26, 0.78);
border: 1px solid rgba(133, 147, 169, 0.2);
}
.stat-card span {
display: block;
margin-bottom: 8px;
color: #8d99b3;
font-size: 12px;
}
.stat-card strong {
font-size: 18px;
}
.timing-panel {
display: grid;
gap: 12px;
padding: 14px;
border-radius: 18px;
background: rgba(7, 14, 26, 0.88);
border: 1px solid rgba(133, 147, 169, 0.18);
}
.timing-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
color: #cfd7e6;
}
.timing-head span {
color: #8d99b3;
font-size: 12px;
}
.timing-head strong {
font-size: 16px;
}
.timing-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.timing-label {
display: grid;
place-items: center;
min-height: 40px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(91, 122, 255, 0.12);
border: 1px solid rgba(91, 122, 255, 0.28);
color: #dce4ff;
font-size: 12px;
font-weight: 700;
}
.timing-label.empty {
background: rgba(133, 147, 169, 0.08);
border-color: rgba(133, 147, 169, 0.18);
color: #7e8aa5;
}
.hint {
margin: 0;
color: #8d99b3;
line-height: 1.65;
}
.hint.subtle {
font-size: 13px;
}
@media (max-width: 720px) {
.stats {
grid-template-columns: 1fr;
}
.timing-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>