389 lines
13 KiB
JavaScript
389 lines
13 KiB
JavaScript
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 = '';
|
|
this.layout = 'grid';
|
|
this.onChunk = null;
|
|
this.storeChunks = true;
|
|
this.mixedAudioDestination = null;
|
|
this.mixedAudioTrackIds = new Set();
|
|
}
|
|
|
|
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, layout, onChunk, storeChunks } = {}) {
|
|
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.layout = layout || 'grid';
|
|
this.onChunk = typeof onChunk === 'function' ? onChunk : null;
|
|
this.storeChunks = storeChunks !== false;
|
|
this.chunks = [];
|
|
this.mixedAudioTrackIds = new Set();
|
|
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;
|
|
}
|
|
}
|
|
|
|
syncAudio({ localStream, remoteStream, remoteStreams } = {}) {
|
|
if (!this.isRecording() || !this.audioContext || !this.mixedAudioDestination) {
|
|
return;
|
|
}
|
|
|
|
const streams = collectStreams({ localStream, remoteStream, remoteStreams });
|
|
const audioTracks = collectLiveAudioTracks(streams);
|
|
audioTracks.forEach(track => this._connectAudioTrack(track));
|
|
}
|
|
|
|
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();
|
|
this.mixedAudioDestination = this.audioContext.createMediaStreamDestination();
|
|
audioTracks.forEach(track => this._connectAudioTrack(track));
|
|
|
|
return this.mixedAudioDestination.stream.getAudioTracks()[0] || null;
|
|
}
|
|
|
|
_connectAudioTrack(track) {
|
|
if (!track || track.readyState === 'ended') {
|
|
return;
|
|
}
|
|
const trackId = track.id || `${track.kind}-${Date.now()}`;
|
|
if (this.mixedAudioTrackIds.has(trackId)) {
|
|
return;
|
|
}
|
|
this.mixedAudioTrackIds.add(trackId);
|
|
|
|
const sourceStream = new this.window.MediaStream([track]);
|
|
const source = this.audioContext.createMediaStreamSource(sourceStream);
|
|
source.connect(this.mixedAudioDestination);
|
|
this.audioSources.push(source);
|
|
}
|
|
|
|
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) {
|
|
if (this.storeChunks) {
|
|
this.chunks.push(event.data);
|
|
}
|
|
if (this.onChunk) {
|
|
try {
|
|
this.onChunk(event.data);
|
|
}
|
|
catch (_error) {
|
|
// Ignore chunk callback failures so recording can continue.
|
|
}
|
|
}
|
|
}
|
|
};
|
|
this.mediaRecorder.onerror = (event) => {
|
|
if (this.pendingStop) {
|
|
this.pendingStop.reject(event.error || new Error('录制失败'));
|
|
this.pendingStop = null;
|
|
}
|
|
this.cleanup();
|
|
};
|
|
this.mediaRecorder.onstop = () => {
|
|
const filename = this.buildFilename();
|
|
const mimeType = this.mediaRecorder.mimeType || 'video/webm';
|
|
const blob = this.storeChunks ? new Blob(this.chunks, { type: mimeType }) : null;
|
|
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 (this.layout === 'host-only') {
|
|
if (localVideo) {
|
|
drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height);
|
|
return;
|
|
}
|
|
drawEmptyFrame(context, canvas);
|
|
return;
|
|
}
|
|
|
|
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.mixedAudioDestination = null;
|
|
this.mixedAudioTrackIds = new Set();
|
|
this.mediaRecorder = null;
|
|
this.canvas = null;
|
|
this.context = null;
|
|
this.chunks = [];
|
|
this.onChunk = null;
|
|
this.storeChunks = true;
|
|
}
|
|
}
|