视频录制开发
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,6 +43,7 @@ node_modules/
|
|||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
recordings/
|
||||||
*.lcov
|
*.lcov
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
|
|||||||
@@ -21,15 +21,13 @@ export function createCallViewController({ store, chatMessage, notify }) {
|
|||||||
toggleVideo();
|
toggleVideo();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRecording() {
|
async function toggleRecording() {
|
||||||
const state = store.getState();
|
try {
|
||||||
const currentState = state.session.localUser.mediaState.recording || false;
|
const result = await store.toggleRecording();
|
||||||
store.updateLocalMedia('recording', !currentState);
|
notify(result.message);
|
||||||
|
}
|
||||||
if (!currentState) {
|
catch (error) {
|
||||||
notify('\u5f00\u59cb\u5f55\u5236');
|
notify(error.message || '\u5f55\u5236\u5931\u8d25');
|
||||||
} else {
|
|
||||||
notify('\u505c\u6b62\u5f55\u5236');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInstance, ensureSignalingStarted, getActiveSignalingInstance, sendInviteSignal, sendSocketUserInfo } from './signaling-session.js';
|
||||||
import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js';
|
import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js';
|
||||||
import { createLogger } from './logger.js';
|
import { createLogger } from './logger.js';
|
||||||
|
import { MeetingRecorder } from './meeting-recorder.js';
|
||||||
|
|
||||||
const logger = createLogger('store');
|
const logger = createLogger('store');
|
||||||
class CallStateManager {
|
class CallStateManager {
|
||||||
@@ -27,6 +28,7 @@ class CallStateManager {
|
|||||||
this.listeners = [];
|
this.listeners = [];
|
||||||
this.socketEventHandlers = {};
|
this.socketEventHandlers = {};
|
||||||
this._inviteEventSignaling = null;
|
this._inviteEventSignaling = null;
|
||||||
|
this.meetingRecorder = new MeetingRecorder();
|
||||||
}
|
}
|
||||||
subscribe(callback) {
|
subscribe(callback) {
|
||||||
this.listeners.push(callback);
|
this.listeners.push(callback);
|
||||||
@@ -107,6 +109,78 @@ class CallStateManager {
|
|||||||
await this._updateLocalMediaRefactored(mediaType, value);
|
await this._updateLocalMediaRefactored(mediaType, value);
|
||||||
return;
|
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) {
|
async _updateLocalMediaRefactored(mediaType, value) {
|
||||||
if (mediaType === 'video' && value) {
|
if (mediaType === 'video' && value) {
|
||||||
await this._enableLocalVideo();
|
await this._enableLocalVideo();
|
||||||
@@ -423,6 +497,14 @@ class CallStateManager {
|
|||||||
this.startActivityDetection(this.state.remoteStream, { isLocal: false });
|
this.startActivityDetection(this.state.remoteStream, { isLocal: false });
|
||||||
}
|
}
|
||||||
async hangUp() {
|
async hangUp() {
|
||||||
|
if (this.meetingRecorder?.isRecording()) {
|
||||||
|
try {
|
||||||
|
await this.stopRecording();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.error('Error stopping recording before hangUp:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
this.clearStatsMessage();
|
this.clearStatsMessage();
|
||||||
this.stopNetworkQualityDetection();
|
this.stopNetworkQualityDetection();
|
||||||
if (this.durationInterval) {
|
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('当前浏览器不支持会议录制');
|
||||||
|
});
|
||||||
|
});
|
||||||
190
src/server.ts
190
src/server.ts
@@ -15,6 +15,9 @@ const multer = require('multer');
|
|||||||
const AVATAR_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024;
|
const AVATAR_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024;
|
||||||
const ALLOWED_AVATAR_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
const ALLOWED_AVATAR_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||||
const ALLOWED_AVATAR_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
|
const ALLOWED_AVATAR_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
|
||||||
|
const DEFAULT_RECORDING_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
|
const ALLOWED_RECORDING_MIME_TYPES = new Set(['video/webm', 'video/mp4', 'application/octet-stream']);
|
||||||
|
const ALLOWED_RECORDING_EXTENSIONS = new Set(['.webm', '.mp4']);
|
||||||
|
|
||||||
function safeAvatarExtension(file: any): string {
|
function safeAvatarExtension(file: any): string {
|
||||||
const originalExt = path.extname(file.originalname || '').toLowerCase();
|
const originalExt = path.extname(file.originalname || '').toLowerCase();
|
||||||
@@ -40,6 +43,52 @@ function isAllowedAvatar(file: any): boolean {
|
|||||||
return ALLOWED_AVATAR_MIME_TYPES.has(file.mimetype) && safeAvatarExtension(file).length > 0;
|
return ALLOWED_AVATAR_MIME_TYPES.has(file.mimetype) && safeAvatarExtension(file).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRecordingRoot(): string {
|
||||||
|
return path.resolve(process.env.RECORDING_DIR || path.join(process.cwd(), 'recordings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordingUploadLimitBytes(): number {
|
||||||
|
const value = Number(process.env.RECORDING_MAX_BYTES);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : DEFAULT_RECORDING_UPLOAD_LIMIT_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePathSegment(value: string | undefined, fallback: string): string {
|
||||||
|
const sanitized = (value || fallback).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120);
|
||||||
|
return sanitized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInside(parent: string, child: string): boolean {
|
||||||
|
const relative = path.relative(parent, child);
|
||||||
|
return relative.length === 0 || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRecordingExtension(file: any): string {
|
||||||
|
const originalExt = path.extname(file.originalname || '').toLowerCase();
|
||||||
|
if (ALLOWED_RECORDING_EXTENSIONS.has(originalExt)) {
|
||||||
|
return originalExt;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (normalizeMimeType(file.mimetype)) {
|
||||||
|
case 'video/mp4':
|
||||||
|
return '.mp4';
|
||||||
|
case 'video/webm':
|
||||||
|
return '.webm';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMimeType(mimetype: string | undefined): string {
|
||||||
|
return (mimetype || '').split(';')[0].trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedRecording(file: any): boolean {
|
||||||
|
const mimetype = normalizeMimeType(file.mimetype);
|
||||||
|
const ext = safeRecordingExtension(file);
|
||||||
|
const isCompatibleMime = ALLOWED_RECORDING_MIME_TYPES.has(mimetype) || mimetype.startsWith('video/') || mimetype === 'text/plain' || mimetype === '';
|
||||||
|
return ext.length > 0 && isCompatibleMime;
|
||||||
|
}
|
||||||
|
|
||||||
export const createServer = (config: Options): express.Express => {
|
export const createServer = (config: Options): express.Express => {
|
||||||
const app: express.Express = express();
|
const app: express.Express = express();
|
||||||
resetHandler(config.mode);
|
resetHandler(config.mode);
|
||||||
@@ -148,6 +197,147 @@ export const createServer = (config: Options): express.Express => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const recordingRoot = getRecordingRoot();
|
||||||
|
const recordingTempDir = path.join(recordingRoot, '.tmp');
|
||||||
|
const recordingStorage = multer.diskStorage({
|
||||||
|
destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => {
|
||||||
|
if (!fs.existsSync(recordingTempDir)) {
|
||||||
|
fs.mkdirSync(recordingTempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
cb(null, recordingTempDir);
|
||||||
|
},
|
||||||
|
filename: (_req: any, file: any, cb: (error: Error | null, filename: string) => void) => {
|
||||||
|
cb(null, `${uuid()}${safeRecordingExtension(file)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const recordingUpload = multer({
|
||||||
|
storage: recordingStorage,
|
||||||
|
limits: {
|
||||||
|
fileSize: getRecordingUploadLimitBytes()
|
||||||
|
},
|
||||||
|
fileFilter: (_req: express.Request, file: any, cb: (error: Error | null, acceptFile?: boolean) => void) => {
|
||||||
|
if (!isAllowedRecording(file)) {
|
||||||
|
log(LogLevel.warn, 'Recording upload rejected by type filter:', {
|
||||||
|
originalname: file.originalname,
|
||||||
|
mimetype: file.mimetype,
|
||||||
|
normalizedMimetype: normalizeMimeType(file.mimetype),
|
||||||
|
extension: safeRecordingExtension(file)
|
||||||
|
});
|
||||||
|
cb(new Error('Only mp4 or webm recordings are allowed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/recordings', (req: express.Request, res: express.Response) => {
|
||||||
|
recordingUpload.single('recording')(req, res, (error: Error) => {
|
||||||
|
if (error) {
|
||||||
|
log(LogLevel.warn, 'Recording upload rejected:', error.message);
|
||||||
|
const isSizeLimit = error.name === 'MulterError' && (error as any).code === 'LIMIT_FILE_SIZE';
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: isSizeLimit ? 'Recording file is too large' : error.message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = req as any;
|
||||||
|
if (!request.file) {
|
||||||
|
res.status(400).json({ success: false, message: 'No recording uploaded' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = safeRecordingExtension(request.file);
|
||||||
|
if (!ext) {
|
||||||
|
fs.unlink(request.file.path, () => undefined);
|
||||||
|
res.status(400).json({ success: false, message: 'Unsupported recording file type' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingId = uuid();
|
||||||
|
const meetingId = sanitizePathSegment(request.body.meetingId, 'unknown');
|
||||||
|
const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${ext}`);
|
||||||
|
const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${recordingId}${ext}`;
|
||||||
|
const meetingDir = path.join(recordingRoot, meetingId);
|
||||||
|
const finalPath = path.join(meetingDir, finalFilename);
|
||||||
|
|
||||||
|
if (!isPathInside(recordingRoot, finalPath)) {
|
||||||
|
fs.unlink(request.file.path, () => undefined);
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording path' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdir(meetingDir, { recursive: true }, (mkdirError) => {
|
||||||
|
if (mkdirError) {
|
||||||
|
fs.unlink(request.file.path, () => undefined);
|
||||||
|
log(LogLevel.error, 'Error creating recording directory:', mkdirError);
|
||||||
|
res.status(500).json({ success: false, message: 'Recording directory unavailable' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rename(request.file.path, finalPath, (renameError) => {
|
||||||
|
if (renameError) {
|
||||||
|
fs.unlink(request.file.path, () => undefined);
|
||||||
|
log(LogLevel.error, 'Error saving recording:', renameError);
|
||||||
|
res.status(500).json({ success: false, message: 'Recording save failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
id: recordingId,
|
||||||
|
meetingId,
|
||||||
|
filename: finalFilename,
|
||||||
|
originalFilename,
|
||||||
|
mimetype: normalizeMimeType(request.file.mimetype),
|
||||||
|
size: request.file.size,
|
||||||
|
userId: request.body.userId || '',
|
||||||
|
uploadedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
fs.writeFile(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2), () => undefined);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
recordingId,
|
||||||
|
meetingId,
|
||||||
|
filename: finalFilename,
|
||||||
|
originalFilename,
|
||||||
|
size: request.file.size,
|
||||||
|
url: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(finalFilename)}/download`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/recordings/:meetingId/:filename/download', (req: express.Request, res: express.Response) => {
|
||||||
|
const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown');
|
||||||
|
const filename = sanitizePathSegment(req.params.filename, '');
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
|
||||||
|
if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording filename' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(recordingRoot, meetingId, filename);
|
||||||
|
if (!isPathInside(recordingRoot, filePath)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Invalid recording path' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.access(filePath, fs.constants.R_OK, (accessError) => {
|
||||||
|
if (accessError) {
|
||||||
|
res.status(404).json({ success: false, message: 'Recording not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.download(filePath, filename);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.use('/uploads', express.static(path.join(__dirname, '../client/public/uploads')));
|
app.use('/uploads', express.static(path.join(__dirname, '../client/public/uploads')));
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
Reference in New Issue
Block a user