feat(web): 添加远程视频录制功能

This commit is contained in:
TongTongStudio
2026-07-30 09:23:53 +08:00
parent e831ef2795
commit ed84745005
3 changed files with 226 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { store, sendKey, sendResolutionChange } from '../store/controllerStore'; import { store, sendKey, sendResolutionChange, toggleRecording } from '../store/controllerStore';
// Android KeyEvent 键值(与被控端 SystemInputUtils 注入一致) // Android KeyEvent 键值(与被控端 SystemInputUtils 注入一致)
const keys = [ const keys = [
@@ -65,6 +65,18 @@ watch(() => store.currentResolution, (res) => {
<span class="resolution-current" v-if="store.currentResolution"> <span class="resolution-current" v-if="store.currentResolution">
当前: {{ store.currentResolution.width }}×{{ store.currentResolution.height }} 当前: {{ store.currentResolution.width }}×{{ store.currentResolution.height }}
</span> </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> </div>
<button <button
@@ -78,3 +90,41 @@ watch(() => store.currentResolution, (res) => {
</button> </button>
</div> </div>
</template> </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>

View 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;
}
}

View File

@@ -1,8 +1,12 @@
import { reactive, markRaw } from 'vue'; import { reactive, markRaw } from 'vue';
import { SignalingClient } from '../services/SignalingClient'; import { SignalingClient } from '../services/SignalingClient';
import { WebRtcController } from '../services/WebRtcController'; import { WebRtcController } from '../services/WebRtcController';
import { VideoRecorder } from '../services/VideoRecorder';
import { loadProto } from '../proto/controlMessage'; import { loadProto } from '../proto/controlMessage';
// 远程视频录制器(基于浏览器原生 MediaRecorder
const videoRecorder = new VideoRecorder();
// 与 Android/Flutter 端一致的 ICE 配置(请按需替换为自己的 TURN 凭据)。 // 与 Android/Flutter 端一致的 ICE 配置(请按需替换为自己的 TURN 凭据)。
export const DEFAULT_ICE_SERVERS = [ export const DEFAULT_ICE_SERVERS = [
// 公共 TURNrelay 兜底UDP + TCP 两种传输TCP 用于 UDP 被防火墙拦截的网络。 // 公共 TURNrelay 兜底UDP + TCP 两种传输TCP 用于 UDP 被防火墙拦截的网络。
@@ -38,6 +42,10 @@ export const store = reactive({
remoteStream: null, remoteStream: null,
// 被控端上报的当前实际采集分辨率(宽/高/帧率),用于与分辨率下拉框保持一致。 // 被控端上报的当前实际采集分辨率(宽/高/帧率),用于与分辨率下拉框保持一致。
currentResolution: null, currentResolution: null,
// 远程视频录制状态
recording: false,
recordStatus: '',
}); });
let signaling = null; let signaling = null;
@@ -134,8 +142,16 @@ export async function connectToDevice(targetId, authType = null, authValue = nul
store.targetDeviceId = targetId; store.targetDeviceId = targetId;
store.error = ''; store.error = '';
store.statusText = '正在发起连接...'; 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 { try {
webrtc = new WebRtcController({ webrtc = new WebRtcController({
@@ -166,15 +182,72 @@ export async function connectToDevice(targetId, authType = null, authValue = nul
} }
export async function disconnectDevice() { export async function disconnectDevice() {
// 结束控制时若正在录制,先停止并自动保存(触发浏览器下载),再关闭连接。
if (videoRecorder.recording) {
await stopRecording();
}
if (webrtc) { await webrtc.close(); webrtc = null; } if (webrtc) { await webrtc.close(); webrtc = null; }
store.rtcConnected = false; store.rtcConnected = false;
store.dataChannelOpen = false; store.dataChannelOpen = false;
store.remoteStream = null; store.remoteStream = null;
store.stats = null; store.stats = null;
store.currentResolution = null; store.currentResolution = null;
// 注意:保留 recordStatus 的"已保存:文件名"提示,便于用户确认自动保存结果;
// 仅复位 recording 标志,下次连接开始时再清空提示。
store.recording = false;
store.statusText = store.signalingConnected ? '已断开设备连接' : '未连接'; 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() { export function disconnectSignaling() {
disconnectDevice(); disconnectDevice();
if (signaling) { signaling.disconnect(); signaling = null; } if (signaling) { signaling.disconnect(); signaling = null; }