fix:新增侧相机视频窗口

zlx
zhoulexin 2 weeks ago
parent 0bc2be5a42
commit e8cb4e399c

32
package-lock.json generated

@ -16,7 +16,8 @@
"jszip": "^3.10.1",
"pinia": "^2.3.0",
"vue": "^3.5.38",
"vue-router": "^4.5.0"
"vue-router": "^4.5.0",
"whammy": "^0.0.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
@ -796,6 +797,29 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
@ -2966,6 +2990,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/whammy": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/whammy/-/whammy-0.0.1.tgz",
"integrity": "sha512-H8J4+I5ISuOZXAZiNMZXD71+oZIM6szrgYG8wt4dCpDNEU8mogxPGvgMIFg/BhkuptOLbtW0X34E/YE6a75ujg==",
"license": "BSD-2-Clause"
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",

@ -17,7 +17,8 @@
"jszip": "^3.10.1",
"pinia": "^2.3.0",
"vue": "^3.5.38",
"vue-router": "^4.5.0"
"vue-router": "^4.5.0",
"whammy": "^0.0.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",

@ -1,5 +1,8 @@
<template>
<div class="video-container">
<div class="video-container" ref="containerRef">
<!-- 视频标题 -->
<div v-if="title" class="video-title" :class="{ 'title-secondary': title === '侧相机' }">{{ title }}</div>
<!-- 实时流模式 -->
<WebRtcPlayer
v-if="videoMode === 'live'"
@ -20,22 +23,171 @@
<p class="empty-title">暂无视频画面</p>
<p class="empty-hint">点击上方切换加载实时流或上传离线视频</p>
</div>
<!-- 截图/录屏工具栏仅对第二个视频窗口显示 -->
<div v-if="enableScreenshot || enableRecording" class="media-toolbar">
<el-tooltip content="截图" placement="top">
<el-button size="small" circle @click="takeScreenshot">
<el-icon><Camera /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip :content="recording ? '停止录屏' : '录屏'" placement="top">
<el-button
size="small"
circle
:type="recording ? 'danger' : 'default'"
@click="toggleRecording"
>
<el-icon><VideoCameraFilled /></el-icon>
</el-button>
</el-tooltip>
</div>
<!-- 录屏倒计时/状态提示 -->
<div v-if="recording" class="recording-indicator">
<span class="rec-dot"></span>
<span>REC {{ recordingTime }}</span>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { ref, watch, nextTick, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Camera, VideoCameraFilled } from '@element-plus/icons-vue'
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
const props = defineProps({
videoMode: String,
webrtcUrl: String,
offlineVideoUrl: String,
videoAutoplayTrigger: Number
videoAutoplayTrigger: Number,
title: { type: String, default: '' },
enableScreenshot: { type: Boolean, default: false },
enableRecording: { type: Boolean, default: false }
})
defineEmits(['connectionChange'])
const offlineVideoRef = ref(null)
const containerRef = ref(null)
// ============ ============
function takeScreenshot() {
const container = containerRef.value
if (!container) return
// WebRTC video
let videoEl = container.querySelector('video')
if (!videoEl) return
const canvas = document.createElement('canvas')
canvas.width = videoEl.videoWidth || 640
canvas.height = videoEl.videoHeight || 480
const ctx = canvas.getContext('2d')
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height)
const link = document.createElement('a')
link.download = `screenshot_${Date.now()}.png`
link.href = canvas.toDataURL('image/png')
link.click()
}
// ============ ============
const recording = ref(false)
const mediaRecorder = ref(null)
const recordedChunks = ref([])
const recordingTime = ref('00:00')
let recordingTimer = null
let recordingStartTime = 0
function updateRecordingTime() {
const elapsed = Math.floor((Date.now() - recordingStartTime) / 1000)
const m = String(Math.floor(elapsed / 60)).padStart(2, '0')
const s = String(elapsed % 60).padStart(2, '0')
recordingTime.value = `${m}:${s}`
}
async function toggleRecording() {
if (recording.value) {
stopRecording()
} else {
await startRecording()
}
}
async function startRecording() {
const container = containerRef.value
if (!container) return
// video
const videoEl = container.querySelector('video')
if (!videoEl) return
try {
// WebRTC video
let stream = videoEl.captureStream ? videoEl.captureStream(30) : null
if (!stream && videoEl.srcObject) {
stream = videoEl.srcObject
}
if (!stream) {
ElMessage.warning('无法获取视频流进行录制')
return
}
// MP4 退 WebM
const mimeTypes = [
'video/mp4;codecs=avc1',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm'
]
let selectedMimeType = ''
for (const mt of mimeTypes) {
if (MediaRecorder.isTypeSupported(mt)) {
selectedMimeType = mt
break
}
}
if (!selectedMimeType) {
ElMessage.warning('当前浏览器不支持录屏')
return
}
const isMp4 = selectedMimeType.startsWith('video/mp4')
recordedChunks.value = []
const recorder = new MediaRecorder(stream, { mimeType: selectedMimeType })
recorder.ondataavailable = (e) => {
if (e.data.size > 0) recordedChunks.value.push(e.data)
}
recorder.onstop = () => {
const blob = new Blob(recordedChunks.value, { type: selectedMimeType })
const link = document.createElement('a')
link.download = `recording_${Date.now()}.${isMp4 ? 'mp4' : 'webm'}`
link.href = URL.createObjectURL(blob)
link.click()
URL.revokeObjectURL(link.href)
}
recorder.start(100)
mediaRecorder.value = recorder
recording.value = true
recordingStartTime = Date.now()
recordingTimer = setInterval(updateRecordingTime, 1000)
} catch (e) {
ElMessage.error('录屏启动失败: ' + e.message)
}
}
function stopRecording() {
if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
mediaRecorder.value.stop()
}
recording.value = false
if (recordingTimer) {
clearInterval(recordingTimer)
recordingTimer = null
}
recordingTime.value = '00:00'
}
//
watch(() => props.videoAutoplayTrigger, () => {
@ -43,6 +195,13 @@ watch(() => props.videoAutoplayTrigger, () => {
nextTick(() => offlineVideoRef.value?.play())
}
})
onUnmounted(() => {
if (recordingTimer) clearInterval(recordingTimer)
if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
mediaRecorder.value.stop()
}
})
</script>
<style lang="scss" scoped>
@ -54,6 +213,27 @@ watch(() => props.videoAutoplayTrigger, () => {
position: relative;
min-height: 300px;
.video-title {
position: absolute;
top: 8px;
left: 8px;
z-index: 10;
background: linear-gradient(135deg, rgba(64, 158, 255, 0.9), rgba(64, 158, 255, 0.6));
color: #fff;
font-size: 13px;
font-weight: 600;
padding: 3px 12px;
border-radius: 4px;
letter-spacing: 1px;
backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
&.title-secondary {
background: linear-gradient(135deg, rgba(103, 194, 58, 0.9), rgba(103, 194, 58, 0.6));
box-shadow: 0 2px 8px rgba(103, 194, 58, 0.3);
}
}
:deep(.webrtc-wrapper) {
width: 100%;
height: 100%;
@ -99,4 +279,55 @@ watch(() => props.videoAutoplayTrigger, () => {
}
}
}
.media-toolbar {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 6px;
z-index: 10;
.el-button {
background: rgba(0, 0, 0, 0.6);
border: none;
color: #fff;
&:hover {
background: rgba(0, 0, 0, 0.8);
}
&.el-button--danger {
background: rgba(245, 108, 108, 0.8);
}
}
}
.recording-indicator {
position: absolute;
top: 8px;
left: 8px;
display: flex;
align-items: center;
gap: 6px;
background: rgba(0, 0, 0, 0.6);
color: #f56c6c;
font-size: 13px;
font-weight: 600;
padding: 4px 10px;
border-radius: 4px;
z-index: 10;
.rec-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f56c6c;
animation: blink 1s step-end infinite;
}
}
@keyframes blink {
50% { opacity: 0; }
}
</style>

@ -31,12 +31,26 @@
@upload-video="handleVideoUpload"
/>
<VideoPlayer
:video-mode="videoMode"
:webrtc-url="webrtcUrl"
:offline-video-url="offlineVideoUrl"
:video-autoplay-trigger="videoAutoplayTrigger"
/>
<div class="video-row">
<VideoPlayer
:video-mode="videoMode"
:webrtc-url="webrtcUrl"
:offline-video-url="offlineVideoUrl"
:video-autoplay-trigger="videoAutoplayTrigger"
title="主相机"
/>
<VideoPlayer
v-if="videoMode === 'live' && webrtcUrl2"
:video-mode="videoMode"
:webrtc-url="webrtcUrl2"
:offline-video-url="offlineVideoUrl"
:video-autoplay-trigger="videoAutoplayTrigger"
title="侧相机"
:enable-screenshot="true"
:enable-recording="true"
class="secondary-video"
/>
</div>
<DetectionProgress
:visible="videoMode === 'offline' && !!offlineVideoUrl && algorithmRunning"
@ -97,6 +111,7 @@ import { getAlgorithmStatusApi, setAlgorithmStatusApi, saveReportApi } from '@/a
const videoMode = ref('idle')
const streamAddress = ref('rtsp://127.0.0.1:8556/camera2')
const webrtcUrl = ref('')
const webrtcUrl2 = ref('')
const streamRunning = ref(false)
const algorithmEnabled = ref(false)
const isLiveMode = computed(() => videoMode.value === 'live')
@ -179,6 +194,7 @@ function connectRawStream() {
case 'webrtc_stream':
webrtcUrl.value = msg.webrtc_url
webrtcUrl2.value = msg.webrtc_url2 || msg.webrtc_url
addLog('已获取原始 WebRTC 流,开始播放', 'success')
ElMessage.success('原始视频流已连接')
break
@ -270,6 +286,7 @@ function connectDetectStream() {
case 'webrtc_stream':
webrtcUrl.value = msg.webrtc_url
webrtcUrl2.value = msg.webrtc_url2 || msg.webrtc_url
addLog('已获取检测 WebRTC 流,开始播放', 'success')
ElMessage.success('检测流已连接')
break
@ -403,6 +420,7 @@ async function switchStream() {
stopRawStream()
videoMode.value = 'live'
webrtcUrl.value = ''
webrtcUrl2.value = ''
algorithmRunning.value = false
paused.value = false
clearAllLiveData()
@ -427,13 +445,14 @@ async function toggleStream() {
} else {
stopDetectStream()
algorithmRunning.value = false
streamRunning.value = false
videoMode.value = 'idle'
webrtcUrl.value = ''
paused.value = false
ending.value = false
ElMessage.info('视频流已关闭')
addLog('视频流已关闭', 'info')
streamRunning.value = false
videoMode.value = 'idle'
webrtcUrl.value = ''
webrtcUrl2.value = ''
paused.value = false
ending.value = false
ElMessage.info('视频流已关闭')
addLog('视频流已关闭', 'info')
}
} else {
// true
@ -458,6 +477,7 @@ async function toggleStream() {
streamRunning.value = true
videoMode.value = 'live'
webrtcUrl.value = ''
webrtcUrl2.value = ''
clearAllLiveData()
connectProcessWs()
// ""
@ -480,6 +500,7 @@ function handleVideoUpload(file) {
offlineVideoUrl.value = URL.createObjectURL(file.raw)
detectionFile.value = file.raw
videoMode.value = 'offline'
webrtcUrl2.value = ''
resetDetectionState()
flowLogs.value = []
addLog('离线视频已加载','warning')
@ -925,6 +946,24 @@ onUnmounted(() => {
min-height: 0;
}
.video-row {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
.video-container {
flex: 1;
min-width: 0;
min-height: 0;
}
.secondary-video {
position: relative;
}
}
.stats-panel {
display: flex;
flex-wrap: wrap;

Loading…
Cancel
Save