视频录制开发
This commit is contained in:
@@ -21,15 +21,13 @@ export function createCallViewController({ store, chatMessage, notify }) {
|
||||
toggleVideo();
|
||||
}
|
||||
|
||||
function toggleRecording() {
|
||||
const state = store.getState();
|
||||
const currentState = state.session.localUser.mediaState.recording || false;
|
||||
store.updateLocalMedia('recording', !currentState);
|
||||
|
||||
if (!currentState) {
|
||||
notify('\u5f00\u59cb\u5f55\u5236');
|
||||
} else {
|
||||
notify('\u505c\u6b62\u5f55\u5236');
|
||||
async function toggleRecording() {
|
||||
try {
|
||||
const result = await store.toggleRecording();
|
||||
notify(result.message);
|
||||
}
|
||||
catch (error) {
|
||||
notify(error.message || '\u5f55\u5236\u5931\u8d25');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
336
client/public/meeting-recorder.js
Normal file
336
client/public/meeting-recorder.js
Normal file
@@ -0,0 +1,336 @@
|
||||
const DEFAULT_WIDTH = 1280;
|
||||
const DEFAULT_HEIGHT = 720;
|
||||
const DEFAULT_FPS = 30;
|
||||
const MIME_TYPE_CANDIDATES = [
|
||||
{ mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', extension: 'mp4' },
|
||||
{ mimeType: 'video/mp4', extension: 'mp4' },
|
||||
{ mimeType: 'video/webm;codecs=vp9,opus', extension: 'webm' },
|
||||
{ mimeType: 'video/webm;codecs=vp8,opus', extension: 'webm' },
|
||||
{ mimeType: 'video/webm', extension: 'webm' }
|
||||
];
|
||||
|
||||
function getSupportedFormat(mediaRecorderCtor) {
|
||||
if (!mediaRecorderCtor || typeof mediaRecorderCtor.isTypeSupported !== 'function') {
|
||||
return {
|
||||
mimeType: '',
|
||||
extension: 'webm'
|
||||
};
|
||||
}
|
||||
|
||||
return MIME_TYPE_CANDIDATES.find(format => mediaRecorderCtor.isTypeSupported(format.mimeType)) || {
|
||||
mimeType: '',
|
||||
extension: 'webm'
|
||||
};
|
||||
}
|
||||
|
||||
function isElementVisible(element) {
|
||||
if (!element || element.classList.contains('hidden')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function drawVideoCover(context, video, x, y, width, height) {
|
||||
const videoWidth = video.videoWidth || width;
|
||||
const videoHeight = video.videoHeight || height;
|
||||
const sourceRatio = videoWidth / videoHeight;
|
||||
const targetRatio = width / height;
|
||||
let sourceX = 0;
|
||||
let sourceY = 0;
|
||||
let sourceWidth = videoWidth;
|
||||
let sourceHeight = videoHeight;
|
||||
|
||||
if (sourceRatio > targetRatio) {
|
||||
sourceWidth = videoHeight * targetRatio;
|
||||
sourceX = (videoWidth - sourceWidth) / 2;
|
||||
} else {
|
||||
sourceHeight = videoWidth / targetRatio;
|
||||
sourceY = (videoHeight - sourceHeight) / 2;
|
||||
}
|
||||
|
||||
context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
||||
}
|
||||
|
||||
function drawEmptyFrame(context, canvas) {
|
||||
context.fillStyle = '#111827';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = '#9ca3af';
|
||||
context.font = '24px sans-serif';
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText('等待会议画面...', canvas.width / 2, canvas.height / 2);
|
||||
}
|
||||
|
||||
function drawGrid(context, videos, canvas) {
|
||||
const columns = Math.ceil(Math.sqrt(videos.length));
|
||||
const rows = Math.ceil(videos.length / columns);
|
||||
const gap = 8;
|
||||
const tileWidth = (canvas.width - gap * (columns - 1)) / columns;
|
||||
const tileHeight = (canvas.height - gap * (rows - 1)) / rows;
|
||||
|
||||
videos.forEach((video, index) => {
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
const x = column * (tileWidth + gap);
|
||||
const y = row * (tileHeight + gap);
|
||||
drawVideoCover(context, video, x, y, tileWidth, tileHeight);
|
||||
});
|
||||
}
|
||||
|
||||
function drawLocalPreview(context, localVideo, canvas) {
|
||||
const previewWidth = Math.floor(canvas.width * 0.22);
|
||||
const previewHeight = Math.floor(previewWidth * 9 / 16);
|
||||
const margin = 24;
|
||||
const x = canvas.width - previewWidth - margin;
|
||||
const y = canvas.height - previewHeight - margin;
|
||||
|
||||
context.fillStyle = 'rgba(0, 0, 0, 0.4)';
|
||||
context.fillRect(x - 4, y - 4, previewWidth + 8, previewHeight + 8);
|
||||
drawVideoCover(context, localVideo, x, y, previewWidth, previewHeight);
|
||||
}
|
||||
|
||||
function collectStreams({ localStream, remoteStream, remoteStreams } = {}) {
|
||||
return [
|
||||
localStream,
|
||||
remoteStream,
|
||||
...Object.values(remoteStreams || {})
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function collectLiveAudioTracks(streams) {
|
||||
return streams.flatMap(stream => stream.getAudioTracks())
|
||||
.filter(track => track.readyState !== 'ended');
|
||||
}
|
||||
|
||||
export class MeetingRecorder {
|
||||
constructor({
|
||||
documentRef = document,
|
||||
windowRef = window,
|
||||
width = DEFAULT_WIDTH,
|
||||
height = DEFAULT_HEIGHT,
|
||||
fps = DEFAULT_FPS
|
||||
} = {}) {
|
||||
this.document = documentRef;
|
||||
this.window = windowRef;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.fps = fps;
|
||||
this.mediaRecorder = null;
|
||||
this.chunks = [];
|
||||
this.animationFrameId = null;
|
||||
this.audioContext = null;
|
||||
this.audioSources = [];
|
||||
this.recordingStream = null;
|
||||
this.connectionId = '';
|
||||
}
|
||||
|
||||
isSupported() {
|
||||
return Boolean(
|
||||
this.window.MediaRecorder &&
|
||||
this.document.createElement('canvas').captureStream
|
||||
);
|
||||
}
|
||||
|
||||
isRecording() {
|
||||
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
|
||||
}
|
||||
|
||||
async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) {
|
||||
if (this.isRecording()) {
|
||||
throw new Error('会议正在录制中');
|
||||
}
|
||||
|
||||
if (!this.isSupported()) {
|
||||
throw new Error('当前浏览器不支持会议录制');
|
||||
}
|
||||
|
||||
const canvas = this.document.createElement('canvas');
|
||||
canvas.width = this.width;
|
||||
canvas.height = this.height;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('无法创建录制画布');
|
||||
}
|
||||
|
||||
this.connectionId = connectionId || '';
|
||||
this.chunks = [];
|
||||
this.canvas = canvas;
|
||||
this.context = context;
|
||||
|
||||
const canvasStream = canvas.captureStream(this.fps);
|
||||
const streams = collectStreams({ localStream, remoteStream, remoteStreams });
|
||||
const audioTrack = this.createMixedAudioTrack(streams);
|
||||
const tracks = [
|
||||
...canvasStream.getVideoTracks(),
|
||||
...(audioTrack ? [audioTrack] : [])
|
||||
];
|
||||
|
||||
this.recordingStream = new this.window.MediaStream(tracks);
|
||||
try {
|
||||
this.startDrawing();
|
||||
this.startMediaRecorder(this.recordingStream);
|
||||
}
|
||||
catch (error) {
|
||||
this.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.isRecording()) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingStop = { resolve, reject };
|
||||
this.mediaRecorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
createMixedAudioTrack(streams) {
|
||||
const audioTracks = collectLiveAudioTracks(streams);
|
||||
|
||||
if (audioTracks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const AudioContextCtor = this.window.AudioContext || this.window.webkitAudioContext;
|
||||
if (!AudioContextCtor) {
|
||||
return audioTracks[0].clone ? audioTracks[0].clone() : audioTracks[0];
|
||||
}
|
||||
|
||||
this.audioContext = new AudioContextCtor();
|
||||
const destination = this.audioContext.createMediaStreamDestination();
|
||||
|
||||
audioTracks.forEach(track => {
|
||||
const sourceStream = new this.window.MediaStream([track]);
|
||||
const source = this.audioContext.createMediaStreamSource(sourceStream);
|
||||
source.connect(destination);
|
||||
this.audioSources.push(source);
|
||||
});
|
||||
|
||||
return destination.stream.getAudioTracks()[0] || null;
|
||||
}
|
||||
|
||||
startMediaRecorder(stream) {
|
||||
const MediaRecorderCtor = this.window.MediaRecorder;
|
||||
const format = getSupportedFormat(MediaRecorderCtor);
|
||||
const options = format.mimeType ? { mimeType: format.mimeType } : {};
|
||||
this.fileExtension = format.extension;
|
||||
|
||||
this.mediaRecorder = new MediaRecorderCtor(stream, options);
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
this.chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
this.mediaRecorder.onerror = (event) => {
|
||||
if (this.pendingStop) {
|
||||
this.pendingStop.reject(event.error || new Error('录制失败'));
|
||||
this.pendingStop = null;
|
||||
}
|
||||
this.cleanup();
|
||||
};
|
||||
this.mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType || 'video/webm' });
|
||||
const filename = this.buildFilename();
|
||||
const mimeType = blob.type || this.mediaRecorder.mimeType || 'video/webm';
|
||||
this.cleanup();
|
||||
if (this.pendingStop) {
|
||||
this.pendingStop.resolve({ blob, filename, mimeType });
|
||||
this.pendingStop = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.start(1000);
|
||||
}
|
||||
|
||||
startDrawing() {
|
||||
const draw = () => {
|
||||
this.drawFrame();
|
||||
this.animationFrameId = this.window.requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
}
|
||||
|
||||
drawFrame() {
|
||||
const context = this.context;
|
||||
const canvas = this.canvas;
|
||||
const videos = this.getRecordableVideos();
|
||||
const localVideo = videos.find(video => video.id === 'localVideo');
|
||||
const remoteVideos = videos.filter(video => video !== localVideo);
|
||||
|
||||
context.fillStyle = '#020617';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (remoteVideos.length > 0) {
|
||||
drawGrid(context, remoteVideos, canvas);
|
||||
if (localVideo) {
|
||||
drawLocalPreview(context, localVideo, canvas);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (localVideo) {
|
||||
drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height);
|
||||
return;
|
||||
}
|
||||
|
||||
drawEmptyFrame(context, canvas);
|
||||
}
|
||||
|
||||
getRecordableVideos() {
|
||||
return Array.from(this.document.querySelectorAll('#participantGrid video, #remoteVideo, #localVideo'))
|
||||
.filter(video => video.srcObject && isElementVisible(video) && video.readyState >= 2);
|
||||
}
|
||||
|
||||
download(blob, filename = this.buildFilename()) {
|
||||
const url = this.window.URL.createObjectURL(blob);
|
||||
const link = this.document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
this.document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
this.window.setTimeout(() => {
|
||||
this.window.URL.revokeObjectURL(url);
|
||||
}, 1000);
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
buildFilename() {
|
||||
const datePart = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const meetingPart = this.connectionId ? `-${this.connectionId}` : '';
|
||||
return `meeting-recording${meetingPart}-${datePart}.${this.fileExtension || 'webm'}`;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.animationFrameId) {
|
||||
this.window.cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
if (this.recordingStream) {
|
||||
this.recordingStream.getTracks().forEach(track => track.stop());
|
||||
this.recordingStream = null;
|
||||
}
|
||||
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
this.audioContext = null;
|
||||
}
|
||||
|
||||
this.audioSources = [];
|
||||
this.mediaRecorder = null;
|
||||
this.canvas = null;
|
||||
this.context = null;
|
||||
this.chunks = [];
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { buildStatsLogPayload, createAudioAnalyser, getAudioLevel } from './medi
|
||||
import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInstance, ensureSignalingStarted, getActiveSignalingInstance, sendInviteSignal, sendSocketUserInfo } from './signaling-session.js';
|
||||
import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js';
|
||||
import { createLogger } from './logger.js';
|
||||
import { MeetingRecorder } from './meeting-recorder.js';
|
||||
|
||||
const logger = createLogger('store');
|
||||
class CallStateManager {
|
||||
@@ -27,6 +28,7 @@ class CallStateManager {
|
||||
this.listeners = [];
|
||||
this.socketEventHandlers = {};
|
||||
this._inviteEventSignaling = null;
|
||||
this.meetingRecorder = new MeetingRecorder();
|
||||
}
|
||||
subscribe(callback) {
|
||||
this.listeners.push(callback);
|
||||
@@ -107,6 +109,78 @@ class CallStateManager {
|
||||
await this._updateLocalMediaRefactored(mediaType, value);
|
||||
return;
|
||||
}
|
||||
async toggleRecording() {
|
||||
const isRecording = this.state.session.localUser.mediaState.recording || false;
|
||||
|
||||
if (isRecording) {
|
||||
return this.stopRecording();
|
||||
}
|
||||
|
||||
return this.startRecording();
|
||||
}
|
||||
async startRecording() {
|
||||
if (this.state.session.status !== 'ongoing') {
|
||||
throw new Error('会议连接成功后才能开始录制');
|
||||
}
|
||||
|
||||
await this.meetingRecorder.start({
|
||||
localStream: this.state.localStream,
|
||||
remoteStream: this.state.remoteStream,
|
||||
remoteStreams: this.state.remoteStreams,
|
||||
connectionId: this.connectionId
|
||||
});
|
||||
await this._updateLocalMediaRefactored('recording', true);
|
||||
|
||||
return {
|
||||
recording: true,
|
||||
message: '开始录制会议'
|
||||
};
|
||||
}
|
||||
async stopRecording() {
|
||||
const result = await this.meetingRecorder.stop();
|
||||
await this._updateLocalMediaRefactored('recording', false);
|
||||
if (!result) {
|
||||
return {
|
||||
recording: false,
|
||||
message: '停止录制会议'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadResult = await this.uploadRecording(result);
|
||||
return {
|
||||
recording: false,
|
||||
message: uploadResult?.url ? `录制已上传到服务器:${uploadResult.url}` : `录制已上传:${result.filename}`
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
logger.error('Recording upload failed:', error);
|
||||
this.meetingRecorder.download(result.blob, result.filename);
|
||||
return {
|
||||
recording: false,
|
||||
message: `上传失败,已下载到本地:${result.filename}`
|
||||
};
|
||||
}
|
||||
}
|
||||
async uploadRecording({ blob, filename }) {
|
||||
const formData = new FormData();
|
||||
formData.append('meetingId', this.connectionId || this.state.session.id || 'unknown');
|
||||
formData.append('userId', this.state.session.localUser.id || '');
|
||||
formData.append('filename', filename);
|
||||
formData.append('recording', blob, filename);
|
||||
|
||||
const response = await fetch('/api/recordings', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const responseBody = await response.json().catch(() => ({}));
|
||||
if (!response.ok || responseBody.success === false) {
|
||||
throw new Error(responseBody.message || 'Recording upload failed');
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
async _updateLocalMediaRefactored(mediaType, value) {
|
||||
if (mediaType === 'video' && value) {
|
||||
await this._enableLocalVideo();
|
||||
@@ -423,6 +497,14 @@ class CallStateManager {
|
||||
this.startActivityDetection(this.state.remoteStream, { isLocal: false });
|
||||
}
|
||||
async hangUp() {
|
||||
if (this.meetingRecorder?.isRecording()) {
|
||||
try {
|
||||
await this.stopRecording();
|
||||
}
|
||||
catch (error) {
|
||||
logger.error('Error stopping recording before hangUp:', error);
|
||||
}
|
||||
}
|
||||
this.clearStatsMessage();
|
||||
this.stopNetworkQualityDetection();
|
||||
if (this.durationInterval) {
|
||||
|
||||
123
client/test/meeting-recorder.test.js
Normal file
123
client/test/meeting-recorder.test.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import { MeetingRecorder } from '../public/meeting-recorder.js';
|
||||
|
||||
class MediaStreamMock {
|
||||
constructor(tracks = []) {
|
||||
this.tracks = tracks;
|
||||
}
|
||||
|
||||
getTracks() {
|
||||
return this.tracks;
|
||||
}
|
||||
|
||||
getAudioTracks() {
|
||||
return this.tracks.filter(track => track.kind === 'audio');
|
||||
}
|
||||
|
||||
getVideoTracks() {
|
||||
return this.tracks.filter(track => track.kind === 'video');
|
||||
}
|
||||
}
|
||||
|
||||
class MediaRecorderMock {
|
||||
static isTypeSupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
constructor(stream, options) {
|
||||
this.stream = stream;
|
||||
this.mimeType = options.mimeType;
|
||||
this.state = 'inactive';
|
||||
}
|
||||
|
||||
start() {
|
||||
this.state = 'recording';
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.state = 'inactive';
|
||||
this.ondataavailable({ data: new Blob(['recording'], { type: this.mimeType }) });
|
||||
this.onstop();
|
||||
}
|
||||
}
|
||||
|
||||
function createTrack(kind) {
|
||||
return {
|
||||
kind,
|
||||
readyState: 'live',
|
||||
stop: jest.fn(),
|
||||
clone: jest.fn(() => createTrack(kind))
|
||||
};
|
||||
}
|
||||
|
||||
function createWindowMock({ mediaRecorder = MediaRecorderMock } = {}) {
|
||||
return {
|
||||
MediaRecorder: mediaRecorder,
|
||||
MediaStream: MediaStreamMock,
|
||||
URL: {
|
||||
createObjectURL: jest.fn(() => 'blob:recording'),
|
||||
revokeObjectURL: jest.fn()
|
||||
},
|
||||
requestAnimationFrame: jest.fn(() => 1),
|
||||
cancelAnimationFrame: jest.fn(),
|
||||
setTimeout: jest.fn((callback) => {
|
||||
callback();
|
||||
return 1;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
describe('MeetingRecorder', () => {
|
||||
beforeEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
|
||||
fillStyle: '',
|
||||
font: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
fillRect: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
drawImage: jest.fn()
|
||||
}));
|
||||
HTMLCanvasElement.prototype.captureStream = jest.fn(() => new MediaStreamMock([createTrack('video')]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('starts and stops recording a meeting file', async () => {
|
||||
const windowRef = createWindowMock();
|
||||
const recorder = new MeetingRecorder({ documentRef: document, windowRef });
|
||||
const localVideo = document.createElement('video');
|
||||
localVideo.id = 'localVideo';
|
||||
localVideo.srcObject = new MediaStreamMock([createTrack('video')]);
|
||||
Object.defineProperty(localVideo, 'readyState', { value: 2 });
|
||||
Object.defineProperty(localVideo, 'videoWidth', { value: 640 });
|
||||
Object.defineProperty(localVideo, 'videoHeight', { value: 360 });
|
||||
localVideo.getBoundingClientRect = () => ({ width: 320, height: 180 });
|
||||
document.body.appendChild(localVideo);
|
||||
|
||||
await recorder.start({
|
||||
localStream: new MediaStreamMock([createTrack('audio')]),
|
||||
connectionId: '123-456-789'
|
||||
});
|
||||
|
||||
expect(recorder.isRecording()).toBe(true);
|
||||
|
||||
const result = await recorder.stop();
|
||||
|
||||
expect(result.filename).toContain('meeting-recording-123-456-789');
|
||||
expect(result.mimeType).toBe('video/mp4;codecs=avc1.42E01E,mp4a.40.2');
|
||||
expect(result.filename).toMatch(/\.mp4$/);
|
||||
expect(windowRef.URL.createObjectURL).not.toHaveBeenCalled();
|
||||
expect(recorder.isRecording()).toBe(false);
|
||||
});
|
||||
|
||||
test('reports unsupported browsers', async () => {
|
||||
HTMLCanvasElement.prototype.captureStream = undefined;
|
||||
const windowRef = createWindowMock({ mediaRecorder: undefined });
|
||||
const recorder = new MeetingRecorder({ documentRef: document, windowRef });
|
||||
|
||||
await expect(recorder.start()).rejects.toThrow('当前浏览器不支持会议录制');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user