【m】修改为服务器录屏
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
175
client/public/call/media/server-recording-peer.js
Normal file
175
client/public/call/media/server-recording-peer.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user