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 = []; } }