feat: 深入适配 RK628D CSI 采集卡的设备识别、参数读取、自恢复和音频采集

This commit is contained in:
mofeng-git
2026-04-19 11:26:21 +08:00
parent 8eac31f69f
commit 7c703b8b4b
39 changed files with 3261 additions and 769 deletions

View File

@@ -59,7 +59,7 @@ import {
Loader2,
} from 'lucide-vue-next'
const { t } = useI18n()
const { t, te } = useI18n()
const router = useRouter()
const systemStore = useSystemStore()
const configStore = useConfigStore()
@@ -98,6 +98,12 @@ const videoErrorMessage = ref('')
const videoRestarting = ref(false) // Track if video is restarting due to config change
const mjpegFrameReceived = ref(false) // Whether MJPEG stream has received at least one frame
/** From `stream.state_changed`: ok | no_signal | device_lost | device_busy */
type StreamSignalState = 'ok' | 'no_signal' | 'device_lost' | 'device_busy'
const streamSignalState = ref<StreamSignalState>('ok')
const streamSignalReason = ref<string | null>(null)
const streamNextRetryMs = ref<number | null>(null)
// Video aspect ratio (dynamically updated from actual video dimensions)
// Using string format "width/height" to let browser handle the ratio calculation
const videoAspectRatio = ref<string | null>(null)
@@ -644,6 +650,7 @@ function waitForVideoFirstFrame(el: HTMLVideoElement, timeoutMs = 2000): Promise
})
}
/** For WebRTC watch: skip auto-reconnect when these hold. */
function shouldSuppressAutoReconnect(): boolean {
return videoMode.value === 'mjpeg'
|| !isConsoleActive.value
@@ -751,6 +758,17 @@ function handleVideoError() {
return
}
// Expected <img> error while overlay shows no_signal / device_* — do not retry.
if (streamSignalState.value !== 'ok') {
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
}
videoLoading.value = false
mjpegFrameReceived.value = false
return
}
// Count consecutive errors even in grace period
consecutiveErrors++
@@ -993,22 +1011,121 @@ function handleStreamModeSwitching(data: { transition_id: string; to_mode: strin
}
function handleStreamStateChanged(data: any) {
if (data.state === 'error') {
const state = typeof data?.state === 'string' ? data.state : ''
const reason = typeof data?.reason === 'string' && data.reason.length > 0 ? data.reason : null
const nextRetry = typeof data?.next_retry_ms === 'number' && data.next_retry_ms > 0
? data.next_retry_ms
: null
streamSignalReason.value = reason
streamNextRetryMs.value = nextRetry
const previous = streamSignalState.value
switch (state) {
case 'streaming':
case 'ready':
case 'uninitialized':
streamSignalState.value = 'ok'
break
case 'no_signal':
streamSignalState.value = 'no_signal'
break
case 'device_lost':
streamSignalState.value = 'device_lost'
break
case 'device_busy':
streamSignalState.value = 'device_busy'
break
}
if (state === 'error') {
videoError.value = true
videoErrorMessage.value = t('console.streamError')
} else if (data.state === 'recovering' && videoMode.value !== 'mjpeg') {
// Backend is in the DeviceLost recovery loop; start WebRTC reconnect if not already scheduled.
} else if (state === 'no_signal' && videoMode.value !== 'mjpeg') {
cancelWebRTCRecovery()
videoRestarting.value = false
videoError.value = false
videoErrorMessage.value = ''
} else if (state === 'device_busy' && videoMode.value !== 'mjpeg') {
cancelWebRTCRecovery()
videoRestarting.value = true
videoLoading.value = true
videoError.value = false
videoErrorMessage.value = ''
if (previous !== 'device_busy') {
captureFrameOverlay().catch(() => {})
}
} else if (state === 'device_lost' && videoMode.value !== 'mjpeg') {
if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) {
scheduleWebRTCRecovery()
}
} else if (data.state === 'streaming' || data.state === 'no_signal') {
// Backend stream is alive; cancel any pending recovery timers.
if (data.state === 'streaming') {
cancelWebRTCRecovery()
} else if (state === 'streaming') {
cancelWebRTCRecovery()
videoError.value = false
videoErrorMessage.value = ''
videoRestarting.value = false
if (
videoMode.value === 'mjpeg'
&& (previous === 'no_signal' || previous === 'device_lost' || previous === 'device_busy')
) {
refreshVideo()
} else if (
videoMode.value !== 'mjpeg'
&& (previous === 'no_signal' || previous === 'device_busy' || previous === 'device_lost')
) {
if (webrtc.isConnected.value && !webrtc.isConnecting.value) {
void rebindWebRTCVideo().then(() => {
videoLoading.value = false
})
} else if (!webrtc.isConnected.value && !webrtc.isConnecting.value) {
void connectWebRTCSerial('stream recovered').then(async (ok) => {
if (ok) {
await rebindWebRTCVideo()
videoLoading.value = false
} else if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) {
scheduleWebRTCRecovery()
}
})
}
}
}
}
const showSignalOverlay = computed(() => streamSignalState.value !== 'ok')
const signalOverlayInfo = computed(() => {
const reason = streamSignalReason.value
const reasonHintKey = reason ? `console.signal.reason.${reason}` : ''
const hint = reasonHintKey && te(reasonHintKey) ? t(reasonHintKey) : ''
switch (streamSignalState.value) {
case 'no_signal':
return {
title: t('console.signal.noSignal.title'),
detail: t('console.signal.noSignal.detail'),
hint,
tone: 'info' as const,
}
case 'device_lost':
return {
title: t('console.signal.deviceLost.title'),
detail: t('console.signal.deviceLost.detail'),
hint,
tone: 'error' as const,
}
case 'device_busy':
return {
title: t('console.signal.deviceBusy.title'),
detail: t('console.signal.deviceBusy.detail'),
hint,
tone: 'info' as const,
}
default:
return { title: '', detail: '', hint: '', tone: 'info' as const }
}
})
function handleStreamStatsUpdate(data: any) {
// Always update clients count in store (for MJPEG mode display)
if (typeof data.clients === 'number') {
@@ -1177,8 +1294,12 @@ function refreshVideo() {
}
// MJPEG URL with cache-busting timestamp (reactive)
// Only return valid URL when in MJPEG mode to prevent unnecessary requests
const mjpegTimestamp = ref(0) // Start with 0 to prevent initial load
// Only return valid URL when in MJPEG mode and the backend reports a
// healthy stream. When the backend goes offline (no_signal / device_lost
// / device_busy) we deliberately return an empty string so the `<img>`
// tag has no `src` and the 4-state overlay fully owns the video area —
// no more fake placeholder JPEG peeking through.
const mjpegTimestamp = ref(0)
const mjpegUrl = computed(() => {
if (videoMode.value !== 'mjpeg') {
return '' // Don't load MJPEG when in H264 mode
@@ -1186,6 +1307,9 @@ const mjpegUrl = computed(() => {
if (mjpegTimestamp.value === 0) {
return '' // Don't load until refreshVideo() is called
}
if (streamSignalState.value !== 'ok') {
return '' // Backend is offline; let the overlay own the viewport
}
return `${streamApi.getMjpegUrl(myClientId)}&t=${mjpegTimestamp.value}`
})
@@ -1491,21 +1615,27 @@ watch(() => webrtc.state.value, (newState, oldState) => {
webrtcReconnectTimeout = null
}
if (shouldSuppressAutoReconnect()) {
return
}
// Update stream online status based on WebRTC connection state
// Run before `shouldSuppressAutoReconnect()` so `device_busy` / `videoRestarting`
// never blocks clearing the loading overlay when ICE becomes connected.
if (videoMode.value !== 'mjpeg') {
if (newState === 'connected') {
systemStore.setStreamOnline(true)
webrtcReconnectFailures = 0
if (videoLoading.value) {
void rebindWebRTCVideo().then(() => {
videoLoading.value = false
})
}
} else if (newState === 'disconnected' || newState === 'failed') {
// Don't immediately set offline - wait for potential reconnect
// The device_info event will eventually sync the correct state
}
}
if (shouldSuppressAutoReconnect()) {
return
}
// Auto-reconnect when disconnected (but was previously connected)
if (newState === 'disconnected' && oldState === 'connected' && videoMode.value !== 'mjpeg') {
webrtcReconnectTimeout = setTimeout(async () => {
@@ -2584,6 +2714,50 @@ onUnmounted(() => {
</div>
</Transition>
<!--
Canonical 4-state signal overlay (no_signal / device_lost /
device_busy). Fully covers the video area with a solid dim
backdrop so the browser never shows a frozen last frame or a
transparent video element peeking through the MJPEG `<img>`
has its `src` cleared the moment the backend goes offline and
the WebRTC track is simply obscured. Sits below the loading /
error overlays so those take precedence when both apply.
-->
<Transition name="fade">
<div
v-if="showSignalOverlay && !videoLoading && !videoError"
class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-4 transition-opacity duration-300 pointer-events-none"
:class="{
'bg-black/80 backdrop-blur-sm': signalOverlayInfo.tone === 'error',
'bg-black/70 backdrop-blur-sm': signalOverlayInfo.tone !== 'error',
}"
>
<MonitorOff
class="h-10 w-10 sm:h-16 sm:w-16"
:class="{
'text-slate-200': signalOverlayInfo.tone === 'info',
'text-red-300': signalOverlayInfo.tone === 'error',
}"
/>
<div class="text-center max-w-md">
<p
class="font-semibold text-sm sm:text-lg text-white"
>{{ signalOverlayInfo.title }}</p>
<p
class="text-xs sm:text-sm mt-1 sm:mt-2"
:class="{
'text-slate-200/80': signalOverlayInfo.tone === 'info',
'text-red-100/80': signalOverlayInfo.tone === 'error',
}"
>{{ signalOverlayInfo.detail }}</p>
<p
v-if="signalOverlayInfo.hint"
class="text-[11px] sm:text-xs mt-2 text-white/50"
>{{ signalOverlayInfo.hint }}</p>
</div>
</div>
</Transition>
<!-- Error Overlay with smooth transition and detailed info -->
<Transition name="fade">
<div