【m】修改为服务器录屏

This commit is contained in:
2026-06-02 02:34:40 +08:00
parent d74a0c8121
commit 66d6f92d1e
21 changed files with 4053 additions and 32 deletions

View File

@@ -124,6 +124,11 @@ export class MeetingRecorder {
this.audioSources = [];
this.recordingStream = null;
this.connectionId = '';
this.layout = 'grid';
this.onChunk = null;
this.storeChunks = true;
this.mixedAudioDestination = null;
this.mixedAudioTrackIds = new Set();
}
isSupported() {
@@ -137,7 +142,7 @@ export class MeetingRecorder {
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
}
async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) {
async start({ localStream, remoteStream, remoteStreams, connectionId, layout, onChunk, storeChunks } = {}) {
if (this.isRecording()) {
throw new Error('会议正在录制中');
}
@@ -156,7 +161,11 @@ export class MeetingRecorder {
}
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;
@@ -179,6 +188,16 @@ export class MeetingRecorder {
}
}
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);
@@ -203,16 +222,26 @@ export class MeetingRecorder {
}
this.audioContext = new AudioContextCtor();
const destination = this.audioContext.createMediaStreamDestination();
this.mixedAudioDestination = this.audioContext.createMediaStreamDestination();
audioTracks.forEach(track => this._connectAudioTrack(track));
audioTracks.forEach(track => {
const sourceStream = new this.window.MediaStream([track]);
const source = this.audioContext.createMediaStreamSource(sourceStream);
source.connect(destination);
this.audioSources.push(source);
});
return this.mixedAudioDestination.stream.getAudioTracks()[0] || null;
}
return destination.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) {
@@ -224,7 +253,17 @@ export class MeetingRecorder {
this.mediaRecorder = new MediaRecorderCtor(stream, options);
this.mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.chunks.push(event.data);
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) => {
@@ -235,9 +274,9 @@ export class MeetingRecorder {
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';
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 });
@@ -267,6 +306,15 @@ export class MeetingRecorder {
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) {
@@ -328,9 +376,13 @@ export class MeetingRecorder {
}
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;
}
}

View File

