feat(web): 添加远程视频录制功能
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { store, sendKey, sendResolutionChange } from '../store/controllerStore';
|
||||
import { store, sendKey, sendResolutionChange, toggleRecording } from '../store/controllerStore';
|
||||
|
||||
// Android KeyEvent 键值(与被控端 SystemInputUtils 注入一致)
|
||||
const keys = [
|
||||
@@ -65,6 +65,18 @@ watch(() => store.currentResolution, (res) => {
|
||||
<span class="resolution-current" v-if="store.currentResolution">
|
||||
当前: {{ store.currentResolution.width }}×{{ store.currentResolution.height }}
|
||||
</span>
|
||||
|
||||
<span class="record-sep"></span>
|
||||
|
||||
<button
|
||||
class="record-btn"
|
||||
:class="{ recording: store.recording }"
|
||||
:disabled="!store.rtcConnected"
|
||||
@click="toggleRecording"
|
||||
>
|
||||
{{ store.recording ? '停止录制' : '录制' }}
|
||||
</button>
|
||||
<span class="record-status" v-if="store.recordStatus">{{ store.recordStatus }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -78,3 +90,41 @@ watch(() => store.currentResolution, (res) => {
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.record-sep {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
margin: 0 10px;
|
||||
background: #3a3f4b;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #3a3f4b;
|
||||
border-radius: 6px;
|
||||
background: #2b6cff;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.record-btn:disabled {
|
||||
background: #3a3f4b;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.record-btn.recording {
|
||||
background: #e5484d;
|
||||
}
|
||||
|
||||
.record-status {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
color: #ffb020;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
101
WebRTCControllerWeb/src/services/VideoRecorder.js
Normal file
101
WebRTCControllerWeb/src/services/VideoRecorder.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// 基于浏览器原生 MediaRecorder 录制远端 WebRTC 视频流(recvonly 的被控端画面)。
|
||||
// 输出封装:Chrome/Firefox 为 WebM(VP8/VP9 + Opus);Safari 可出 MP4(H.264)。
|
||||
// 无需第三方库,也不逐帧抽数据——直接把 MediaStream 交给 MediaRecorder。
|
||||
|
||||
function timestamp() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return (
|
||||
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
|
||||
`_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
export class VideoRecorder {
|
||||
constructor() {
|
||||
this.recorder = null;
|
||||
this.chunks = [];
|
||||
this.recording = false;
|
||||
this.startTime = 0;
|
||||
}
|
||||
|
||||
// 浏览器是否支持 MediaRecorder
|
||||
get isSupported() {
|
||||
return typeof MediaRecorder !== 'undefined';
|
||||
}
|
||||
|
||||
// 探测浏览器支持的录制 mimeType(优先 WebM/VP9,回落 WebM/VP8,再回落 MP4)
|
||||
static pickMimeType() {
|
||||
if (typeof MediaRecorder === 'undefined') return null;
|
||||
const candidates = [
|
||||
'video/webm;codecs=vp9,opus',
|
||||
'video/webm;codecs=vp8,opus',
|
||||
'video/webm',
|
||||
'video/mp4',
|
||||
];
|
||||
if (typeof MediaRecorder.isTypeSupported === 'function') {
|
||||
for (const t of candidates) {
|
||||
if (MediaRecorder.isTypeSupported(t)) return t;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// 开始录制;stream 为远端 MediaStream(含视频轨)。成功返回 true。
|
||||
start(stream) {
|
||||
if (this.recording || !stream) return false;
|
||||
if (!this.isSupported) throw new Error('当前浏览器不支持 MediaRecorder 录制');
|
||||
const mimeType = VideoRecorder.pickMimeType();
|
||||
try {
|
||||
this.recorder = mimeType
|
||||
? new MediaRecorder(stream, { mimeType })
|
||||
: new MediaRecorder(stream);
|
||||
} catch (e) {
|
||||
throw new Error('创建 MediaRecorder 失败: ' + (e?.message || e));
|
||||
}
|
||||
this.chunks = [];
|
||||
this.recorder.ondataavailable = (e) => {
|
||||
if (e.data && e.data.size > 0) this.chunks.push(e.data);
|
||||
};
|
||||
// 每秒收集一次分片:即使意外中断也只丢失最近 1 秒,且 final chunk 更完整。
|
||||
this.recorder.start(1000);
|
||||
this.recording = true;
|
||||
this.startTime = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 停止录制,返回 { blob, url, ext, filename, mimeType } 或 null。
|
||||
stop() {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.recorder || !this.recording) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
this.recorder.onstop = () => {
|
||||
const type = this.recorder.mimeType || 'video/webm';
|
||||
const blob = new Blob(this.chunks, { type });
|
||||
const ext = type.includes('mp4') ? 'mp4' : 'webm';
|
||||
const filename = 'rec_' + timestamp() + '.' + ext;
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.recording = false;
|
||||
this.chunks = [];
|
||||
this.recorder = null;
|
||||
resolve({ blob, url, ext, filename, mimeType: type });
|
||||
};
|
||||
this.recorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
// 取消并丢弃(如断开连接时调用,不触发下载)。
|
||||
cancel() {
|
||||
if (this.recorder && this.recording) {
|
||||
try {
|
||||
this.recorder.onstop = null;
|
||||
this.recorder.stop();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
this.recording = false;
|
||||
this.chunks = [];
|
||||
this.recorder = null;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { reactive, markRaw } from 'vue';
|
||||
import { SignalingClient } from '../services/SignalingClient';
|
||||
import { WebRtcController } from '../services/WebRtcController';
|
||||
import { VideoRecorder } from '../services/VideoRecorder';
|
||||
import { loadProto } from '../proto/controlMessage';
|
||||
|
||||
// 远程视频录制器(基于浏览器原生 MediaRecorder)。
|
||||
const videoRecorder = new VideoRecorder();
|
||||
|
||||
// 与 Android/Flutter 端一致的 ICE 配置(请按需替换为自己的 TURN 凭据)。
|
||||
export const DEFAULT_ICE_SERVERS = [
|
||||
// 公共 TURN(relay 兜底):UDP + TCP 两种传输,TCP 用于 UDP 被防火墙拦截的网络。
|
||||
@@ -38,6 +42,10 @@ export const store = reactive({
|
||||
remoteStream: null,
|
||||
// 被控端上报的当前实际采集分辨率(宽/高/帧率),用于与分辨率下拉框保持一致。
|
||||
currentResolution: null,
|
||||
|
||||
// 远程视频录制状态
|
||||
recording: false,
|
||||
recordStatus: '',
|
||||
});
|
||||
|
||||
let signaling = null;
|
||||
@@ -134,8 +142,16 @@ export async function connectToDevice(targetId, authType = null, authValue = nul
|
||||
store.targetDeviceId = targetId;
|
||||
store.error = '';
|
||||
store.statusText = '正在发起连接...';
|
||||
// 清空上一次连接的录制提示。
|
||||
store.recording = false;
|
||||
store.recordStatus = '';
|
||||
|
||||
if (webrtc) { await webrtc.close(); webrtc = null; }
|
||||
if (webrtc) {
|
||||
// 切换目标设备会结束当前控制,若正在录制则先自动保存。
|
||||
if (videoRecorder.recording) await stopRecording();
|
||||
await webrtc.close();
|
||||
webrtc = null;
|
||||
}
|
||||
|
||||
try {
|
||||
webrtc = new WebRtcController({
|
||||
@@ -166,15 +182,72 @@ export async function connectToDevice(targetId, authType = null, authValue = nul
|
||||
}
|
||||
|
||||
export async function disconnectDevice() {
|
||||
// 结束控制时若正在录制,先停止并自动保存(触发浏览器下载),再关闭连接。
|
||||
if (videoRecorder.recording) {
|
||||
await stopRecording();
|
||||
}
|
||||
if (webrtc) { await webrtc.close(); webrtc = null; }
|
||||
store.rtcConnected = false;
|
||||
store.dataChannelOpen = false;
|
||||
store.remoteStream = null;
|
||||
store.stats = null;
|
||||
store.currentResolution = null;
|
||||
// 注意:保留 recordStatus 的"已保存:文件名"提示,便于用户确认自动保存结果;
|
||||
// 仅复位 recording 标志,下次连接开始时再清空提示。
|
||||
store.recording = false;
|
||||
store.statusText = store.signalingConnected ? '已断开设备连接' : '未连接';
|
||||
}
|
||||
|
||||
// 切换远程视频录制:开始 / 停止。
|
||||
export function toggleRecording() {
|
||||
if (store.recording) return stopRecording();
|
||||
return startRecording();
|
||||
}
|
||||
|
||||
// 开始录制当前远端视频流。
|
||||
export function startRecording() {
|
||||
if (store.recording) return;
|
||||
if (!store.remoteStream) {
|
||||
store.error = '尚未接收到视频画面,无法录制';
|
||||
return;
|
||||
}
|
||||
if (!videoRecorder.isSupported) {
|
||||
store.error = '当前浏览器不支持 MediaRecorder 录制';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ok = videoRecorder.start(store.remoteStream);
|
||||
if (ok) {
|
||||
store.recording = true;
|
||||
store.recordStatus = '录制中…';
|
||||
}
|
||||
} catch (e) {
|
||||
store.recordStatus = '';
|
||||
store.error = '开始录制失败: ' + (e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// 停止录制并触发浏览器下载。
|
||||
export async function stopRecording() {
|
||||
const result = await videoRecorder.stop();
|
||||
store.recording = false;
|
||||
if (result) {
|
||||
// 通过 Blob URL 触发浏览器下载(纯前端唯一"保存"途径)。
|
||||
const a = document.createElement('a');
|
||||
a.href = result.url;
|
||||
a.download = result.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
store.recordStatus = '已保存:' + result.filename;
|
||||
// 下载完成后延迟释放 ObjectURL,避免下载被浏览器中断。
|
||||
setTimeout(() => URL.revokeObjectURL(result.url), 30000);
|
||||
} else {
|
||||
store.recordStatus = '录制已停止';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function disconnectSignaling() {
|
||||
disconnectDevice();
|
||||
if (signaling) { signaling.disconnect(); signaling = null; }
|
||||
|
||||
Reference in New Issue
Block a user