【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
});
}
}