refactor(camera): 重构摄像头视频流处理机制

- 移除原有的双视频元素缓冲机制,改用MediaSource API实现直接流式播放
- 新增RGB和深度图像流的状态管理系统,支持连接、实时、重连、异常状态显示
- 实现直接RGB流播放功能,通过新的getRgbStreamUrl接口获取视频流
- 添加媒体播放器管理机制,支持视频流的创建、销毁和状态维护
- 优化视频播放性能,实现缓冲区管理和实时播放同步
- 简化UI布局结构,移除单一流容器,统一使用流容器组件
- 更新WebSocket插件以支持媒体数据包解析和分发
- 增加重连机制和错误处理,提升视频流播放稳定性
- 移除lodash防抖依赖,改用原生setTimeout实现重连调度
This commit is contained in:
lixiaolong 2026-08-03 09:49:53 +08:00
parent 31027439a4
commit 7b791bc541
2 changed files with 485 additions and 244 deletions

View File

@ -1,4 +1,8 @@
import { throttle } from 'lodash';
const MEDIA_HEADER_SIZE = 26;
const MEDIA_MAGIC = [0x43, 0x4d, 0x56, 0x46]; // CMVF
const MEDIA_MIME_TYPES = {
1: 'video/mp4; codecs="avc1.42C01E"'
};
class WebSocketManager {
constructor(url, userId) {
@ -10,7 +14,8 @@ class WebSocketManager {
this.heartbeatTimer = null;
this.isManualClose = false;
this.messageQueue = [];
this.maxQueueSize = 1;
this.processingMessages = false;
this.maxQueueSize = 100;
this.status = false;
}
@ -28,21 +33,28 @@ class WebSocketManager {
this.status = true;
this.startHeartbeat();
this.processMessageQueue();
this.dispatch('open');
};
this.ws.onmessage = (event) => {
if (this.messageQueue.length < this.maxQueueSize) {
this.messageQueue.push(event);
this.processMessageQueue();
} else {
console.warn('消息队列已满,丢弃消息');
this.ws.onmessage = async (event) => {
const data = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
if (data instanceof ArrayBuffer && this.isMediaPacket(data)) {
this.dispatchMediaPacket(data);
return;
}
if (this.messageQueue.length >= this.maxQueueSize) {
console.warn('WebSocket控制消息队列已满丢弃最旧消息');
this.messageQueue.shift();
}
this.messageQueue.push(data);
this.processMessageQueue();
};
this.ws.onclose = () => {
console.log('WebSocket 断开');
this.status = false;
this.stopHeartbeat();
this.dispatch('close');
if (!this.isManualClose) {
this.reconnect();
}
@ -53,65 +65,80 @@ class WebSocketManager {
};
}
async processMessageQueue() {
if (!this.ws || this.messageQueue.length === 0) return;
isMediaPacket(buffer) {
if (buffer.byteLength < MEDIA_HEADER_SIZE) return false;
const bytes = new Uint8Array(buffer, 0, MEDIA_MAGIC.length);
return MEDIA_MAGIC.every((value, index) => bytes[index] === value);
}
const event = this.messageQueue.shift();
dispatchMediaPacket(buffer) {
try {
// 处理消息数据
console.log('收到消息数据类型:', event.data.constructor.name);
let compressedData;
const view = new DataView(buffer);
const version = view.getUint8(4);
const mediaType = view.getUint8(5);
const timestamp = view.getUint32(8) * 0x100000000 + view.getUint32(12);
const sequence = view.getUint32(16);
const channelLength = view.getUint16(20);
const payloadLength = view.getUint32(22);
const payloadOffset = MEDIA_HEADER_SIZE + channelLength;
if (version !== 1 || payloadOffset + payloadLength !== buffer.byteLength) {
throw new Error('媒体数据包头无效');
}
const channel = new TextDecoder().decode(new Uint8Array(buffer, MEDIA_HEADER_SIZE, channelLength));
const mimeType = MEDIA_MIME_TYPES[mediaType];
if (!mimeType) {
throw new Error(`不支持的媒体类型: ${mediaType}`);
}
this.dispatch(channel, {
media: true,
mimeType,
sequence,
timestamp,
data: buffer.slice(payloadOffset, payloadOffset + payloadLength)
});
} catch (error) {
console.error('WebSocket媒体包解析失败:', error);
}
}
// 处理 Blob 或 ArrayBuffer
if (event.data instanceof Blob) {
console.log('处理 Blob 数据,大小:', event.data.size);
compressedData = new Uint8Array(await event.data.arrayBuffer());
} else if (event.data instanceof ArrayBuffer) {
console.log('处理 ArrayBuffer 数据,大小:', event.data.byteLength);
compressedData = new Uint8Array(event.data);
async processMessageQueue() {
if (this.processingMessages || !this.ws) return;
this.processingMessages = true;
try {
while (this.messageQueue.length > 0) {
const data = this.messageQueue.shift();
let message;
if (typeof data === 'string') {
message = JSON.parse(data);
} else if (data instanceof ArrayBuffer) {
const decompressedStream = new Blob([data])
.stream()
.pipeThrough(new DecompressionStream('gzip'));
const decompressedData = await new Response(decompressedStream).arrayBuffer();
message = JSON.parse(new TextDecoder().decode(decompressedData));
} else {
throw new Error('不支持的数据类型: ' + event.data.constructor.name);
throw new Error(`不支持的WebSocket数据类型: ${typeof data}`);
}
// 使用 DecompressionStream 解压 GZIP 数据
const decompressionStream = new DecompressionStream('gzip');
const writer = decompressionStream.writable.getWriter();
writer.write(compressedData);
writer.close();
const decompressedStream = decompressionStream.readable;
const reader = decompressedStream.getReader();
let decompressedData = new Uint8Array(0);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const newData = new Uint8Array(decompressedData.length + value.length);
newData.set(decompressedData);
newData.set(value, decompressedData.length);
decompressedData = newData;
if (message.channel) {
this.dispatch(message.channel, message.payload);
} else if (message.type) {
this.dispatch(message.type, message.payload ?? message.content ?? message);
}
// 将解压后的数据转换为字符串
const textDecoder = new TextDecoder();
const decompressedString = textDecoder.decode(decompressedData);
console.log('解压后的数据:', JSON.parse(decompressedString));
// 解析 JSON
const { type, channel, payload,timestamp } = JSON.parse(decompressedString);
console.log('后端到前端延迟:', ((new Date()).getTime() - timestamp));
if (channel && this.events[channel]) {
this.events[channel].forEach(callback => callback(payload));
} else if (type && this.events[type]) {
this.events[type].forEach(callback => callback(payload));
}
setTimeout(() => this.processMessageQueue(), 0);
} catch (err) {
console.error('WebSocket 消息解析失败:', err);
console.error('错误堆栈:', err.stack);
console.error('原始数据类型:', event.data.constructor.name);
console.error('原始数据大小:', event.data instanceof Blob ? event.data.size : event.data.byteLength || '未知');
console.error('WebSocket消息解析失败:', err);
} finally {
this.processingMessages = false;
if (this.messageQueue.length > 0) {
queueMicrotask(() => this.processMessageQueue());
}
}
}
dispatch(eventName, payload) {
const callbacks = this.events[eventName];
if (callbacks) {
callbacks.slice().forEach(callback => callback(payload));
}
}

View File

@ -14,30 +14,20 @@
</div>
</el-col>
<el-col :span="20" class="video-panel" v-if="form.stereoModule || form.rgbCamera">
<div v-if="form.stereoModule && form.rgbCamera" class="stream-container">
<div class="stream-item">
<div class="stream-container" :class="{ dual: form.stereoModule && form.rgbCamera }">
<div v-show="form.stereoModule" class="stream-item">
<div class="stream-wrapper">
<video ref="depthVideo" autoplay muted playsinline></video>
<video ref="depthVideoBuffer" autoplay muted playsinline style="display: none;"></video>
<span class="stream-state">{{ statusText(streamStatus.getDepthImageStream) }}</span>
</div>
</div>
<div class="stream-item">
<div v-show="form.rgbCamera" class="stream-item">
<div class="stream-wrapper">
<video ref="colorVideo" autoplay muted playsinline></video>
<video ref="colorVideoBuffer" autoplay muted playsinline style="display: none;"></video>
<span class="stream-state">{{ statusText(streamStatus.getRGBImageStream) }}</span>
</div>
</div>
</div>
<div v-else class="single-stream-container">
<div v-if="form.stereoModule" class="stream-wrapper">
<video ref="depthVideo" width="640" height="480" autoplay muted playsinline></video>
<video ref="depthVideoBuffer" width="640" height="480" autoplay muted playsinline style="display: none;"></video>
</div>
<div v-if="form.rgbCamera" class="stream-wrapper">
<video ref="colorVideo" width="640" height="480" autoplay muted playsinline></video>
<video ref="colorVideoBuffer" width="640" height="480" autoplay muted playsinline style="display: none;"></video>
</div>
</div>
</el-col>
<el-col v-else :span="20" style="align-self: center;">
<el-empty description="请选择模式" />
@ -47,18 +37,17 @@
</template>
<script setup>
import { inject, reactive, toRefs, watch, onMounted, onUnmounted, ref, nextTick } from 'vue';
import { inject, reactive, toRefs, watch, onUnmounted, ref, nextTick } from 'vue';
import { getRegister } from "@/api/device/register";
import { getRgbStreamUrl } from "@/api/device/camera";
import { useRoute } from "vue-router";
import { debounce } from 'lodash'; // lodash
// 1. props
const props = defineProps({
initialDeviceId: { // propcameraId
initialDeviceId: {
type: String,
default: null,
},
initialTerminalId: { // prop terminalId
initialTerminalId: {
type: String,
default: null,
},
@ -78,175 +67,402 @@ const data = reactive({
});
const { form } = toRefs(data);
const depthVideo = ref(null);
const depthVideoBuffer = ref(null);
const colorVideo = ref(null);
const colorVideoBuffer = ref(null);
const blobUrls = ref([]);
const frameData = reactive({
getRGBImageStream: { count: 0, lastTime: 0 },
getDepthImageStream: { count: 0, lastTime: 0 },
const streamStatus = reactive({
getRGBImageStream: 'idle',
getDepthImageStream: 'idle',
});
const mediaPlayers = new Map();
const retryTimers = new Map();
let rgbStreamGeneration = 0;
let rgbStreamRetryTimer = null;
let rgbStreamAbortController = null;
const MAX_QUEUE_BYTES = 2 * 1024 * 1024;
const STARTUP_BUFFER_SECONDS = 0.35;
const TARGET_LIVE_LATENCY_SECONDS = 0.45;
const MAX_PLAYED_BUFFER_SECONDS = 2;
const PLAYBACK_SYNC_INTERVAL_MS = 200;
function renderVideo(mainRef, bufferRef, data, channel, imageType) {
const mainVideo = mainRef.value;
const bufferVideo = bufferRef.value;
if (!mainVideo || !bufferVideo) {
console.error(`视频元素未找到 (${imageType}): 通道: ${channel}`);
return;
}
const statusText = (status) => ({
connecting: '连接中',
live: '实时',
retrying: '重连中',
error: '播放异常',
}[status] || '未连接');
const start = performance.now();
function getVideoRef(method) {
return method === 'getRGBImageStream' ? colorVideo : depthVideo;
}
async function startDirectRgbStream() {
if (!data.terminalId || !data.cameraId || !colorVideo.value) return;
rgbStreamAbortController?.abort();
rgbStreamAbortController = null;
const generation = ++rgbStreamGeneration;
clearTimeout(rgbStreamRetryTimer);
rgbStreamRetryTimer = null;
streamStatus.getRGBImageStream = 'connecting';
try {
if (typeof data === 'string') {
const newSrc = data.startsWith('blob:') ? data : `data:video/mp4;base64,${data}`;
//
bufferVideo.src = newSrc;
bufferVideo.onloadeddata = () => {
//
if (mainVideo.src && mainVideo.src.startsWith('blob:')) {
URL.revokeObjectURL(mainVideo.src);
blobUrls.value = blobUrls.value.filter(url => url !== mainVideo.src);
const response = await getRgbStreamUrl(data.terminalId, data.cameraId);
if (generation !== rgbStreamGeneration || !data.form.rgbCamera) return;
const relativeUrl = response.data;
const baseUrl = import.meta.env.VITE_APP_BASE_API.replace(/\/$/, '');
const video = colorVideo.value;
if (!video) return;
const player = createMediaPlayer(
'getRGBImageStream',
video,
'video/mp4; codecs="avc1.42C01E"'
);
rgbStreamAbortController = new AbortController();
consumeDirectRgbStream(
`${baseUrl}${relativeUrl}`,
generation,
player,
rgbStreamAbortController.signal
);
} catch (error) {
if (generation === rgbStreamGeneration && data.form.rgbCamera) {
console.error('Failed to open the direct RGB video stream:', error);
scheduleDirectRgbRestart();
}
mainVideo.src = bufferVideo.src;
blobUrls.value.push(newSrc);
bufferVideo.src = ''; //
};
frameData[imageType].count++;
const currentTime = performance.now();
if (frameData[imageType].lastTime && currentTime - frameData[imageType].lastTime >= 1000) {
const fps = frameData[imageType].count * 1000 / (currentTime - frameData[imageType].lastTime);
console.log(`渲染帧率 (${imageType}): ${fps.toFixed(2)} fps, 通道: ${channel}`);
frameData[imageType].count = 0;
frameData[imageType].lastTime = currentTime;
} else if (!frameData[imageType].lastTime) {
frameData[imageType].lastTime = currentTime;
}
console.log(`视频渲染耗时 (${imageType}): ${performance.now() - start}ms, 通道: ${channel}`);
if (performance.memory) {
console.log('当前内存使用:', {
usedJSHeapSize: (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
totalJSHeapSize: (performance.memory.totalJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
});
}
} else {
console.error(`未知 payload 类型 (${imageType}):`, typeof data, '通道:', channel);
}
} catch (err) {
console.error(`视频渲染失败 (${imageType}):`, err, '通道:', channel);
}
}
//
const subscribeDebounced = debounce(async (sub, terminalId, method) => {
if (!socket || !terminalId || !data.cameraId) {
console.warn(`订阅失败: socket=${!!socket}, terminalId=${terminalId}, cameraId=${data.cameraId}, method=${method}`);
async function consumeDirectRgbStream(url, generation, player, signal) {
try {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok || !response.body) {
throw new Error(`RGB video stream request failed: HTTP ${response.status}`);
}
const reader = response.body.getReader();
while (generation === rgbStreamGeneration && data.form.rgbCamera) {
const { done, value } = await reader.read();
if (done) break;
if (!value?.byteLength || player.destroyed) continue;
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
player.queue.push(chunk);
player.queueBytes += chunk.byteLength;
if (player.queueBytes > MAX_QUEUE_BYTES) {
throw new Error(`RGB video stream queue exceeded ${MAX_QUEUE_BYTES} bytes`);
}
pumpMediaQueue(player);
}
if (!signal.aborted && generation === rgbStreamGeneration && data.form.rgbCamera) {
scheduleDirectRgbRestart();
}
} catch (error) {
if (error?.name !== 'AbortError' && generation === rgbStreamGeneration && data.form.rgbCamera) {
console.error('Direct RGB video stream interrupted:', error);
scheduleDirectRgbRestart();
}
}
}
function stopDirectRgbStream() {
rgbStreamGeneration++;
clearTimeout(rgbStreamRetryTimer);
rgbStreamRetryTimer = null;
rgbStreamAbortController?.abort();
rgbStreamAbortController = null;
destroyMediaPlayer('getRGBImageStream');
streamStatus.getRGBImageStream = 'idle';
}
function scheduleDirectRgbRestart() {
if (rgbStreamRetryTimer || !data.form.rgbCamera) return;
rgbStreamAbortController?.abort();
rgbStreamAbortController = null;
destroyMediaPlayer('getRGBImageStream');
streamStatus.getRGBImageStream = 'retrying';
rgbStreamRetryTimer = setTimeout(() => {
rgbStreamRetryTimer = null;
startDirectRgbStream();
}, 1000);
}
function createMediaPlayer(method, video, mimeType) {
if (!window.MediaSource || !MediaSource.isTypeSupported(mimeType)) {
streamStatus[method] = 'error';
throw new Error(`浏览器不支持媒体格式: ${mimeType}`);
}
destroyMediaPlayer(method);
const mediaSource = new MediaSource();
const objectUrl = URL.createObjectURL(mediaSource);
const state = {
method,
video,
mimeType,
mediaSource,
sourceBuffer: null,
objectUrl,
queue: [],
queueBytes: 0,
lastSequence: 0,
started: false,
syncTimer: null,
destroyed: false,
};
mediaPlayers.set(method, state);
video.src = objectUrl;
mediaSource.addEventListener('sourceopen', () => {
if (state.destroyed || state.sourceBuffer) return;
try {
const sourceBuffer = mediaSource.addSourceBuffer(mimeType);
state.sourceBuffer = sourceBuffer;
sourceBuffer.addEventListener('updateend', () => pumpMediaQueue(state));
sourceBuffer.addEventListener('error', () => restartMediaStream(method));
state.syncTimer = window.setInterval(() => maintainLivePlayback(state), PLAYBACK_SYNC_INTERVAL_MS);
pumpMediaQueue(state);
} catch (error) {
console.error('创建视频缓冲区失败:', error);
restartMediaStream(method);
}
}, { once: true });
return state;
}
function appendMediaPacket(method, packet) {
const video = getVideoRef(method).value;
if (!video) return;
let state = mediaPlayers.get(method);
if (!state || state.mimeType !== packet.mimeType || state.video !== video) {
state = createMediaPlayer(method, video, packet.mimeType);
}
if (state.lastSequence && packet.sequence !== state.lastSequence + 1) {
console.warn(`视频片段序号中断: ${state.lastSequence} -> ${packet.sequence}`);
scheduleRestart(method);
return;
}
const channel = `edgeCameraServiceImpl/${method}/${terminalId}/${data.cameraId}`;
console.log(sub ? '订阅' : '取消订阅', channel);
socket.send({
type: 'channel_subscription',
action: sub ? 'subscribe' : 'unsubscribe',
channel,
});
if (sub) {
await nextTick();
const mainRef = method === 'getRGBImageStream' ? colorVideo : depthVideo;
const bufferRef = method === 'getRGBImageStream' ? colorVideoBuffer : depthVideoBuffer;
if (!mainRef.value || !bufferRef.value) {
console.error(`视频元素未找到: ${method}, 通道: ${channel}`);
state.lastSequence = packet.sequence;
state.queue.push(packet.data);
state.queueBytes += packet.data.byteLength;
if (state.queueBytes > MAX_QUEUE_BYTES) {
console.warn(`视频缓冲积压超过${MAX_QUEUE_BYTES / 1024 / 1024}MB重新建立实时流`);
scheduleRestart(method);
return;
}
pumpMediaQueue(state);
}
//
if (data.callbacks[channel]) {
socket.off(channel, data.callbacks[channel]);
console.log('清理旧回调:', channel);
function pumpMediaQueue(state) {
const sourceBuffer = state.sourceBuffer;
if (state.destroyed || !sourceBuffer || sourceBuffer.updating) return;
try {
maintainLivePlayback(state);
const buffered = sourceBuffer.buffered;
if (buffered.length > 0 && state.started) {
const oldestBufferedTime = buffered.start(0);
const removeEnd = state.video.currentTime - 1;
if (removeEnd - oldestBufferedTime > MAX_PLAYED_BUFFER_SECONDS) {
sourceBuffer.remove(oldestBufferedTime, removeEnd);
return;
}
}
if (state.queue.length === 0) return;
const chunk = state.queue.shift();
state.queueBytes -= chunk.byteLength;
sourceBuffer.appendBuffer(chunk);
streamStatus[state.method] = 'live';
} catch (error) {
console.error('追加视频片段失败:', error);
restartMediaStream(state.method);
}
}
const callback = (data) => {
if (data !== '500') {
requestAnimationFrame(() => renderVideo(mainRef, bufferRef, data, channel, method));
function restartMediaStream(method) {
if (method === 'getRGBImageStream') {
scheduleDirectRgbRestart();
} else {
//
subscribeDebounced(false, terminalId, method);
subscribeDebounced(true, terminalId, method);
scheduleRestart(method);
}
}
function maintainLivePlayback(state) {
const { sourceBuffer, video } = state;
if (state.destroyed || !sourceBuffer || sourceBuffer.updating || sourceBuffer.buffered.length === 0) return;
const buffered = sourceBuffer.buffered;
const rangeIndex = buffered.length - 1;
const liveStart = buffered.start(rangeIndex);
const liveEnd = buffered.end(rangeIndex);
const bufferedDuration = liveEnd - liveStart;
if (!state.started) {
if (bufferedDuration < STARTUP_BUFFER_SECONDS) return;
video.currentTime = Math.max(liveStart, liveEnd - TARGET_LIVE_LATENCY_SECONDS);
video.playbackRate = 1;
state.started = true;
video.play().catch(() => {});
return;
}
if (video.paused) {
video.play().catch(() => {});
}
if (video.seeking) return;
const latency = liveEnd - video.currentTime;
let playbackRate = 1;
if (latency > 1.4) {
playbackRate = 1.2;
} else if (latency > 1) {
playbackRate = 1.12;
} else if (latency > 0.7) {
playbackRate = 1.05;
} else if (latency < 0.28) {
playbackRate = 0.98;
}
if (Math.abs(video.playbackRate - playbackRate) > 0.001) {
video.playbackRate = playbackRate;
}
}
function destroyMediaPlayer(method) {
const state = mediaPlayers.get(method);
if (!state) return;
state.destroyed = true;
state.queue = [];
state.queueBytes = 0;
if (state.syncTimer) {
clearInterval(state.syncTimer);
state.syncTimer = null;
}
const video = state.video;
if (video) {
video.playbackRate = 1;
video.pause();
video.removeAttribute('src');
video.load();
}
URL.revokeObjectURL(state.objectUrl);
mediaPlayers.delete(method);
}
function channelFor(method) {
return `edgeCameraServiceImpl/${method}/${data.terminalId}/${data.cameraId}`;
}
async function setSubscription(enabled, method, force = false) {
if (!socket || !data.terminalId || !data.cameraId) return;
await nextTick();
const channel = channelFor(method);
if (enabled) {
if (data.callbacks[channel] && !force) return;
streamStatus[method] = 'connecting';
if (!data.callbacks[channel]) {
const callback = (payload) => {
if (payload?.media) {
appendMediaPacket(method, payload);
} else if (payload === '500') {
scheduleRestart(method);
}
};
//
socket.on(channel, callback);
data.callbacks[channel] = callback;
console.log('注册新回调:', channel);
}
} else {
clearTimeout(retryTimers.get(method));
retryTimers.delete(method);
}
socket.send({
type: 'channel_subscription',
action: enabled ? 'subscribe' : 'unsubscribe',
channel,
});
if (!enabled) {
if (data.callbacks[channel]) {
socket.off(channel, data.callbacks[channel]);
delete data.callbacks[channel];
console.log('清理回调:', channel);
}
destroyMediaPlayer(method);
streamStatus[method] = 'idle';
}
}, 300); // 300ms
}
function scheduleRestart(method) {
if (retryTimers.has(method) || !data.form[method === 'getRGBImageStream' ? 'rgbCamera' : 'stereoModule']) return;
streamStatus[method] = 'retrying';
destroyMediaPlayer(method);
const timer = setTimeout(async () => {
retryTimers.delete(method);
await setSubscription(false, method);
await new Promise(resolve => setTimeout(resolve, 500));
if (data.form[method === 'getRGBImageStream' ? 'rgbCamera' : 'stereoModule']) {
await setSubscription(true, method);
}
}, 1000);
retryTimers.set(method, timer);
}
function handleSocketOpen() {
if (data.form.stereoModule && data.terminalId && data.cameraId) {
destroyMediaPlayer('getDepthImageStream');
setSubscription(true, 'getDepthImageStream', true);
}
}
function handleSocketClose() {
destroyMediaPlayer('getDepthImageStream');
if (data.form.stereoModule) {
streamStatus.getDepthImageStream = 'retrying';
}
}
socket?.on('open', handleSocketOpen);
socket?.on('close', handleSocketClose);
//
watch(() => data.form.stereoModule, async (newVal) => {
if (!data.terminalId || !data.cameraId) return;
await nextTick();
subscribeDebounced(newVal, data.terminalId, 'getDepthImageStream');
await setSubscription(newVal, 'getDepthImageStream');
});
watch(() => data.form.rgbCamera, async (newVal) => {
if (!data.terminalId || !data.cameraId) return;
if (newVal) {
await nextTick();
subscribeDebounced(newVal, data.terminalId, 'getRGBImageStream');
});
// 2. 使 watchEffect props
watchEffect(() => {
let deviceId = null;
// 使 props
if (props.initialDeviceId) {
deviceId = props.initialDeviceId;
await startDirectRgbStream();
} else {
stopDirectRgbStream();
}
// props cameraIdterminalId
//
// route.path.split("/")[3] cameraId
if (!props.initialDeviceId && route.path.split("/")[3]) {
deviceId = route.path.split("/")[3];
// getRegister cameraId
// getRegister
});
watch(
() => props.initialDeviceId || route.path.split('/')[3],
async (deviceId) => {
if (!deviceId) return;
try {
const response = await getRegister(deviceId);
data.cameraId = response.data.deviceCode;
data.terminalId = props.initialTerminalId || response.data.idDeDeviceTerminalConfig;
if (data.form.stereoModule) await setSubscription(true, 'getDepthImageStream');
if (data.form.rgbCamera) await startDirectRgbStream();
} catch (error) {
console.error('获取摄像头注册信息失败:', error);
}
getRegister(deviceId).then(res => {
data.cameraId = res.data.deviceCode;
data.terminalId = res.data.idDeDeviceTerminalConfig;
}).catch(error => {
console.error("Error fetching register from route:", error);
});
});
onMounted(() => {
// getRegister(route.path.split("/")[3]).then(res => {
// data.cameraId = res.data.deviceCode;
// data.terminalId = res.data.idDeDeviceTerminalConfig;
// });
});
},
{ immediate: true }
);
onUnmounted(() => {
if (socket && data.terminalId && data.cameraId) {
const channels = [
`edgeCameraServiceImpl/getRGBImageStream/${data.terminalId}/${data.cameraId}`,
`edgeCameraServiceImpl/getDepthImageStream/${data.terminalId}/${data.cameraId}`,
];
channels.forEach(channel => {
socket.off(channel, data.callbacks[channel]);
console.log('卸载时清理回调:', channel);
socket?.off('open', handleSocketOpen);
socket?.off('close', handleSocketClose);
stopDirectRgbStream();
['getDepthImageStream'].forEach(method => {
clearTimeout(retryTimers.get(method));
if (data.terminalId && data.cameraId) {
const channel = channelFor(method);
socket?.send({ type: 'channel_subscription', action: 'unsubscribe', channel });
if (data.callbacks[channel]) socket.off(channel, data.callbacks[channel]);
}
destroyMediaPlayer(method);
});
data.callbacks = {};
}
blobUrls.value.forEach(url => URL.revokeObjectURL(url));
blobUrls.value = [];
});
</script>
@ -276,34 +492,19 @@ onUnmounted(() => {
flex-direction: column;
flex-grow: 1;
overflow: hidden;
.single-stream-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex-grow: 1;
.stream-wrapper {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
video {
width: auto;
height: 100%;
object-fit: contain;
transition: opacity 0.1s ease-in-out; /* 平滑过渡 */
}
}
}
.stream-container {
display: flex;
flex-direction: column;
flex-grow: 1;
min-height: 0;
&.dual {
.stream-item {
min-height: 50%;
}
}
.stream-item {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
@ -317,10 +518,23 @@ onUnmounted(() => {
overflow: hidden;
position: relative;
video {
width: auto;
width: 100%;
height: 100%;
object-fit: contain;
transition: opacity 0.1s ease-in-out; /* 平滑过渡 */
background: #000;
}
.stream-state {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 9px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(0, 0, 0, 0.58);
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
line-height: 18px;
pointer-events: none;
}
}
}