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 { class WebSocketManager {
constructor(url, userId) { constructor(url, userId) {
@ -10,7 +14,8 @@ class WebSocketManager {
this.heartbeatTimer = null; this.heartbeatTimer = null;
this.isManualClose = false; this.isManualClose = false;
this.messageQueue = []; this.messageQueue = [];
this.maxQueueSize = 1; this.processingMessages = false;
this.maxQueueSize = 100;
this.status = false; this.status = false;
} }
@ -28,21 +33,28 @@ class WebSocketManager {
this.status = true; this.status = true;
this.startHeartbeat(); this.startHeartbeat();
this.processMessageQueue(); this.processMessageQueue();
this.dispatch('open');
}; };
this.ws.onmessage = (event) => { this.ws.onmessage = async (event) => {
if (this.messageQueue.length < this.maxQueueSize) { const data = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
this.messageQueue.push(event); if (data instanceof ArrayBuffer && this.isMediaPacket(data)) {
this.processMessageQueue(); this.dispatchMediaPacket(data);
} else { return;
console.warn('消息队列已满,丢弃消息');
} }
if (this.messageQueue.length >= this.maxQueueSize) {
console.warn('WebSocket控制消息队列已满丢弃最旧消息');
this.messageQueue.shift();
}
this.messageQueue.push(data);
this.processMessageQueue();
}; };
this.ws.onclose = () => { this.ws.onclose = () => {
console.log('WebSocket 断开'); console.log('WebSocket 断开');
this.status = false; this.status = false;
this.stopHeartbeat(); this.stopHeartbeat();
this.dispatch('close');
if (!this.isManualClose) { if (!this.isManualClose) {
this.reconnect(); this.reconnect();
} }
@ -53,65 +65,80 @@ class WebSocketManager {
}; };
} }
async processMessageQueue() { isMediaPacket(buffer) {
if (!this.ws || this.messageQueue.length === 0) return; 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 { try {
// 处理消息数据 const view = new DataView(buffer);
console.log('收到消息数据类型:', event.data.constructor.name); const version = view.getUint8(4);
let compressedData; 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 async processMessageQueue() {
if (event.data instanceof Blob) { if (this.processingMessages || !this.ws) return;
console.log('处理 Blob 数据,大小:', event.data.size); this.processingMessages = true;
compressedData = new Uint8Array(await event.data.arrayBuffer()); try {
} else if (event.data instanceof ArrayBuffer) { while (this.messageQueue.length > 0) {
console.log('处理 ArrayBuffer 数据,大小:', event.data.byteLength); const data = this.messageQueue.shift();
compressedData = new Uint8Array(event.data); 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 { } else {
throw new Error('不支持的数据类型: ' + event.data.constructor.name); throw new Error(`不支持的WebSocket数据类型: ${typeof data}`);
} }
if (message.channel) {
// 使用 DecompressionStream 解压 GZIP 数据 this.dispatch(message.channel, message.payload);
const decompressionStream = new DecompressionStream('gzip'); } else if (message.type) {
const writer = decompressionStream.writable.getWriter(); this.dispatch(message.type, message.payload ?? message.content ?? message);
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;
} }
// 将解压后的数据转换为字符串
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) { } catch (err) {
console.error('WebSocket消息解析失败:', err); console.error('WebSocket消息解析失败:', err);
console.error('错误堆栈:', err.stack); } finally {
console.error('原始数据类型:', event.data.constructor.name); this.processingMessages = false;
console.error('原始数据大小:', event.data instanceof Blob ? event.data.size : event.data.byteLength || '未知'); 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> </div>
</el-col> </el-col>
<el-col :span="20" class="video-panel" v-if="form.stereoModule || form.rgbCamera"> <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-container" :class="{ dual: form.stereoModule && form.rgbCamera }">
<div class="stream-item"> <div v-show="form.stereoModule" class="stream-item">
<div class="stream-wrapper"> <div class="stream-wrapper">
<video ref="depthVideo" autoplay muted playsinline></video> <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> </div>
<div class="stream-item"> <div v-show="form.rgbCamera" class="stream-item">
<div class="stream-wrapper"> <div class="stream-wrapper">
<video ref="colorVideo" autoplay muted playsinline></video> <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>
</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>
<el-col v-else :span="20" style="align-self: center;"> <el-col v-else :span="20" style="align-self: center;">
<el-empty description="请选择模式" /> <el-empty description="请选择模式" />
@ -47,18 +37,17 @@
</template> </template>
<script setup> <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 { getRegister } from "@/api/device/register";
import { getRgbStreamUrl } from "@/api/device/camera";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { debounce } from 'lodash'; // lodash
// 1. props
const props = defineProps({ const props = defineProps({
initialDeviceId: { // propcameraId initialDeviceId: {
type: String, type: String,
default: null, default: null,
}, },
initialTerminalId: { // prop terminalId initialTerminalId: {
type: String, type: String,
default: null, default: null,
}, },
@ -78,175 +67,402 @@ const data = reactive({
}); });
const { form } = toRefs(data); const { form } = toRefs(data);
const depthVideo = ref(null); const depthVideo = ref(null);
const depthVideoBuffer = ref(null);
const colorVideo = ref(null); const colorVideo = ref(null);
const colorVideoBuffer = ref(null); const streamStatus = reactive({
const blobUrls = ref([]); getRGBImageStream: 'idle',
const frameData = reactive({ getDepthImageStream: 'idle',
getRGBImageStream: { count: 0, lastTime: 0 },
getDepthImageStream: { count: 0, lastTime: 0 },
}); });
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 statusText = (status) => ({
const mainVideo = mainRef.value; connecting: '连接中',
const bufferVideo = bufferRef.value; live: '实时',
if (!mainVideo || !bufferVideo) { retrying: '重连中',
console.error(`视频元素未找到 (${imageType}): 通道: ${channel}`); error: '播放异常',
return; }[status] || '未连接');
function getVideoRef(method) {
return method === 'getRGBImageStream' ? colorVideo : depthVideo;
} }
const start = performance.now(); 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 { try {
if (typeof data === 'string') { const response = await getRgbStreamUrl(data.terminalId, data.cameraId);
const newSrc = data.startsWith('blob:') ? data : `data:video/mp4;base64,${data}`; if (generation !== rgbStreamGeneration || !data.form.rgbCamera) return;
const relativeUrl = response.data;
// const baseUrl = import.meta.env.VITE_APP_BASE_API.replace(/\/$/, '');
bufferVideo.src = newSrc; const video = colorVideo.value;
bufferVideo.onloadeddata = () => { if (!video) return;
// const player = createMediaPlayer(
if (mainVideo.src && mainVideo.src.startsWith('blob:')) { 'getRGBImageStream',
URL.revokeObjectURL(mainVideo.src); video,
blobUrls.value = blobUrls.value.filter(url => url !== mainVideo.src); '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 = ''; //
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;
frameData[imageType].count++; mediaSource.addEventListener('sourceopen', () => {
const currentTime = performance.now(); if (state.destroyed || state.sourceBuffer) return;
if (frameData[imageType].lastTime && currentTime - frameData[imageType].lastTime >= 1000) { try {
const fps = frameData[imageType].count * 1000 / (currentTime - frameData[imageType].lastTime); const sourceBuffer = mediaSource.addSourceBuffer(mimeType);
console.log(`渲染帧率 (${imageType}): ${fps.toFixed(2)} fps, 通道: ${channel}`); state.sourceBuffer = sourceBuffer;
frameData[imageType].count = 0; sourceBuffer.addEventListener('updateend', () => pumpMediaQueue(state));
frameData[imageType].lastTime = currentTime; sourceBuffer.addEventListener('error', () => restartMediaStream(method));
} else if (!frameData[imageType].lastTime) { state.syncTimer = window.setInterval(() => maintainLivePlayback(state), PLAYBACK_SYNC_INTERVAL_MS);
frameData[imageType].lastTime = currentTime; pumpMediaQueue(state);
} } catch (error) {
console.log(`视频渲染耗时 (${imageType}): ${performance.now() - start}ms, 通道: ${channel}`); console.error('创建视频缓冲区失败:', error);
if (performance.memory) { restartMediaStream(method);
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);
} }
}, { once: true });
return state;
} }
// function appendMediaPacket(method, packet) {
const subscribeDebounced = debounce(async (sub, terminalId, method) => { const video = getVideoRef(method).value;
if (!socket || !terminalId || !data.cameraId) { if (!video) return;
console.warn(`订阅失败: socket=${!!socket}, terminalId=${terminalId}, cameraId=${data.cameraId}, method=${method}`); 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; return;
} }
const channel = `edgeCameraServiceImpl/${method}/${terminalId}/${data.cameraId}`; state.lastSequence = packet.sequence;
console.log(sub ? '订阅' : '取消订阅', channel); state.queue.push(packet.data);
socket.send({ state.queueBytes += packet.data.byteLength;
type: 'channel_subscription', if (state.queueBytes > MAX_QUEUE_BYTES) {
action: sub ? 'subscribe' : 'unsubscribe', console.warn(`视频缓冲积压超过${MAX_QUEUE_BYTES / 1024 / 1024}MB重新建立实时流`);
channel, scheduleRestart(method);
}); return;
}
pumpMediaQueue(state);
}
if (sub) { 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);
}
}
function restartMediaStream(method) {
if (method === 'getRGBImageStream') {
scheduleDirectRgbRestart();
} else {
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(); await nextTick();
const mainRef = method === 'getRGBImageStream' ? colorVideo : depthVideo; const channel = channelFor(method);
const bufferRef = method === 'getRGBImageStream' ? colorVideoBuffer : depthVideoBuffer;
if (!mainRef.value || !bufferRef.value) {
console.error(`视频元素未找到: ${method}, 通道: ${channel}`);
return;
}
// if (enabled) {
if (data.callbacks[channel]) { if (data.callbacks[channel] && !force) return;
socket.off(channel, data.callbacks[channel]); streamStatus[method] = 'connecting';
console.log('清理旧回调:', channel); if (!data.callbacks[channel]) {
} const callback = (payload) => {
if (payload?.media) {
const callback = (data) => { appendMediaPacket(method, payload);
if (data !== '500') { } else if (payload === '500') {
requestAnimationFrame(() => renderVideo(mainRef, bufferRef, data, channel, method)); scheduleRestart(method);
} else {
//
subscribeDebounced(false, terminalId, method);
subscribeDebounced(true, terminalId, method);
} }
}; };
//
socket.on(channel, callback); socket.on(channel, callback);
data.callbacks[channel] = callback; data.callbacks[channel] = callback;
console.log('注册新回调:', channel); }
} else { } else {
clearTimeout(retryTimers.get(method));
retryTimers.delete(method);
}
socket.send({
type: 'channel_subscription',
action: enabled ? 'subscribe' : 'unsubscribe',
channel,
});
if (!enabled) {
if (data.callbacks[channel]) { if (data.callbacks[channel]) {
socket.off(channel, data.callbacks[channel]); socket.off(channel, data.callbacks[channel]);
delete 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) => { watch(() => data.form.stereoModule, async (newVal) => {
if (!data.terminalId || !data.cameraId) return; if (!data.terminalId || !data.cameraId) return;
await nextTick(); await setSubscription(newVal, 'getDepthImageStream');
subscribeDebounced(newVal, data.terminalId, 'getDepthImageStream');
}); });
watch(() => data.form.rgbCamera, async (newVal) => { watch(() => data.form.rgbCamera, async (newVal) => {
if (!data.terminalId || !data.cameraId) return; if (!data.terminalId || !data.cameraId) return;
if (newVal) {
await nextTick(); await nextTick();
subscribeDebounced(newVal, data.terminalId, 'getRGBImageStream'); await startDirectRgbStream();
}); } else {
stopDirectRgbStream();
// 2. 使 watchEffect props
watchEffect(() => {
let deviceId = null;
// 使 props
if (props.initialDeviceId) {
deviceId = props.initialDeviceId;
} }
// 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; { immediate: true }
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;
// });
});
onUnmounted(() => { onUnmounted(() => {
if (socket && data.terminalId && data.cameraId) { socket?.off('open', handleSocketOpen);
const channels = [ socket?.off('close', handleSocketClose);
`edgeCameraServiceImpl/getRGBImageStream/${data.terminalId}/${data.cameraId}`, stopDirectRgbStream();
`edgeCameraServiceImpl/getDepthImageStream/${data.terminalId}/${data.cameraId}`, ['getDepthImageStream'].forEach(method => {
]; clearTimeout(retryTimers.get(method));
channels.forEach(channel => { if (data.terminalId && data.cameraId) {
socket.off(channel, data.callbacks[channel]); const channel = channelFor(method);
console.log('卸载时清理回调:', channel); socket?.send({ type: 'channel_subscription', action: 'unsubscribe', channel });
if (data.callbacks[channel]) socket.off(channel, data.callbacks[channel]);
}
destroyMediaPlayer(method);
}); });
data.callbacks = {}; data.callbacks = {};
}
blobUrls.value.forEach(url => URL.revokeObjectURL(url));
blobUrls.value = [];
}); });
</script> </script>
@ -276,34 +492,19 @@ onUnmounted(() => {
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
overflow: hidden; 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 { .stream-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
min-height: 0;
&.dual {
.stream-item {
min-height: 50%;
}
}
.stream-item { .stream-item {
flex: 1; flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@ -317,10 +518,23 @@ onUnmounted(() => {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
video { video {
width: auto; width: 100%;
height: 100%; height: 100%;
object-fit: contain; 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;
} }
} }
} }