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

@@ -213,7 +213,18 @@ export interface VideoEncoderSelfCheckResponse {
export const streamApi = {
status: () =>
request<{
state: 'uninitialized' | 'ready' | 'streaming' | 'no_signal' | 'error'
state:
| 'uninitialized'
| 'ready'
| 'streaming'
| 'no_signal'
| 'no_cable'
| 'no_sync'
| 'out_of_range'
| 'device_lost'
| 'recovering'
| 'device_busy'
| 'error'
device: string | null
format: string | null
resolution: [number, number] | null
@@ -649,6 +660,7 @@ export const configApi = {
}>
}>
usb_bus: string | null
has_signal: boolean
}>
serial: Array<{ path: string; name: string }>
audio: Array<{

View File

@@ -47,6 +47,7 @@ interface VideoDevice {
fps: number[]
}[]
}[]
has_signal?: boolean
}
const props = defineProps<{

View File

@@ -14,7 +14,14 @@ export interface ConsoleEventHandlers {
onStreamModeSwitching?: (data: { transition_id: string; to_mode: string; from_mode: string }) => void
onStreamModeReady?: (data: { transition_id: string; mode: string }) => void
onWebRTCReady?: (data: { codec: string; hardware: boolean; transition_id?: string }) => void
onStreamStateChanged?: (data: { state: string; device?: string | null }) => void
onStreamStateChanged?: (data: {
state: string
device?: string | null
/** Optional fine-grained diagnostic tag (e.g. `no_cable`, `out_of_range`, `recovering`). */
reason?: string | null
/** Optional countdown (ms) until the next backend self-recovery attempt. */
next_retry_ms?: number | null
}) => void
onStreamDeviceLost?: (data: { device: string; reason: string }) => void
onStreamReconnecting?: (data: { device: string; attempt: number }) => void
onStreamRecovered?: (data: { device: string }) => void

View File

@@ -240,6 +240,8 @@ export default {
fps: 'Frame Rate',
selectFps: 'Select FPS',
noVideoDevices: 'No video devices detected',
noSignalDetected: 'No HDMI signal detected. Please connect an HDMI cable and refresh.',
refreshDevices: 'Refresh Devices',
// Audio
audioDevice: 'Audio Device',
selectAudioDevice: 'Select audio capture device',
@@ -310,6 +312,33 @@ export default {
configChanging: 'Applying new configuration...',
videoRestarted: 'Video stream updated',
streamError: 'Stream error',
// Four canonical video states (backend StreamStateChanged: streaming /
// no_signal / device_lost / device_busy). `reason` provides optional
// fine-grained diagnostic sub-text.
signal: {
noSignal: {
title: 'Waiting for video signal',
detail: 'Capture device is ready, waiting for the target to output video',
},
deviceLost: {
title: 'Video device offline',
detail: 'Capture card is not responding, attempting to re-detect…',
},
deviceBusy: {
title: 'Video channel busy',
detail: 'Applying a new configuration or another component is using the device, please wait…',
},
reason: {
no_cable: 'HDMI cable not detected — check the cable and that the target is powered on',
no_sync: 'Unstable signal: timings could not be locked — try a lower resolution or refresh rate',
out_of_range: 'Resolution or refresh rate exceeds capture capability — try 1080p60 or below',
no_signal: 'Capture card is ready, waiting for a picture…',
recovering: 'Reconnecting the video device automatically',
device_lost: 'Video node disappeared, waiting for the driver to recover',
config_changing: 'Applying new configuration',
mode_switching: 'Switching video mode',
},
},
// WebRTC
webrtcConnected: 'WebRTC Connected',
webrtcConnectedDesc: 'Using low-latency H.264 video stream',

View File

@@ -240,6 +240,8 @@ export default {
fps: '帧率',
selectFps: '选择帧率',
noVideoDevices: '未检测到视频设备',
noSignalDetected: '未检测到 HDMI 信号,请连接 HDMI 线缆后刷新。',
refreshDevices: '刷新设备',
// Audio
audioDevice: '音频设备',
selectAudioDevice: '选择音频采集设备',
@@ -310,6 +312,32 @@ export default {
configChanging: '正在应用新配置...',
videoRestarted: '视频流已更新',
streamError: '视频流错误',
// 四档视频状态(对应后端 StreamStateChangedstreaming / no_signal /
// device_lost / device_busy. `reason` 子键可选,用于在副文案中补充细节。
signal: {
noSignal: {
title: '暂无视频信号',
detail: '采集卡已就绪,正在等待被控机画面',
},
deviceLost: {
title: '视频设备已断开',
detail: '采集卡离线,正在尝试重新识别…',
},
deviceBusy: {
title: '视频通道忙',
detail: '正在切换配置或被其他组件占用,请稍候…',
},
reason: {
no_cable: '未检测到 HDMI 线缆,请检查连接或被控机是否已开机',
no_sync: '信号不稳定,无法锁定时序,可尝试降低被控机分辨率/刷新率',
out_of_range: '分辨率或刷新率超出采集卡能力,建议切换到 1080p60 以内',
no_signal: '采集卡已就绪,正在等待画面…',
recovering: '正在自动重连视频设备',
device_lost: '视频节点丢失,等待驱动恢复',
config_changing: '正在应用新配置',
mode_switching: '正在切换视频模式',
},
},
// WebRTC
webrtcConnected: 'WebRTC 已连接',
webrtcConnectedDesc: '正在使用 H.264 低延迟视频流',

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

View File

@@ -35,6 +35,7 @@ import {
Check,
HelpCircle,
Puzzle,
RefreshCw,
} from 'lucide-vue-next'
const { t } = useI18n()
@@ -106,6 +107,7 @@ interface VideoDeviceInfo {
}>
}>
usb_bus: string | null
has_signal: boolean
}
interface AudioDeviceInfo {
@@ -164,6 +166,29 @@ const passwordStrengthColor = computed(() => {
return colors[passwordStrength.value] || colors[0]
})
// Whether the selected video device currently has an HDMI signal
const selectedDeviceHasSignal = computed(() => {
const device = devices.value.video.find((d) => d.path === videoDevice.value)
return device?.has_signal ?? true
})
const refreshingDevices = ref(false)
async function refreshDeviceList() {
refreshingDevices.value = true
try {
const result = await configApi.listDevices()
devices.value = result
if (result.extensions) {
ttydAvailable.value = result.extensions.ttyd_available
}
} catch {
// keep current list
} finally {
refreshingDevices.value = false
}
}
// Computed: available formats for selected video device
const availableFormats = computed(() => {
const device = devices.value.video.find((d) => d.path === videoDevice.value)
@@ -735,6 +760,14 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</Select>
</div>
<div v-if="videoDevice && !selectedDeviceHasSignal" class="flex items-center gap-3 p-3 rounded-lg border border-orange-500/30 bg-orange-500/5 text-sm text-orange-600 dark:text-orange-400">
<p class="flex-1">{{ t('setup.noSignalDetected') }}</p>
<Button variant="outline" size="sm" :disabled="refreshingDevices" @click="refreshDeviceList">
<RefreshCw class="w-4 h-4 mr-1" :class="{ 'animate-spin': refreshingDevices }" />
{{ t('setup.refreshDevices') }}
</Button>
</div>
<div v-if="videoDevice" class="space-y-2">
<div class="flex items-center gap-2">
<Label for="videoFormat">{{ t('setup.videoFormat') }}</Label>