@@ -0,0 +1,175 @@
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('server-recording-peer');
export class ServerRecordingPeer {
constructor({
rtcConfiguration,
getLocalStream,
getSignaling,
getConnectionId,
getParticipantId
}) {
this.rtcConfiguration = rtcConfiguration;
this.getLocalStream = getLocalStream;
this.getSignaling = getSignaling;
this.getConnectionId = getConnectionId;
this.getParticipantId = getParticipantId;
this.peers = new Map();
}
async start(request) {
if (!request || !request.recordingId) {
return;
}
this.stop(request.recordingId);
const localStream = this.getLocalStream();
const tracks = localStream ? localStream.getTracks().filter(track => track.readyState !== 'ended') : [];
if (tracks.length === 0) {
this._sendStatus(request, 'no-local-media');
return;
}
const pc = new RTCPeerConnection(this.rtcConfiguration);
const state = {
pc,
recordingId: request.recordingId,
connectionId: request.connectionId || this.getConnectionId(),
pendingCandidates: []
};
this.peers.set(request.recordingId, state);
pc.onicecandidate = (event) => {
if (!event.candidate) {
return;
}
this._sendCandidate(state, event.candidate);
};
pc.onconnectionstatechange = () => {
logger.debug(`recording peer ${request.recordingId} state: ${pc.connectionState}`);
};
tracks.forEach(track => {
pc.addTransceiver(track, {
direction: 'sendonly',
streams: localStream ? [localStream] : []
});
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this._sendOffer(state);
}
async applyAnswer(answer) {
const state = this.peers.get(answer?.recordingId);
if (!state || !answer?.sdp) {
return;
}
await state.pc.setRemoteDescription(new RTCSessionDescription({
type: 'answer',
sdp: answer.sdp
}));
await this._flushPendingCandidates(state);
}
async addIceCandidate(candidate) {
const state = this.peers.get(candidate?.recordingId);
if (!state || !candidate?.candidate) {
return;
}
const iceCandidate = new RTCIceCandidate({
candidate: candidate.candidate,
sdpMid: candidate.sdpMid,
sdpMLineIndex: candidate.sdpMLineIndex
});
if (!state.pc.remoteDescription) {
state.pendingCandidates.push(iceCandidate);
return;
}
await state.pc.addIceCandidate(iceCandidate);
}
stop(recordingId) {
if (!recordingId) {
this.peers.forEach(peerState => this._closePeer(peerState));
this.peers.clear();
return;
}
const state = this.peers.get(recordingId);
if (!state) {
return;
}
this._closePeer(state);
this.peers.delete(recordingId);
}
_closePeer(state) {
state.pendingCandidates = [];
state.pc.close();
}
async _flushPendingCandidates(state) {
if (!state?.pc?.remoteDescription || !state.pendingCandidates.length) {
return;
}
const pendingCandidates = state.pendingCandidates.splice(0, state.pendingCandidates.length);
for (const candidate of pendingCandidates) {
await state.pc.addIceCandidate(candidate);
}
}
_sendOffer(state) {
const signaling = this.getSignaling();
if (!signaling || typeof signaling.sendRecordingOffer !== 'function') {
return;
}
signaling.sendRecordingOffer({
recordingId: state.recordingId,
connectionId: state.connectionId,
participantId: this.getParticipantId() || '',
sdp: state.pc.localDescription?.sdp || ''
});
}
_sendCandidate(state, candidate) {
const signaling = this.getSignaling();
if (!signaling || typeof signaling.sendRecordingCandidate !== 'function') {
return;
}
signaling.sendRecordingCandidate({
recordingId: state.recordingId,
connectionId: state.connectionId,
participantId: this.getParticipantId() || '',
candidate: candidate.candidate,
sdpMid: candidate.sdpMid,
sdpMLineIndex: candidate.sdpMLineIndex
});
}
_sendStatus(request, status) {
const signaling = this.getSignaling();
if (!signaling || typeof signaling.sendRecordingStatus !== 'function') {
return;
}
signaling.sendRecordingStatus({
recordingId: request.recordingId,
connectionId: request.connectionId || this.getConnectionId(),
participantId: this.getParticipantId() || '',
status
});
}
}

View File

@@ -10,6 +10,7 @@ import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInst
import { getNetworkQualityFromSummary, summarizeInboundStats } from './media/webrtc-stats.js';
import { createLogger } from '../shared/logger.js';
import { MeetingRecorder } from './media/meeting-recorder.js';
import { ServerRecordingPeer } from './media/server-recording-peer.js';
const logger = createLogger('store');
class CallStateManager {
@@ -28,6 +29,9 @@ class CallStateManager {
this.listeners = [];
this.socketEventHandlers = {};
this._inviteEventSignaling = null;
this._recordingEventSignaling = null;
this.serverRecordingSession = null;
this.serverRecordingPeer = null;
this.meetingRecorder = new MeetingRecorder();
}
subscribe(callback) {
@@ -112,12 +116,73 @@ class CallStateManager {
async toggleRecording() {
const isRecording = this.state.session.localUser.mediaState.recording || false;
if (this.useWebSocket && this.connectionId) {
return isRecording ? this.stopServerRecording() : this.startServerRecording();
}
if (isRecording) {
return this.stopRecording();
}
return this.startRecording();
}
async startServerRecording() {
if (this.state.session.status !== 'ongoing') {
throw new Error('会议连接成功后才能开始录制');
}
const response = await fetch('/api/recording-sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connectionId: this.connectionId,
layout: 'grid',
format: 'webm'
})
});
const responseBody = await response.json().catch(() => ({}));
if (!response.ok || responseBody.success === false) {
throw new Error(responseBody.message || '服务端录制启动失败');
}
this.serverRecordingSession = responseBody.session;
this._setRecordingMediaState(true);
return {
recording: true,
message: '服务端录制已开始'
};
}
async stopServerRecording() {
const recordingId = this.serverRecordingSession?.id;
if (!recordingId) {
this._setRecordingMediaState(false);
return {
recording: false,
message: '服务端录制已停止'
};
}
const response = await fetch(`/api/recording-sessions/${encodeURIComponent(recordingId)}`, {
method: 'DELETE'
});
const responseBody = await response.json().catch(() => ({}));
if (!response.ok || responseBody.success === false) {
throw new Error(responseBody.message || '服务端录制停止失败');
}
this.serverRecordingSession = responseBody.session;
this._setRecordingMediaState(false);
return {
recording: false,
message: '服务端录制已停止'
};
}
_setRecordingMediaState(value) {
this.state.session.localUser.mediaState.recording = value;
this._notifyLocalMediaChange('recording', value);
this.emitMediaStateChange();
this._notifyUserListUpdate();
}
async startRecording() {
if (this.state.session.status !== 'ongoing') {
throw new Error('会议连接成功后才能开始录制');
@@ -229,6 +294,7 @@ class CallStateManager {
async _updateLocalMediaRefactored(mediaType, value) {
if (mediaType === 'video' && value) {
await this._enableLocalVideo();
this._refreshServerRecordingPeer();
this._notifyUserListUpdate();
return;
}
@@ -241,6 +307,7 @@ class CallStateManager {
if (mediaType === 'audio') {
this._setLocalAudioTrackEnabled(value);
}
this._refreshServerRecordingPeer();
this._notifyUserListUpdate();
}
async _enableLocalVideo() {
@@ -441,6 +508,8 @@ class CallStateManager {
await this._startConnection(connectionId);
}
_registerCallbacks() {
this._ensureServerRecordingPeer();
this._bindRecordingSignalHandlers();
this.renderstreaming.onNewPeer = (participantId) => {
logger.debug(`New peer created for ${participantId}, adding local tracks`);
if (this.state.localStream) {
@@ -534,6 +603,46 @@ class CallStateManager {
this._handleRenderStreamingMessage(data);
};
}
_ensureServerRecordingPeer() {
if (this.serverRecordingPeer) {
return this.serverRecordingPeer;
}
this.serverRecordingPeer = new ServerRecordingPeer({
rtcConfiguration: getRTCConfiguration(),
getLocalStream: () => this.state.localStream,
getSignaling: () => this.getActiveSignaling(),
getConnectionId: () => this.connectionId,
getParticipantId: () => this.selfParticipantId || (this.role === 'host' ? 'host' : '')
});
return this.serverRecordingPeer;
}
_bindRecordingSignalHandlers() {
const signaling = this.renderstreaming?._signaling;
if (!signaling || signaling === this._recordingEventSignaling || typeof signaling.addEventListener !== 'function') {
return;
}
signaling.addEventListener('recording-started', (event) => {
this._handleRecordingStarted(event.detail);
});
signaling.addEventListener('recording-peer-request', (event) => {
this._handleRecordingPeerRequest(event.detail);
});
signaling.addEventListener('recording-stopped', (event) => {
this._handleRecordingStopped(event.detail);
});
signaling.addEventListener('recording-status', (event) => {
this._handleRecordingStatus(event.detail);
});
signaling.addEventListener('recording-answer', (event) => {
this._handleRecordingAnswer(event.detail);
});
signaling.addEventListener('recording-candidate', (event) => {
this._handleRecordingCandidate(event.detail);
});
this._recordingEventSignaling = signaling;
}
async _startConnection(connectionId) {
await this.renderstreaming.start();
await this.renderstreaming.createConnection(connectionId);
@@ -552,6 +661,9 @@ class CallStateManager {
}
this.clearStatsMessage();
this.stopNetworkQualityDetection();
if (this.serverRecordingPeer) {
this.serverRecordingPeer.stop();
}
if (this.durationInterval) {
clearInterval(this.durationInterval);
this.durationInterval = null;
@@ -674,6 +786,116 @@ class CallStateManager {
break;
}
}
_isCurrentRecordingEvent(data) {
return data && (!data.connectionId || data.connectionId === this.connectionId);
}
_handleRecordingStarted(data) {
if (!this._isCurrentRecordingEvent(data)) {
return;
}
this.serverRecordingSession = {
id: data.recordingId,
connectionId: data.connectionId,
status: data.status,
layout: data.layout,
format: data.format,
startedAt: data.startedAt
};
this._setRecordingMediaState(true);
showNotification('服务端录制已开始', 'success');
}
_handleRecordingStopped(data) {
if (!this._isCurrentRecordingEvent(data)) {
return;
}
if (this.serverRecordingSession && this.serverRecordingSession.id === data.recordingId) {
this.serverRecordingSession = {
...this.serverRecordingSession,
status: data.status,
stoppedAt: data.stoppedAt
};
}
if (this.serverRecordingPeer) {
this.serverRecordingPeer.stop(data.recordingId);
}
this._setRecordingMediaState(false);
showNotification('服务端录制已停止', 'success');
}
async _handleRecordingPeerRequest(data) {
if (!this._isCurrentRecordingEvent(data)) {
return;
}
logger.debug('收到服务端录制媒体请求:', data);
this.notify({
type: 'RECORDING_PEER_REQUEST',
recordingId: data.recordingId,
mediaMode: data.mediaMode
});
try {
await this._ensureServerRecordingPeer().start(data);
}
catch (error) {
logger.error('服务端录制 PeerConnection 创建失败:', error);
showNotification('服务端录制媒体连接失败', 'error');
}
}
_isServerRecordingActive() {
return this.useWebSocket
&& this.serverRecordingSession
&& this.serverRecordingSession.status === 'recording';
}
_refreshServerRecordingPeer() {
if (!this._isServerRecordingActive() || !this.serverRecordingPeer) {
return;
}
this.serverRecordingPeer.start({
recordingId: this.serverRecordingSession.id,
connectionId: this.connectionId,
mediaMode: 'webrtc-sendonly'
}).catch((error) => {
logger.error('服务端录制媒体重协商失败:', error);
});
}
_handleRecordingStatus(data) {
if (!this._isCurrentRecordingEvent(data)) {
return;
}
logger.debug('收到服务端录制状态:', data);
this.notify({
type: 'RECORDING_STATUS',
status: data.status,
recordingId: data.recordingId
});
}
async _handleRecordingAnswer(data) {
if (!this._isCurrentRecordingEvent(data) || !this.serverRecordingPeer) {
return;
}
try {
await this.serverRecordingPeer.applyAnswer(data);
}
catch (error) {
logger.error('服务端录制 answer 处理失败:', error);
}
}
async _handleRecordingCandidate(data) {
if (!this._isCurrentRecordingEvent(data) || !this.serverRecordingPeer) {
return;
}
try {
await this.serverRecordingPeer.addIceCandidate(data);
}
catch (error) {
logger.error('服务端录制 candidate 处理失败:', error);
}
}
_handleChatMessage(data) {
const chatPayload = data.data || data.message;
if (!chatPayload) {

View File

@@ -235,6 +235,24 @@ export class WebSocketSignaling extends EventTarget {
case "invite-failed":
this.dispatchEvent(new CustomEvent('invite-failed', { detail: msg.data }));
break;
case "recording-started":
this.dispatchEvent(new CustomEvent('recording-started', { detail: msg }));
break;
case "recording-peer-request":
this.dispatchEvent(new CustomEvent('recording-peer-request', { detail: msg }));
break;
case "recording-stopped":
this.dispatchEvent(new CustomEvent('recording-stopped', { detail: msg }));
break;
case "recording-status":
this.dispatchEvent(new CustomEvent('recording-status', { detail: msg }));
break;
case "recording-answer":
this.dispatchEvent(new CustomEvent('recording-answer', { detail: msg }));
break;
case "recording-candidate":
this.dispatchEvent(new CustomEvent('recording-candidate', { detail: msg }));
break;
default:
break;
}
@@ -326,4 +344,22 @@ export class WebSocketSignaling extends EventTarget {
Logger.log(sendJson);
this.websocket.send(sendJson);
}
sendRecordingOffer(payload) {
const sendJson = JSON.stringify({ type: 'recording-offer', data: payload });
Logger.log(sendJson);
this.websocket.send(sendJson);
}
sendRecordingCandidate(payload) {
const sendJson = JSON.stringify({ type: 'recording-candidate', data: payload });
Logger.log(sendJson);
this.websocket.send(sendJson);
}
sendRecordingStatus(payload) {
const sendJson = JSON.stringify({ type: 'recording-status', data: payload });
Logger.log(sendJson);
this.websocket.send(sendJson);
}
}

View File

@@ -0,0 +1,108 @@
import { jest } from '@jest/globals';
import { ServerRecordingPeer } from '../../public/call/media/server-recording-peer.js';
function createTrack(kind, id) {
return {
kind,
id,
readyState: 'live'
};
}
function createStream(tracks) {
return {
getTracks() {
return tracks;
}
};
}
describe('ServerRecordingPeer', () => {
test('queues remote candidates until answer is applied', async () => {
const originalRTCPeerConnection = window.RTCPeerConnection;
const originalRTCSessionDescription = window.RTCSessionDescription;
const originalRTCIceCandidate = window.RTCIceCandidate;
class FakeRTCPeerConnection {
constructor() {
this.localDescription = null;
this.remoteDescription = null;
this.candidates = [];
}
addTransceiver() {}
async createOffer() {
return { type: 'offer', sdp: 'test-offer-sdp' };
}
async setLocalDescription(description) {
this.localDescription = description;
}
async setRemoteDescription(description) {
this.remoteDescription = description;
}
async addIceCandidate(candidate) {
if (!this.remoteDescription) {
throw new Error('remote description missing');
}
this.candidates.push(candidate);
}
close() {}
}
window.RTCPeerConnection = FakeRTCPeerConnection;
window.RTCSessionDescription = class {
constructor(init) {
Object.assign(this, init);
}
};
window.RTCIceCandidate = class {
constructor(init) {
Object.assign(this, init);
}
};
const signaling = {
sendRecordingOffer: jest.fn(),
sendRecordingCandidate: jest.fn()
};
const peer = new ServerRecordingPeer({
rtcConfiguration: {},
getLocalStream: () => createStream([createTrack('video', 'video-1')]),
getSignaling: () => signaling,
getConnectionId: () => 'room-1',
getParticipantId: () => 'participant-1'
});
await peer.start({
recordingId: 'recording-1',
connectionId: 'room-1'
});
await expect(peer.addIceCandidate({
recordingId: 'recording-1',
candidate: 'candidate:1',
sdpMid: '0',
sdpMLineIndex: 0
})).resolves.toBeUndefined();
const state = peer.peers.get('recording-1');
expect(state.pendingCandidates).toHaveLength(1);
expect(state.pc.candidates).toHaveLength(0);
await peer.applyAnswer({
recordingId: 'recording-1',
sdp: 'test-answer-sdp'
});
expect(state.pendingCandidates).toHaveLength(0);
expect(state.pc.candidates).toHaveLength(1);
window.RTCPeerConnection = originalRTCPeerConnection;
window.RTCSessionDescription = originalRTCSessionDescription;
window.RTCIceCandidate = originalRTCIceCandidate;
});
});