Compare commits
1 Commits
600f64dc6d
...
本地音视频合并
| Author | SHA1 | Date | |
|---|---|---|---|
| 83cf098c5f |
@@ -124,11 +124,6 @@ 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() {
|
||||
@@ -142,7 +137,7 @@ export class MeetingRecorder {
|
||||
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
|
||||
}
|
||||
|
||||
async start({ localStream, remoteStream, remoteStreams, connectionId, layout, onChunk, storeChunks } = {}) {
|
||||
async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) {
|
||||
if (this.isRecording()) {
|
||||
throw new Error('会议正在录制中');
|
||||
}
|
||||
@@ -161,11 +156,7 @@ 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;
|
||||
|
||||
@@ -188,16 +179,6 @@ 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);
|
||||
@@ -222,26 +203,16 @@ export class MeetingRecorder {
|
||||
}
|
||||
|
||||
this.audioContext = new AudioContextCtor();
|
||||
this.mixedAudioDestination = this.audioContext.createMediaStreamDestination();
|
||||
audioTracks.forEach(track => this._connectAudioTrack(track));
|
||||
const destination = this.audioContext.createMediaStreamDestination();
|
||||
|
||||
return this.mixedAudioDestination.stream.getAudioTracks()[0] || null;
|
||||
}
|
||||
audioTracks.forEach(track => {
|
||||
const sourceStream = new this.window.MediaStream([track]);
|
||||
const source = this.audioContext.createMediaStreamSource(sourceStream);
|
||||
source.connect(destination);
|
||||
this.audioSources.push(source);
|
||||
});
|
||||
|
||||
_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);
|
||||
return destination.stream.getAudioTracks()[0] || null;
|
||||
}
|
||||
|
||||
startMediaRecorder(stream) {
|
||||
@@ -253,17 +224,7 @@ export class MeetingRecorder {
|
||||
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.chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
this.mediaRecorder.onerror = (event) => {
|
||||
@@ -274,9 +235,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 = this.mediaRecorder.mimeType || 'video/webm';
|
||||
const blob = this.storeChunks ? new Blob(this.chunks, { type: mimeType }) : null;
|
||||
const mimeType = blob.type || this.mediaRecorder.mimeType || 'video/webm';
|
||||
this.cleanup();
|
||||
if (this.pendingStop) {
|
||||
this.pendingStop.resolve({ blob, filename, mimeType });
|
||||
@@ -306,15 +267,6 @@ 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) {
|
||||
@@ -376,13 +328,9 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ 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 {
|
||||
@@ -29,9 +28,6 @@ class CallStateManager {
|
||||
this.listeners = [];
|
||||
this.socketEventHandlers = {};
|
||||
this._inviteEventSignaling = null;
|
||||
this._recordingEventSignaling = null;
|
||||
this.serverRecordingSession = null;
|
||||
this.serverRecordingPeer = null;
|
||||
this.meetingRecorder = new MeetingRecorder();
|
||||
}
|
||||
subscribe(callback) {
|
||||
@@ -116,73 +112,12 @@ 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('会议连接成功后才能开始录制');
|
||||
@@ -294,7 +229,6 @@ class CallStateManager {
|
||||
async _updateLocalMediaRefactored(mediaType, value) {
|
||||
if (mediaType === 'video' && value) {
|
||||
await this._enableLocalVideo();
|
||||
this._refreshServerRecordingPeer();
|
||||
this._notifyUserListUpdate();
|
||||
return;
|
||||
}
|
||||
@@ -307,7 +241,6 @@ class CallStateManager {
|
||||
if (mediaType === 'audio') {
|
||||
this._setLocalAudioTrackEnabled(value);
|
||||
}
|
||||
this._refreshServerRecordingPeer();
|
||||
this._notifyUserListUpdate();
|
||||
}
|
||||
async _enableLocalVideo() {
|
||||
@@ -508,8 +441,6 @@ 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) {
|
||||
@@ -603,46 +534,6 @@ 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);
|
||||
@@ -661,9 +552,6 @@ class CallStateManager {
|
||||
}
|
||||
this.clearStatsMessage();
|
||||
this.stopNetworkQualityDetection();
|
||||
if (this.serverRecordingPeer) {
|
||||
this.serverRecordingPeer.stop();
|
||||
}
|
||||
if (this.durationInterval) {
|
||||
clearInterval(this.durationInterval);
|
||||
this.durationInterval = null;
|
||||
@@ -786,116 +674,6 @@ 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) {
|
||||
|
||||
@@ -100,11 +100,11 @@ function formatDate(value) {
|
||||
}
|
||||
|
||||
function getPersonId(person) {
|
||||
return person?.userId || person?.id || person?.participantId || '';
|
||||
return person?.userId || person?.id || '';
|
||||
}
|
||||
|
||||
function getPersonName(person) {
|
||||
return person?.name || person?.displayName || getPersonId(person) || '-';
|
||||
return person?.name || getPersonId(person) || '-';
|
||||
}
|
||||
|
||||
function getRecordingHost(recording) {
|
||||
|
||||
@@ -448,7 +448,6 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(420px, 1fr) 360px;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recordings-upload,
|
||||
@@ -613,33 +612,7 @@ body {
|
||||
.recordings-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.recordings-table-wrap::-webkit-scrollbar,
|
||||
.recordings-preview-meta::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.recordings-table-wrap::-webkit-scrollbar-track,
|
||||
.recordings-preview-meta::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
}
|
||||
|
||||
.recordings-table-wrap::-webkit-scrollbar-thumb,
|
||||
.recordings-preview-meta::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.32);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.recordings-table-wrap::-webkit-scrollbar-thumb:hover,
|
||||
.recordings-preview-meta::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(165, 180, 252, 0.52);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.recordings-table {
|
||||
@@ -927,7 +900,6 @@ body {
|
||||
.recordings-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.recordings-list,
|
||||
|
||||
@@ -1,41 +1,5 @@
|
||||
import * as Logger from "../utils/logger.js";
|
||||
|
||||
const RECORDING_SIGNAL_EVENTS = [
|
||||
'recording-started',
|
||||
'recording-peer-request',
|
||||
'recording-stopped',
|
||||
'recording-status',
|
||||
'recording-answer',
|
||||
'recording-candidate'
|
||||
];
|
||||
|
||||
function parseOnMessageData(data) {
|
||||
if (typeof data !== 'string') {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch(e) {
|
||||
Logger.error(`Signaling: on-message, error: ${e}`);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchOnMessageEvent(target, data, participantId) {
|
||||
const parsed = parseOnMessageData(data);
|
||||
if (participantId && parsed && typeof parsed === 'object') {
|
||||
parsed.participantId = participantId;
|
||||
}
|
||||
target.dispatchEvent(new CustomEvent('on-message', { detail: parsed }));
|
||||
if (parsed && typeof parsed.type === 'string' && RECORDING_SIGNAL_EVENTS.indexOf(parsed.type) !== -1) {
|
||||
const detail = parsed.data && typeof parsed.data === 'object'
|
||||
? { type: parsed.type, ...parsed.data }
|
||||
: parsed;
|
||||
target.dispatchEvent(new CustomEvent(parsed.type, { detail }));
|
||||
}
|
||||
}
|
||||
|
||||
export class Signaling extends EventTarget {
|
||||
|
||||
constructor(interval = 1000, baseUrl = null) {
|
||||
@@ -109,7 +73,15 @@ export class Signaling extends EventTarget {
|
||||
this.dispatchEvent(new CustomEvent('candidate', { detail: msg }));
|
||||
break;
|
||||
case "on-message":
|
||||
dispatchOnMessageEvent(this, msg.data, msg.participantId);
|
||||
{
|
||||
let parsed = msg.data;
|
||||
if (typeof msg.data === 'string') {
|
||||
try { parsed = JSON.parse(msg.data); } catch(e) {
|
||||
Logger.error(`Signaling: on-message, error: ${e}`);
|
||||
}
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('on-message', { detail: parsed }));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -229,7 +201,18 @@ export class WebSocketSignaling extends EventTarget {
|
||||
this.dispatchEvent(new CustomEvent('candidate', { detail: { connectionId: msg.from, candidate: msg.data.candidate, sdpMLineIndex: msg.data.sdpMLineIndex, sdpMid: msg.data.sdpMid, participantId: msg.participantId } }));
|
||||
break;
|
||||
case "on-message":
|
||||
dispatchOnMessageEvent(this, msg.data, msg.participantId);
|
||||
{
|
||||
let parsed = msg.data;
|
||||
if (typeof msg.data === 'string') {
|
||||
try { parsed = JSON.parse(msg.data); } catch(e) {
|
||||
Logger.error(`Signaling: on-message, error: ${e}`);
|
||||
}
|
||||
}
|
||||
if (msg.participantId) {
|
||||
parsed.participantId = msg.participantId;
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('on-message', { detail: parsed }));
|
||||
}
|
||||
break;
|
||||
case "participant-left":
|
||||
this.dispatchEvent(new CustomEvent('participant-left', { detail: msg }));
|
||||
@@ -252,24 +235,6 @@ 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;
|
||||
}
|
||||
@@ -361,25 +326,4 @@ export class WebSocketSignaling extends EventTarget {
|
||||
Logger.log(sendJson);
|
||||
this.websocket.send(sendJson);
|
||||
}
|
||||
|
||||
sendRecordingOffer(payload) {
|
||||
this.sendMessage(payload.connectionId || '', {
|
||||
type: 'recording-offer',
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
|
||||
sendRecordingCandidate(payload) {
|
||||
this.sendMessage(payload.connectionId || '', {
|
||||
type: 'recording-candidate',
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
|
||||
sendRecordingStatus(payload) {
|
||||
this.sendMessage(payload.connectionId || '', {
|
||||
type: 'recording-status',
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -43,89 +43,6 @@ function createWebSocketSignaling(port) {
|
||||
return new WebSocketSignaling(1, `ws://localhost:${port}`);
|
||||
}
|
||||
|
||||
describe('recording signaling message envelope', () => {
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
let sentMessages;
|
||||
|
||||
beforeEach(() => {
|
||||
sentMessages = [];
|
||||
window.WebSocket = class {
|
||||
constructor() {
|
||||
this.readyState = 1;
|
||||
}
|
||||
|
||||
send(message) {
|
||||
sentMessages.push(message);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.WebSocket = OriginalWebSocket;
|
||||
});
|
||||
|
||||
test('sends recording offer through on-message', () => {
|
||||
const signaling = new WebSocketSignaling(1, 'ws://localhost:1234');
|
||||
|
||||
signaling.sendRecordingOffer({
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
participantId: 'participant-1',
|
||||
sdp: 'offer-sdp'
|
||||
});
|
||||
|
||||
expect(sentMessages).toHaveLength(1);
|
||||
const outer = JSON.parse(sentMessages[0]);
|
||||
expect(outer.type).toBe('on-message');
|
||||
expect(outer.data.connectionId).toBe('room-1');
|
||||
expect(outer.data.message).toEqual({
|
||||
type: 'recording-offer',
|
||||
data: {
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
participantId: 'participant-1',
|
||||
sdp: 'offer-sdp'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatches wrapped recording messages as recording events', () => {
|
||||
const signaling = new WebSocketSignaling(1, 'ws://localhost:1234');
|
||||
let recordingAnswer;
|
||||
signaling.addEventListener('recording-answer', (event) => {
|
||||
recordingAnswer = event.detail;
|
||||
});
|
||||
|
||||
signaling.websocket.onmessage({
|
||||
data: JSON.stringify({
|
||||
type: 'on-message',
|
||||
from: 'room-1',
|
||||
data: JSON.stringify({
|
||||
type: 'recording-answer',
|
||||
data: {
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
sdp: 'answer-sdp'
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
expect(recordingAnswer).toEqual({
|
||||
type: 'recording-answer',
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
sdp: 'answer-sdp'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(signalingModes)('signaling test in public mode', ({ mode }) => {
|
||||
let signaling1;
|
||||
let signaling2;
|
||||
|
||||
197
src/class/serveraudiorecorder.ts
Normal file
197
src/class/serveraudiorecorder.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { RTCPeerConnection } from 'werift';
|
||||
import { MediaRecorder } from 'werift/nonstandard';
|
||||
import { log, LogLevel } from '../log';
|
||||
|
||||
type ServerAudioRecordingSession = {
|
||||
recordingId: string;
|
||||
meetingId: string;
|
||||
peerConnection: RTCPeerConnection;
|
||||
audioPath: string;
|
||||
createdAt: number;
|
||||
recorder?: MediaRecorder;
|
||||
audioTrackCount: number;
|
||||
localCandidates: any[];
|
||||
};
|
||||
|
||||
type StartServerAudioRecordingOptions = {
|
||||
meetingId?: string;
|
||||
offerSdp: string;
|
||||
iceServers?: any[];
|
||||
};
|
||||
|
||||
type StartServerAudioRecordingResult = {
|
||||
recordingId: string;
|
||||
meetingId: string;
|
||||
answerSdp: string;
|
||||
candidates: any[];
|
||||
audioPath: string;
|
||||
};
|
||||
|
||||
type StoppedServerAudioRecording = {
|
||||
recordingId: string;
|
||||
meetingId: string;
|
||||
audioPath: string;
|
||||
hasAudio: boolean;
|
||||
audioTrackCount: number;
|
||||
createdAt: number;
|
||||
stoppedAt: number;
|
||||
};
|
||||
|
||||
function waitForIceGatheringComplete(peerConnection: RTCPeerConnection, timeoutMs: number): Promise<void> {
|
||||
if (peerConnection.iceGatheringState === 'complete') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const subscription = peerConnection.iceGatheringStateChange.subscribe((state) => {
|
||||
if (state === 'complete') {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
const timer = setTimeout(finish, timeoutMs);
|
||||
|
||||
function finish(): void {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
subscription.unSubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toJsonCandidate(candidate: any): any {
|
||||
if (!candidate) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return typeof candidate.toJSON === 'function' ? candidate.toJSON() : candidate;
|
||||
}
|
||||
|
||||
export class ServerAudioRecorderManager {
|
||||
private sessions: Map<string, ServerAudioRecordingSession> = new Map<string, ServerAudioRecordingSession>();
|
||||
|
||||
constructor(private tempDir: string) {}
|
||||
|
||||
async start(options: StartServerAudioRecordingOptions): Promise<StartServerAudioRecordingResult> {
|
||||
if (!options.offerSdp || typeof options.offerSdp !== 'string') {
|
||||
throw new Error('offerSdp is required');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
const recordingId = uuid();
|
||||
const meetingId = options.meetingId || 'unknown';
|
||||
const audioPath = path.join(this.tempDir, `${recordingId}.server-audio.webm`);
|
||||
const peerConnection = new RTCPeerConnection({
|
||||
iceServers: Array.isArray(options.iceServers) ? options.iceServers : []
|
||||
});
|
||||
const session: ServerAudioRecordingSession = {
|
||||
recordingId,
|
||||
meetingId,
|
||||
peerConnection,
|
||||
audioPath,
|
||||
createdAt: Date.now(),
|
||||
audioTrackCount: 0,
|
||||
localCandidates: []
|
||||
};
|
||||
|
||||
peerConnection.onIceCandidate.subscribe((candidate) => {
|
||||
if (candidate) {
|
||||
session.localCandidates.push(toJsonCandidate(candidate));
|
||||
}
|
||||
});
|
||||
|
||||
peerConnection.onTrack.subscribe((track) => {
|
||||
if (track.kind !== 'audio') {
|
||||
return;
|
||||
}
|
||||
|
||||
session.audioTrackCount += 1;
|
||||
if (session.recorder) {
|
||||
log(LogLevel.warn, `Ignoring extra server audio track for recording ${recordingId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
session.recorder = new MediaRecorder({
|
||||
path: audioPath,
|
||||
tracks: [track]
|
||||
});
|
||||
session.recorder.onError.subscribe((error) => {
|
||||
log(LogLevel.error, `Server audio recorder error for ${recordingId}:`, error);
|
||||
});
|
||||
log(LogLevel.log, `Server audio track received for recording ${recordingId}`);
|
||||
});
|
||||
|
||||
await peerConnection.setRemoteDescription({ type: 'offer', sdp: options.offerSdp });
|
||||
const answer = await peerConnection.createAnswer();
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(peerConnection, 3000);
|
||||
|
||||
this.sessions.set(recordingId, session);
|
||||
|
||||
return {
|
||||
recordingId,
|
||||
meetingId,
|
||||
answerSdp: (peerConnection.localDescription && peerConnection.localDescription.sdp) || answer.sdp,
|
||||
candidates: session.localCandidates,
|
||||
audioPath
|
||||
};
|
||||
}
|
||||
|
||||
async addCandidate(recordingId: string, candidate: any): Promise<boolean> {
|
||||
const session = this.sessions.get(recordingId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await session.peerConnection.addIceCandidate(candidate || {});
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(recordingId: string): Promise<StoppedServerAudioRecording | null> {
|
||||
const session = this.sessions.get(recordingId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.sessions.delete(recordingId);
|
||||
|
||||
if (session.recorder) {
|
||||
await session.recorder.stop();
|
||||
}
|
||||
await session.peerConnection.close();
|
||||
|
||||
const hasAudio = fs.existsSync(session.audioPath) && fs.statSync(session.audioPath).size > 0;
|
||||
return {
|
||||
recordingId: session.recordingId,
|
||||
meetingId: session.meetingId,
|
||||
audioPath: session.audioPath,
|
||||
hasAudio,
|
||||
audioTrackCount: session.audioTrackCount,
|
||||
createdAt: session.createdAt,
|
||||
stoppedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
async cancel(recordingId: string): Promise<boolean> {
|
||||
const stopped = await this.stop(recordingId);
|
||||
if (!stopped) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fs.existsSync(stopped.audioPath)) {
|
||||
fs.unlinkSync(stopped.audioPath);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ import Offer from './offer';
|
||||
import Answer from './answer';
|
||||
import Candidate from './candidate';
|
||||
import { log, LogLevel } from '../log';
|
||||
import { RecordingSession, listRecordingSessions, stopRecordingSession } from '../recording/session-manager';
|
||||
import { registerRecordingPeerCandidate, registerRecordingPeerOffer, stopRecordingAgent } from '../recording/agent';
|
||||
import { startRecordingCompositionJob } from '../recording/composer';
|
||||
import { acceptRecordingOffer, addRecordingIceCandidate, stopRecordingPeer } from '../recording/werift-adapter';
|
||||
import { RecordingPerson } from '../recording/storage';
|
||||
|
||||
/**
|
||||
* 是否为私有模式
|
||||
@@ -73,20 +68,6 @@ interface RoomSnapshot {
|
||||
userCount: number;
|
||||
}
|
||||
|
||||
type RecordingBroadcastPayload = {
|
||||
type: 'recording-started' | 'recording-stopped' | 'recording-status' | 'recording-peer-request';
|
||||
connectionId: string;
|
||||
recordingId: string;
|
||||
status: string;
|
||||
layout?: string;
|
||||
format?: string;
|
||||
startedAt?: string;
|
||||
stoppedAt?: string;
|
||||
mediaMode?: string;
|
||||
};
|
||||
|
||||
type RecordingClientMessageType = 'recording-offer' | 'recording-candidate' | 'recording-status';
|
||||
|
||||
interface StoredRoom {
|
||||
roomId: string;
|
||||
connectionId: string;
|
||||
@@ -336,163 +317,6 @@ function broadcastToGroup(connectionId: string, senderWs: WebSocket, message: an
|
||||
}
|
||||
}
|
||||
|
||||
function sendToEntireGroup(connectionId: string, message: any): boolean {
|
||||
const group = connectionGroup.get(connectionId);
|
||||
if (!group) {
|
||||
return false;
|
||||
}
|
||||
|
||||
safeSend(group.host, message);
|
||||
group.participants.forEach(participantWs => {
|
||||
safeSend(participantWs, message);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function toTypedDataMessage(message: any): any {
|
||||
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||
return message;
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
Object.keys(message).forEach((key) => {
|
||||
if (key !== 'type') {
|
||||
data[key] = message[key];
|
||||
}
|
||||
});
|
||||
return {
|
||||
type: message.type,
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
function toOnMessageEnvelope(connectionId: string, message: any, participantId?: string): any {
|
||||
const envelope: any = {
|
||||
from: connectionId,
|
||||
to: "",
|
||||
type: "on-message",
|
||||
data: JSON.stringify(toTypedDataMessage(message))
|
||||
};
|
||||
if (participantId) {
|
||||
envelope.participantId = participantId;
|
||||
}
|
||||
return envelope;
|
||||
}
|
||||
|
||||
function safeSendOnMessage(ws: WebSocket, connectionId: string, message: any, participantId?: string): boolean {
|
||||
return safeSend(ws, toOnMessageEnvelope(connectionId, message, participantId));
|
||||
}
|
||||
|
||||
function sendOnMessageToEntireGroup(connectionId: string, message: any): boolean {
|
||||
return sendToEntireGroup(connectionId, toOnMessageEnvelope(connectionId, message));
|
||||
}
|
||||
|
||||
function isRecordingClientMessage(message: any): boolean {
|
||||
return message
|
||||
&& typeof message === 'object'
|
||||
&& (message.type === 'recording-offer'
|
||||
|| message.type === 'recording-candidate'
|
||||
|| message.type === 'recording-status');
|
||||
}
|
||||
|
||||
function unwrapRecordingClientPayload(message: any): any {
|
||||
const payload = message.data && typeof message.data === 'object' ? message.data : message;
|
||||
if (!payload.connectionId && message.connectionId) {
|
||||
payload.connectionId = message.connectionId;
|
||||
}
|
||||
if (!payload.participantId && message.participantId) {
|
||||
payload.participantId = message.participantId;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getSdpMediaSections(sdp: string): string[] {
|
||||
return sdp
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^m=(audio|video)\s/i.test(line));
|
||||
}
|
||||
|
||||
function hasRecordableSdpMedia(sdp: string): boolean {
|
||||
return getSdpMediaSections(sdp).length > 0;
|
||||
}
|
||||
|
||||
function getActiveRecordingSessions(connectionId: string): RecordingSession[] {
|
||||
return listRecordingSessions(connectionId).filter((session) => session.status === 'recording');
|
||||
}
|
||||
|
||||
function stopRecordingPeersForSocket(ws: WebSocket, connectionId: string): void {
|
||||
const participantId = getParticipantId(ws);
|
||||
if (!participantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
getActiveRecordingSessions(connectionId).forEach((session) => {
|
||||
stopRecordingPeer(session.id, participantId).catch((error) => {
|
||||
log(LogLevel.warn, 'Failed to stop participant recording peer:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function roomMemberToRecordingPerson(member: RoomMemberInfo | undefined, fallbackRole: string): RecordingPerson | undefined {
|
||||
if (!member) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
participantId: member.participantId || '',
|
||||
userId: member.userId || '',
|
||||
id: member.userId || member.participantId || '',
|
||||
name: member.name || member.userId || member.participantId || '',
|
||||
avatar: member.avatar || '',
|
||||
role: member.role || fallbackRole,
|
||||
status: 'online'
|
||||
};
|
||||
}
|
||||
|
||||
function getRecordingRoomPeople(connectionId: string): { host?: RecordingPerson; participants: RecordingPerson[] } {
|
||||
const room = rooms.get(connectionId);
|
||||
if (!room) {
|
||||
return { participants: [] };
|
||||
}
|
||||
|
||||
const members = Array.from(room.members.values());
|
||||
const hostMember = members.find((member) => member.role === 'host')
|
||||
|| members.find((member) => member.socketId === room.hostSocketId);
|
||||
const host = roomMemberToRecordingPerson(hostMember, 'host');
|
||||
const participants = members
|
||||
.filter((member) => member !== hostMember && member.role === 'participant')
|
||||
.map((member) => roomMemberToRecordingPerson(member, 'participant'))
|
||||
.filter((member) => Boolean(member)) as RecordingPerson[];
|
||||
|
||||
return { host, participants };
|
||||
}
|
||||
|
||||
function stopActiveRecordingSessions(connectionId: string): void {
|
||||
const roomPeople = getRecordingRoomPeople(connectionId);
|
||||
getActiveRecordingSessions(connectionId).forEach((session) => {
|
||||
const stoppedSession = stopRecordingSession(session.id);
|
||||
if (stoppedSession) {
|
||||
broadcastRecordingStopped(stoppedSession);
|
||||
stopRecordingAgent(stoppedSession.id);
|
||||
}
|
||||
stopRecordingPeer(session.id)
|
||||
.then(() => {
|
||||
startRecordingCompositionJob({
|
||||
meetingId: session.connectionId,
|
||||
recordingId: session.id,
|
||||
layout: session.layout,
|
||||
format: session.format,
|
||||
host: roomPeople.host,
|
||||
participants: roomPeople.participants
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
log(LogLevel.warn, 'Failed to stop room recording peers:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除WebSocket连接
|
||||
* @param ws WebSocket连接实例
|
||||
@@ -505,14 +329,12 @@ function remove(ws: WebSocket): void {
|
||||
const group = connectionGroup.get(connectionId);
|
||||
if (group) {
|
||||
if (group.host === ws) {
|
||||
stopActiveRecordingSessions(connectionId);
|
||||
group.participants.forEach(participantWs => {
|
||||
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
|
||||
});
|
||||
rooms.delete(connectionId);
|
||||
connectionGroup.delete(connectionId);
|
||||
} else {
|
||||
stopRecordingPeersForSocket(ws, connectionId);
|
||||
group.participants.delete(ws);
|
||||
removeRoomMember(ws, connectionId);
|
||||
// 包含participantId,让host能识别是哪个participant离开
|
||||
@@ -557,7 +379,6 @@ function onConnect(ws: WebSocket, connectionId: string): void {
|
||||
const role = polite ? 'participant' : 'host';
|
||||
saveRoomMember(ws, connectionId);
|
||||
safeSend(ws, { type: "connect", connectionId: connectionId, polite: polite, role: role, participantId: participantId });
|
||||
sendActiveRecordingRequests(ws, connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -578,7 +399,6 @@ function onDisconnect(ws: WebSocket, connectionId: string): void {
|
||||
if (group) {
|
||||
if (group.host === ws) {
|
||||
// host断开连接,通知所有participants房间已关闭,并删除连接组
|
||||
stopActiveRecordingSessions(connectionId);
|
||||
group.participants.forEach(participantWs => {
|
||||
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
|
||||
});
|
||||
@@ -587,7 +407,6 @@ function onDisconnect(ws: WebSocket, connectionId: string): void {
|
||||
log(LogLevel.log, `Host disconnected, room ${connectionId} deleted, notified ${group.participants.size} participants`);
|
||||
} else {
|
||||
// participant断开连接,从组中移除并通知host(使用participant-left类型,host不会关闭房间)
|
||||
stopRecordingPeersForSocket(ws, connectionId);
|
||||
group.participants.delete(ws);
|
||||
removeRoomMember(ws, connectionId);
|
||||
safeSend(group.host, { type: "participant-left", connectionId: connectionId, participantId: getParticipantId(ws) });
|
||||
@@ -858,181 +677,6 @@ function onBroadcast(ws: WebSocket, message: any): void {
|
||||
}
|
||||
}
|
||||
|
||||
function toRecordingBroadcastPayload(type: RecordingBroadcastPayload['type'], session: RecordingSession): RecordingBroadcastPayload {
|
||||
return {
|
||||
type,
|
||||
connectionId: session.connectionId,
|
||||
recordingId: session.id,
|
||||
status: session.status,
|
||||
layout: session.layout,
|
||||
format: session.format,
|
||||
startedAt: session.startedAt,
|
||||
stoppedAt: session.stoppedAt
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastRecordingStarted(session: RecordingSession): boolean {
|
||||
return sendOnMessageToEntireGroup(
|
||||
session.connectionId,
|
||||
toRecordingBroadcastPayload('recording-started', session)
|
||||
);
|
||||
}
|
||||
|
||||
function broadcastRecordingPeerRequest(session: RecordingSession): boolean {
|
||||
const payload = toRecordingBroadcastPayload('recording-peer-request', session);
|
||||
payload.mediaMode = 'webrtc-sendonly';
|
||||
return sendOnMessageToEntireGroup(session.connectionId, payload);
|
||||
}
|
||||
|
||||
function broadcastRecordingStopped(session: RecordingSession): boolean {
|
||||
return sendOnMessageToEntireGroup(
|
||||
session.connectionId,
|
||||
toRecordingBroadcastPayload('recording-stopped', session)
|
||||
);
|
||||
}
|
||||
|
||||
function sendActiveRecordingRequests(ws: WebSocket, connectionId: string): void {
|
||||
const activeSessions = getActiveRecordingSessions(connectionId);
|
||||
activeSessions.forEach((session) => {
|
||||
safeSendOnMessage(ws, connectionId, toRecordingBroadcastPayload('recording-started', session));
|
||||
safeSendOnMessage(ws, connectionId, {
|
||||
...toRecordingBroadcastPayload('recording-peer-request', session),
|
||||
mediaMode: 'webrtc-sendonly'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function onRecordingOffer(ws: WebSocket, message: any): Promise<void> {
|
||||
const recordingId = typeof message.recordingId === 'string' ? message.recordingId : '';
|
||||
const connectionId = typeof message.connectionId === 'string' ? message.connectionId : '';
|
||||
const sdp = typeof message.sdp === 'string' ? message.sdp : '';
|
||||
if (!recordingId || !connectionId || !sdp) {
|
||||
safeSendOnMessage(ws, connectionId, { type: 'recording-status', recordingId, connectionId, status: 'invalid-offer' });
|
||||
return;
|
||||
}
|
||||
if (!hasRecordableSdpMedia(sdp)) {
|
||||
const participantId = getParticipantId(ws) || message.participantId || 'unknown';
|
||||
log(LogLevel.warn, 'Rejected recording offer without audio/video media sections:', {
|
||||
recordingId,
|
||||
connectionId,
|
||||
participantId,
|
||||
mediaSections: getSdpMediaSections(sdp)
|
||||
});
|
||||
safeSendOnMessage(ws, connectionId, {
|
||||
type: 'recording-status',
|
||||
recordingId,
|
||||
connectionId,
|
||||
status: 'no-media-offer',
|
||||
participantId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const offer = registerRecordingPeerOffer({
|
||||
recordingId,
|
||||
connectionId,
|
||||
sdp,
|
||||
participantId: getParticipantId(ws) || 'unknown'
|
||||
});
|
||||
|
||||
if (!offer) {
|
||||
safeSendOnMessage(ws, connectionId, {
|
||||
type: 'recording-status',
|
||||
recordingId,
|
||||
connectionId,
|
||||
status: 'recorder-unavailable',
|
||||
participantId: getParticipantId(ws)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const participantId = getParticipantId(ws) || 'unknown';
|
||||
const role = getSocketRoleInRoom(ws, connectionId);
|
||||
const answerSdp = await acceptRecordingOffer({
|
||||
recordingId,
|
||||
connectionId,
|
||||
sdp,
|
||||
participantId,
|
||||
role,
|
||||
onLocalCandidate: (candidate) => {
|
||||
const json = typeof candidate.toJSON === 'function' ? candidate.toJSON() : candidate;
|
||||
safeSendOnMessage(ws, connectionId, {
|
||||
type: 'recording-candidate',
|
||||
recordingId,
|
||||
connectionId,
|
||||
participantId,
|
||||
candidate: json.candidate,
|
||||
sdpMid: json.sdpMid,
|
||||
sdpMLineIndex: json.sdpMLineIndex
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
safeSendOnMessage(ws, connectionId, {
|
||||
type: 'recording-answer',
|
||||
recordingId,
|
||||
connectionId,
|
||||
participantId,
|
||||
sdp: answerSdp
|
||||
});
|
||||
} catch (error) {
|
||||
log(LogLevel.error, 'Failed to accept recording offer:', error);
|
||||
safeSendOnMessage(ws, connectionId, {
|
||||
type: 'recording-status',
|
||||
recordingId,
|
||||
connectionId,
|
||||
status: 'offer-failed',
|
||||
participantId: getParticipantId(ws)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
safeSendOnMessage(ws, connectionId, {
|
||||
type: 'recording-status',
|
||||
recordingId,
|
||||
connectionId,
|
||||
status: 'offer-received',
|
||||
participantId: getParticipantId(ws)
|
||||
});
|
||||
}
|
||||
|
||||
async function onRecordingCandidate(ws: WebSocket, message: any): Promise<void> {
|
||||
const recordingId = typeof message.recordingId === 'string' ? message.recordingId : '';
|
||||
const connectionId = typeof message.connectionId === 'string' ? message.connectionId : '';
|
||||
const candidateText = typeof message.candidate === 'string' ? message.candidate : '';
|
||||
if (!recordingId || !connectionId || !candidateText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = registerRecordingPeerCandidate({
|
||||
recordingId,
|
||||
connectionId,
|
||||
candidate: candidateText,
|
||||
participantId: getParticipantId(ws) || message.participantId || 'unknown',
|
||||
sdpMid: typeof message.sdpMid === 'string' ? message.sdpMid : undefined,
|
||||
sdpMLineIndex: typeof message.sdpMLineIndex === 'number' ? message.sdpMLineIndex : undefined
|
||||
});
|
||||
|
||||
if (!candidate) {
|
||||
safeSendOnMessage(ws, connectionId, { type: 'recording-status', recordingId, connectionId, status: 'candidate-rejected' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addRecordingIceCandidate({
|
||||
recordingId,
|
||||
participantId: candidate.participantId,
|
||||
candidate: candidate.candidate,
|
||||
sdpMid: candidate.sdpMid,
|
||||
sdpMLineIndex: candidate.sdpMLineIndex
|
||||
});
|
||||
} catch (error) {
|
||||
log(LogLevel.warn, 'Failed to add recording ICE candidate:', error);
|
||||
safeSendOnMessage(ws, connectionId, { type: 'recording-status', recordingId, connectionId, status: 'candidate-rejected' });
|
||||
}
|
||||
}
|
||||
|
||||
function AddHeartbeat(ws: WebSocket, connectionId: string) {
|
||||
// 初始化心跳检测
|
||||
asAppWebSocket(ws).lastActivity = Date.now();
|
||||
@@ -1164,23 +808,6 @@ function onMessage(ws: WebSocket, message: any): void {
|
||||
}
|
||||
chatMessage.participantId = senderParticipantId;
|
||||
chatMessage.connectionId = connectionId;
|
||||
if (isRecordingClientMessage(chatMessage)) {
|
||||
const recordingPayload = unwrapRecordingClientPayload(chatMessage);
|
||||
switch (chatMessage.type as RecordingClientMessageType) {
|
||||
case 'recording-offer':
|
||||
onRecordingOffer(ws, recordingPayload);
|
||||
break;
|
||||
case 'recording-candidate':
|
||||
onRecordingCandidate(ws, recordingPayload);
|
||||
break;
|
||||
case 'recording-status':
|
||||
log(LogLevel.log, 'Received recording status:', recordingPayload);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (connectionGroup.has(connectionId)) {
|
||||
const group = connectionGroup.get(connectionId);
|
||||
if (group.host === ws) {
|
||||
@@ -1206,5 +833,4 @@ function onMessage(ws: WebSocket, message: any): void {
|
||||
*/
|
||||
export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId,
|
||||
onBroadcast, onGetAllConnectionIds, onGetOnlineUsers, onGetRooms, AddHeartbeat, RemoveHeartbeat, onMessage, isHost,
|
||||
broadcastToGroup, broadcastRecordingStarted, broadcastRecordingPeerRequest, broadcastRecordingStopped, connectionGroup,
|
||||
onHostUserInfo, onInviteCall, onRecordingCandidate, onRecordingOffer };
|
||||
broadcastToGroup, connectionGroup, onHostUserInfo, onInviteCall };
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { RecordingSession } from './session-manager';
|
||||
|
||||
export type RecordingAgentStatus = 'awaiting-media-adapter' | 'negotiating' | 'receiving-media' | 'stopped';
|
||||
|
||||
export type RecordingPeerOffer = {
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
sdp: string;
|
||||
receivedAt: string;
|
||||
};
|
||||
|
||||
export type RecordingPeerCandidate = {
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
candidate: string;
|
||||
sdpMid?: string;
|
||||
sdpMLineIndex?: number;
|
||||
receivedAt: string;
|
||||
};
|
||||
|
||||
export type RecordingPeerAnswer = {
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
sdp: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RecordingPeerTrack = {
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
kind: string;
|
||||
trackId: string;
|
||||
receivedAt: string;
|
||||
rtpPackets: number;
|
||||
};
|
||||
|
||||
export type RecordingAgent = {
|
||||
id: string;
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
status: RecordingAgentStatus;
|
||||
mediaMode: 'webrtc-sendonly';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
stoppedAt?: string;
|
||||
peerOffers: Map<string, RecordingPeerOffer>;
|
||||
peerAnswers: Map<string, RecordingPeerAnswer>;
|
||||
peerCandidates: Map<string, RecordingPeerCandidate[]>;
|
||||
peerTracks: Map<string, RecordingPeerTrack[]>;
|
||||
};
|
||||
|
||||
const agents: Map<string, RecordingAgent> = new Map<string, RecordingAgent>();
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function startRecordingAgent(session: RecordingSession): RecordingAgent {
|
||||
const timestamp = nowIso();
|
||||
const agent: RecordingAgent = {
|
||||
id: `recorder_${session.id}`,
|
||||
recordingId: session.id,
|
||||
connectionId: session.connectionId,
|
||||
status: 'awaiting-media-adapter',
|
||||
mediaMode: 'webrtc-sendonly',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
peerOffers: new Map<string, RecordingPeerOffer>(),
|
||||
peerAnswers: new Map<string, RecordingPeerAnswer>(),
|
||||
peerCandidates: new Map<string, RecordingPeerCandidate[]>(),
|
||||
peerTracks: new Map<string, RecordingPeerTrack[]>()
|
||||
};
|
||||
|
||||
agents.set(session.id, agent);
|
||||
return agent;
|
||||
}
|
||||
|
||||
export function getRecordingAgent(recordingId: string): RecordingAgent | null {
|
||||
return agents.get(recordingId) || null;
|
||||
}
|
||||
|
||||
export function stopRecordingAgent(recordingId: string): RecordingAgent | null {
|
||||
const agent = agents.get(recordingId);
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
agent.status = 'stopped';
|
||||
agent.updatedAt = timestamp;
|
||||
agent.stoppedAt = timestamp;
|
||||
return agent;
|
||||
}
|
||||
|
||||
export function registerRecordingPeerOffer(input: {
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
sdp: string;
|
||||
}): RecordingPeerOffer | null {
|
||||
const agent = agents.get(input.recordingId);
|
||||
if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offer: RecordingPeerOffer = {
|
||||
recordingId: input.recordingId,
|
||||
connectionId: input.connectionId,
|
||||
participantId: input.participantId,
|
||||
sdp: input.sdp,
|
||||
receivedAt: nowIso()
|
||||
};
|
||||
agent.peerOffers.set(input.participantId, offer);
|
||||
agent.status = 'negotiating';
|
||||
agent.updatedAt = offer.receivedAt;
|
||||
return offer;
|
||||
}
|
||||
|
||||
export function registerRecordingPeerAnswer(input: {
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
sdp: string;
|
||||
}): RecordingPeerAnswer | null {
|
||||
const agent = agents.get(input.recordingId);
|
||||
if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const answer: RecordingPeerAnswer = {
|
||||
recordingId: input.recordingId,
|
||||
connectionId: input.connectionId,
|
||||
participantId: input.participantId,
|
||||
sdp: input.sdp,
|
||||
createdAt: nowIso()
|
||||
};
|
||||
agent.peerAnswers.set(input.participantId, answer);
|
||||
agent.updatedAt = answer.createdAt;
|
||||
return answer;
|
||||
}
|
||||
|
||||
export function registerRecordingPeerCandidate(input: {
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
candidate: string;
|
||||
sdpMid?: string;
|
||||
sdpMLineIndex?: number;
|
||||
}): RecordingPeerCandidate | null {
|
||||
const agent = agents.get(input.recordingId);
|
||||
if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate: RecordingPeerCandidate = {
|
||||
recordingId: input.recordingId,
|
||||
connectionId: input.connectionId,
|
||||
participantId: input.participantId,
|
||||
candidate: input.candidate,
|
||||
sdpMid: input.sdpMid,
|
||||
sdpMLineIndex: input.sdpMLineIndex,
|
||||
receivedAt: nowIso()
|
||||
};
|
||||
const participantCandidates = agent.peerCandidates.get(input.participantId) || [];
|
||||
participantCandidates.push(candidate);
|
||||
agent.peerCandidates.set(input.participantId, participantCandidates);
|
||||
agent.updatedAt = candidate.receivedAt;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function registerRecordingPeerTrack(input: {
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
kind: string;
|
||||
trackId: string;
|
||||
}): RecordingPeerTrack | null {
|
||||
const agent = agents.get(input.recordingId);
|
||||
if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const track: RecordingPeerTrack = {
|
||||
recordingId: input.recordingId,
|
||||
connectionId: input.connectionId,
|
||||
participantId: input.participantId,
|
||||
kind: input.kind,
|
||||
trackId: input.trackId,
|
||||
receivedAt: nowIso(),
|
||||
rtpPackets: 0
|
||||
};
|
||||
const participantTracks = agent.peerTracks.get(input.participantId) || [];
|
||||
participantTracks.push(track);
|
||||
agent.peerTracks.set(input.participantId, participantTracks);
|
||||
agent.status = 'receiving-media';
|
||||
agent.updatedAt = track.receivedAt;
|
||||
return track;
|
||||
}
|
||||
|
||||
export function incrementRecordingTrackPackets(input: {
|
||||
recordingId: string;
|
||||
participantId: string;
|
||||
trackId: string;
|
||||
}): void {
|
||||
const agent = agents.get(input.recordingId);
|
||||
if (!agent || agent.status === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
const participantTracks = agent.peerTracks.get(input.participantId) || [];
|
||||
const track = participantTracks.find((item) => item.trackId === input.trackId);
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
track.rtpPackets += 1;
|
||||
agent.updatedAt = nowIso();
|
||||
}
|
||||
|
||||
export function resetRecordingAgents(): void {
|
||||
agents.clear();
|
||||
}
|
||||
@@ -1,591 +0,0 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
RecordingPerson,
|
||||
ServerTrackRecordingFile,
|
||||
ServerTrackRecordingTarget,
|
||||
createComposedRecordingTarget,
|
||||
deleteServerTrackRecordingFiles,
|
||||
listServerTrackRecordingFiles,
|
||||
writeComposedRecordingMetadata
|
||||
} from './storage';
|
||||
|
||||
export type RecordingCompositionStatus = 'queued' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export type RecordingCompositionJob = {
|
||||
id: string;
|
||||
recordingId: string;
|
||||
meetingId: string;
|
||||
status: RecordingCompositionStatus;
|
||||
layout: string;
|
||||
format: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
failedAt?: string;
|
||||
error?: string;
|
||||
inputFiles: string[];
|
||||
host?: RecordingPerson;
|
||||
participants?: RecordingPerson[];
|
||||
deletedInputFiles?: string[];
|
||||
output?: {
|
||||
meetingId: string;
|
||||
filename: string;
|
||||
filePath: string;
|
||||
metadataPath: string;
|
||||
downloadUrl: string;
|
||||
streamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
type StartCompositionInput = {
|
||||
meetingId: string;
|
||||
recordingId: string;
|
||||
layout?: string;
|
||||
format?: string;
|
||||
host?: RecordingPerson;
|
||||
participants?: RecordingPerson[];
|
||||
};
|
||||
|
||||
type CompositionInputSets = {
|
||||
videoInputs: ServerTrackRecordingFile[];
|
||||
audioInputs: ServerTrackRecordingFile[];
|
||||
};
|
||||
|
||||
type VideoTimelineSegment = {
|
||||
startMs: number;
|
||||
endMs: number | null;
|
||||
activeInputs: ServerTrackRecordingFile[];
|
||||
};
|
||||
|
||||
const jobs: Map<string, RecordingCompositionJob> = new Map<string, RecordingCompositionJob>();
|
||||
const COMPOSITION_OUTPUT_WIDTH = 2560;
|
||||
const COMPOSITION_OUTPUT_HEIGHT = 1440;
|
||||
const COMPOSITION_OUTPUT_FPS = 60;
|
||||
const COMPOSITION_HOST_HEIGHT = 1080;
|
||||
const COMPOSITION_VIDEO_BITRATE = '16000k';
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeOption(value: unknown, fallback: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.slice(0, 40) : fallback;
|
||||
}
|
||||
|
||||
function normalizeFormat(value: unknown): string {
|
||||
return normalizeOption(value, 'webm') === 'mp4' ? 'mp4' : 'webm';
|
||||
}
|
||||
|
||||
function getFfmpegPath(): string {
|
||||
return process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
}
|
||||
|
||||
function sortInputs(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] {
|
||||
return files.slice().sort((a, b) => {
|
||||
const participantCompare = a.participantId.localeCompare(b.participantId);
|
||||
if (participantCompare !== 0) {
|
||||
return participantCompare;
|
||||
}
|
||||
return Date.parse(a.uploadedAt) - Date.parse(b.uploadedAt);
|
||||
});
|
||||
}
|
||||
|
||||
function getInputSets(input: StartCompositionInput): CompositionInputSets {
|
||||
const files = listServerTrackRecordingFiles({
|
||||
meetingId: input.meetingId,
|
||||
recordingId: input.recordingId
|
||||
});
|
||||
return {
|
||||
videoInputs: sortInputs(files.filter((file) => file.trackKind === 'video')),
|
||||
audioInputs: sortInputs(files.filter((file) => file.trackKind === 'audio'))
|
||||
};
|
||||
}
|
||||
|
||||
function isHostInput(file: ServerTrackRecordingFile): boolean {
|
||||
if (file.metadata && file.metadata.role === 'host') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const firstParticipant = file.metadata && Array.isArray(file.metadata.participants)
|
||||
? file.metadata.participants[0]
|
||||
: null;
|
||||
return Boolean(firstParticipant && firstParticipant.role === 'host');
|
||||
}
|
||||
|
||||
function orderVideoInputsForComposition(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] {
|
||||
const hostIndex = files.findIndex(isHostInput);
|
||||
if (hostIndex <= 0) {
|
||||
return files.slice();
|
||||
}
|
||||
|
||||
return [
|
||||
files[hostIndex],
|
||||
...files.slice(0, hostIndex),
|
||||
...files.slice(hostIndex + 1)
|
||||
];
|
||||
}
|
||||
|
||||
function parseInputTimestamp(value: unknown): number | null {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
return isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
function getInputStartMs(file: ServerTrackRecordingFile): number | null {
|
||||
const metadata = file.metadata || {};
|
||||
return parseInputTimestamp(metadata.recordingStartedAt)
|
||||
|| parseInputTimestamp(file.recordingStartedAt)
|
||||
|| parseInputTimestamp(metadata.startedAt)
|
||||
|| parseInputTimestamp(file.uploadedAt)
|
||||
|| parseInputTimestamp(metadata.uploadedAt);
|
||||
}
|
||||
|
||||
function getInputEndMs(file: ServerTrackRecordingFile): number | null {
|
||||
const metadata = file.metadata || {};
|
||||
return parseInputTimestamp(metadata.recordingEndedAt)
|
||||
|| parseInputTimestamp(file.recordingEndedAt)
|
||||
|| parseInputTimestamp(metadata.endedAt)
|
||||
|| parseInputTimestamp(metadata.updatedAt);
|
||||
}
|
||||
|
||||
function getTimelineOriginMs(files: ServerTrackRecordingFile[]): number | null {
|
||||
const starts = files
|
||||
.map(getInputStartMs)
|
||||
.filter((timestamp) => timestamp !== null) as number[];
|
||||
if (starts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.min(...starts);
|
||||
}
|
||||
|
||||
function getTimelineDurationSeconds(files: ServerTrackRecordingFile[], timelineOriginMs: number | null): number | null {
|
||||
if (timelineOriginMs === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ends = files
|
||||
.map(getInputEndMs)
|
||||
.filter((timestamp) => timestamp !== null) as number[];
|
||||
if (ends.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationSeconds = (Math.max(...ends) - timelineOriginMs) / 1000;
|
||||
return durationSeconds > 0 ? durationSeconds : null;
|
||||
}
|
||||
|
||||
function getTimelineEndMs(files: ServerTrackRecordingFile[]): number | null {
|
||||
const ends = files
|
||||
.map(getInputEndMs)
|
||||
.filter((timestamp) => timestamp !== null) as number[];
|
||||
if (ends.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(...ends);
|
||||
}
|
||||
|
||||
function getInputOffsetSeconds(file: ServerTrackRecordingFile, timelineOriginMs: number | null): number {
|
||||
const startMs = getInputStartMs(file);
|
||||
if (startMs === null || timelineOriginMs === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, (startMs - timelineOriginMs) / 1000);
|
||||
}
|
||||
|
||||
function formatSeconds(value: number): string {
|
||||
if (value <= 0.001) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return value.toFixed(3).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function getDurationBoundVideoFilters(segmentDurationSeconds: number | null): string[] {
|
||||
if (segmentDurationSeconds === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const duration = formatSeconds(segmentDurationSeconds);
|
||||
return [
|
||||
`tpad=stop_mode=clone:stop_duration=${duration}`,
|
||||
`trim=duration=${duration}`,
|
||||
'setpts=PTS-STARTPTS'
|
||||
];
|
||||
}
|
||||
|
||||
function getBottomTileWidth(index: number, inputCount: number, outputWidth: number): number {
|
||||
const sideCount = inputCount - 1;
|
||||
if (sideCount <= 1) {
|
||||
return outputWidth;
|
||||
}
|
||||
|
||||
const rawWidth = Math.floor(outputWidth / sideCount);
|
||||
const tileWidth = rawWidth % 2 === 0 ? rawWidth : rawWidth - 1;
|
||||
return index === sideCount - 1 ? outputWidth - (tileWidth * index) : tileWidth;
|
||||
}
|
||||
|
||||
function createHostBottomLayout(inputCount: number, outputWidth: number, hostHeight: number): string {
|
||||
const positions = ['0_0'];
|
||||
const sideCount = inputCount - 1;
|
||||
let x = 0;
|
||||
for (let sideIndex = 0; sideIndex < sideCount; sideIndex += 1) {
|
||||
positions.push(`${x}_${hostHeight}`);
|
||||
x += getBottomTileWidth(sideIndex, inputCount, outputWidth);
|
||||
}
|
||||
return positions.join('|');
|
||||
}
|
||||
|
||||
function sortActiveVideoInputs(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] {
|
||||
return orderVideoInputsForComposition(files).sort((a, b) => {
|
||||
const aIsHost = isHostInput(a);
|
||||
const bIsHost = isHostInput(b);
|
||||
if (aIsHost !== bIsHost) {
|
||||
return aIsHost ? -1 : 1;
|
||||
}
|
||||
return a.participantId.localeCompare(b.participantId);
|
||||
});
|
||||
}
|
||||
|
||||
function getVideoTimelineSegments(
|
||||
files: ServerTrackRecordingFile[],
|
||||
timelineOriginMs: number | null,
|
||||
timelineEndMs: number | null
|
||||
): VideoTimelineSegment[] {
|
||||
if (timelineOriginMs === null || timelineEndMs === null || timelineEndMs <= timelineOriginMs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pointsByMs: { [timestamp: string]: boolean } = {};
|
||||
pointsByMs[String(timelineOriginMs)] = true;
|
||||
pointsByMs[String(timelineEndMs)] = true;
|
||||
files.forEach((file) => {
|
||||
const startMs = getInputStartMs(file);
|
||||
const endMs = getInputEndMs(file);
|
||||
if (startMs !== null && startMs > timelineOriginMs && startMs < timelineEndMs) {
|
||||
pointsByMs[String(startMs)] = true;
|
||||
}
|
||||
if (endMs !== null && endMs > timelineOriginMs && endMs < timelineEndMs) {
|
||||
pointsByMs[String(endMs)] = true;
|
||||
}
|
||||
});
|
||||
|
||||
const points = Object.keys(pointsByMs)
|
||||
.map((value) => Number(value))
|
||||
.sort((a, b) => a - b);
|
||||
const segments: VideoTimelineSegment[] = [];
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const startMs = points[index];
|
||||
const endMs = points[index + 1];
|
||||
if (endMs <= startMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeInputs = sortActiveVideoInputs(files.filter((file) => {
|
||||
const fileStartMs = getInputStartMs(file);
|
||||
const fileEndMs = getInputEndMs(file);
|
||||
const inputStartMs = fileStartMs === null ? timelineOriginMs : fileStartMs;
|
||||
const inputEndMs = fileEndMs === null ? timelineEndMs : fileEndMs;
|
||||
return inputStartMs < endMs && inputEndMs > startMs;
|
||||
}));
|
||||
segments.push({ startMs, endMs, activeInputs });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function getFallbackVideoTimelineSegment(
|
||||
files: ServerTrackRecordingFile[],
|
||||
timelineOriginMs: number | null
|
||||
): VideoTimelineSegment {
|
||||
return {
|
||||
startMs: timelineOriginMs === null ? 0 : timelineOriginMs,
|
||||
endMs: null,
|
||||
activeInputs: sortActiveVideoInputs(files)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFfmpegCompositionArgs(input: {
|
||||
videoInputs: ServerTrackRecordingFile[];
|
||||
audioInputs: ServerTrackRecordingFile[];
|
||||
outputPath: string;
|
||||
format: string;
|
||||
}): string[] {
|
||||
const outputWidth = COMPOSITION_OUTPUT_WIDTH;
|
||||
const outputHeight = COMPOSITION_OUTPUT_HEIGHT;
|
||||
const outputFps = COMPOSITION_OUTPUT_FPS;
|
||||
const hostHeight = COMPOSITION_HOST_HEIGHT;
|
||||
const bottomHeight = outputHeight - hostHeight;
|
||||
const videoInputs = orderVideoInputsForComposition(input.videoInputs);
|
||||
const timelineOriginMs = getTimelineOriginMs(videoInputs.concat(input.audioInputs));
|
||||
const timelineEndMs = getTimelineEndMs(videoInputs.concat(input.audioInputs));
|
||||
const timelineDurationSeconds = getTimelineDurationSeconds(videoInputs.concat(input.audioInputs), timelineOriginMs);
|
||||
const timelineVideoSegments = getVideoTimelineSegments(videoInputs, timelineOriginMs, timelineEndMs);
|
||||
const videoSegments = timelineVideoSegments.length > 0
|
||||
? timelineVideoSegments
|
||||
: [getFallbackVideoTimelineSegment(videoInputs, timelineOriginMs)];
|
||||
const args = ['-y'];
|
||||
const orderedInputs = videoInputs.concat(input.audioInputs);
|
||||
orderedInputs.forEach((file) => {
|
||||
args.push('-i', file.filePath);
|
||||
});
|
||||
|
||||
const filters: string[] = [];
|
||||
const videoInputUseCounts = videoInputs.map((file) => videoSegments.filter((segment) => segment.activeInputs.indexOf(file) >= 0).length);
|
||||
const videoInputUsePositions = videoInputs.map(() => 0);
|
||||
videoInputUseCounts.forEach((useCount, inputIndex) => {
|
||||
if (useCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const splitLabels = [];
|
||||
for (let splitIndex = 0; splitIndex < useCount; splitIndex += 1) {
|
||||
splitLabels.push(`[vin${inputIndex}_${splitIndex}]`);
|
||||
}
|
||||
filters.push(`[${inputIndex}:v]split=${useCount}${splitLabels.join('')}`);
|
||||
});
|
||||
|
||||
videoSegments.forEach((segment, segmentIndex) => {
|
||||
const segmentDurationSeconds = segment.endMs === null ? null : (segment.endMs - segment.startMs) / 1000;
|
||||
if (segment.activeInputs.length === 0) {
|
||||
if (segmentDurationSeconds === null) {
|
||||
return;
|
||||
}
|
||||
filters.push(`color=color=black:size=${outputWidth}x${outputHeight}:rate=${outputFps}:duration=${formatSeconds(segmentDurationSeconds)},format=yuv420p[seg${segmentIndex}]`);
|
||||
return;
|
||||
}
|
||||
|
||||
segment.activeInputs.forEach((file, activeIndex) => {
|
||||
const inputIndex = videoInputs.indexOf(file);
|
||||
const inputLabel = videoInputUseCounts[inputIndex] > 1
|
||||
? `vin${inputIndex}_${videoInputUsePositions[inputIndex]++}`
|
||||
: `${inputIndex}:v`;
|
||||
const fileStartMs = getInputStartMs(file);
|
||||
const inputStartMs = fileStartMs === null ? segment.startMs : fileStartMs;
|
||||
const trimStartSeconds = Math.max(0, (segment.startMs - inputStartMs) / 1000);
|
||||
const width = segment.activeInputs.length === 1
|
||||
? outputWidth
|
||||
: activeIndex === 0 ? outputWidth : getBottomTileWidth(activeIndex - 1, segment.activeInputs.length, outputWidth);
|
||||
const height = segment.activeInputs.length === 1
|
||||
? outputHeight
|
||||
: activeIndex === 0 ? hostHeight : bottomHeight;
|
||||
const trimOptions = [`start=${formatSeconds(trimStartSeconds)}`];
|
||||
if (segmentDurationSeconds !== null) {
|
||||
trimOptions.push(`duration=${formatSeconds(segmentDurationSeconds)}`);
|
||||
}
|
||||
const videoFilters = [
|
||||
`trim=${trimOptions.join(':')}`,
|
||||
'setpts=PTS-STARTPTS',
|
||||
...getDurationBoundVideoFilters(segmentDurationSeconds),
|
||||
`scale=${width}:${height}:force_original_aspect_ratio=decrease`,
|
||||
`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`,
|
||||
'setsar=1'
|
||||
];
|
||||
filters.push(`[${inputLabel}]${videoFilters.join(',')}[seg${segmentIndex}v${activeIndex}]`);
|
||||
});
|
||||
|
||||
if (segment.activeInputs.length === 1) {
|
||||
filters.push(`[seg${segmentIndex}v0]fps=${outputFps},format=yuv420p[seg${segmentIndex}]`);
|
||||
return;
|
||||
}
|
||||
|
||||
const segmentVideoLabels = segment.activeInputs.map((_file, activeIndex) => `[seg${segmentIndex}v${activeIndex}]`).join('');
|
||||
filters.push(`${segmentVideoLabels}xstack=inputs=${segment.activeInputs.length}:layout=${createHostBottomLayout(segment.activeInputs.length, outputWidth, hostHeight)}:fill=black,fps=${outputFps},format=yuv420p[seg${segmentIndex}]`);
|
||||
});
|
||||
|
||||
if (videoSegments.length === 1) {
|
||||
filters.push('[seg0]null[vout]');
|
||||
} else {
|
||||
const videoLabels = videoSegments.map((_segment, index) => `[seg${index}]`).join('');
|
||||
filters.push(`${videoLabels}concat=n=${videoSegments.length}:v=1:a=0[vout]`);
|
||||
}
|
||||
|
||||
if (input.audioInputs.length === 1) {
|
||||
const audioInputIndex = videoInputs.length;
|
||||
const offsetMs = Math.round(getInputOffsetSeconds(input.audioInputs[0], timelineOriginMs) * 1000);
|
||||
const offsetFilter = offsetMs > 1 ? `,adelay=${offsetMs}:all=1` : '';
|
||||
filters.push(`[${audioInputIndex}:a]aresample=async=1:first_pts=0${offsetFilter},asetpts=N/SR/TB[aout]`);
|
||||
} else if (input.audioInputs.length > 1) {
|
||||
const audioLabels = input.audioInputs.map((file, index) => {
|
||||
const audioInputIndex = videoInputs.length + index;
|
||||
const offsetMs = Math.round(getInputOffsetSeconds(file, timelineOriginMs) * 1000);
|
||||
const offsetFilter = offsetMs > 1 ? `,adelay=${offsetMs}:all=1` : '';
|
||||
filters.push(`[${audioInputIndex}:a]aresample=async=1:first_pts=0${offsetFilter}[a${index}]`);
|
||||
return `[a${index}]`;
|
||||
}).join('');
|
||||
filters.push(`${audioLabels}amix=inputs=${input.audioInputs.length}:duration=longest:dropout_transition=2,asetpts=N/SR/TB[aout]`);
|
||||
}
|
||||
|
||||
args.push('-filter_complex', filters.join(';'), '-map', '[vout]');
|
||||
if (input.audioInputs.length > 0) {
|
||||
args.push('-map', '[aout]');
|
||||
}
|
||||
|
||||
if (input.format === 'mp4') {
|
||||
args.push('-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-b:v', COMPOSITION_VIDEO_BITRATE, '-r', String(outputFps));
|
||||
if (input.audioInputs.length > 0) {
|
||||
args.push('-c:a', 'aac');
|
||||
}
|
||||
} else {
|
||||
args.push('-c:v', 'libvpx-vp9', '-deadline', 'good', '-cpu-used', '4', '-b:v', COMPOSITION_VIDEO_BITRATE, '-r', String(outputFps));
|
||||
if (input.audioInputs.length > 0) {
|
||||
args.push('-c:a', 'libopus');
|
||||
}
|
||||
}
|
||||
|
||||
if (timelineDurationSeconds !== null) {
|
||||
args.push('-t', formatSeconds(timelineDurationSeconds));
|
||||
}
|
||||
args.push(input.outputPath);
|
||||
return args;
|
||||
}
|
||||
|
||||
function runFfmpeg(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(getFfmpegPath(), args, { windowsHide: true });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', (error: any) => {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
reject(new Error('ffmpeg was not found. Install ffmpeg or set FFMPEG_PATH.'));
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr || `ffmpeg exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toOutput(job: RecordingCompositionJob, target: ServerTrackRecordingTarget): RecordingCompositionJob['output'] {
|
||||
return {
|
||||
meetingId: target.meetingId,
|
||||
filename: target.filename,
|
||||
filePath: target.filePath,
|
||||
metadataPath: target.metadataPath,
|
||||
downloadUrl: `/api/recordings/${encodeURIComponent(target.meetingId)}/${encodeURIComponent(target.filename)}/download`,
|
||||
streamUrl: `/api/recordings/${encodeURIComponent(target.meetingId)}/${encodeURIComponent(target.filename)}/stream`
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveCompositionJob(input: StartCompositionInput): RecordingCompositionJob | null {
|
||||
const recordingId = normalizeOption(input.recordingId, '');
|
||||
const meetingId = normalizeOption(input.meetingId, '');
|
||||
return Array.from(jobs.values()).find((job) => {
|
||||
return job.recordingId === recordingId
|
||||
&& job.meetingId === meetingId
|
||||
&& (job.status === 'queued' || job.status === 'running');
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise<RecordingCompositionJob> {
|
||||
const timestamp = nowIso();
|
||||
job.status = 'running';
|
||||
job.startedAt = timestamp;
|
||||
job.updatedAt = timestamp;
|
||||
|
||||
try {
|
||||
const inputSets = getInputSets(job);
|
||||
if (inputSets.videoInputs.length === 0) {
|
||||
throw new Error('No server-side video track files are available for composition.');
|
||||
}
|
||||
|
||||
const target = createComposedRecordingTarget({
|
||||
meetingId: job.meetingId,
|
||||
recordingId: job.recordingId,
|
||||
format: job.format
|
||||
});
|
||||
const compositionInputs = inputSets.videoInputs.concat(inputSets.audioInputs);
|
||||
const args = buildFfmpegCompositionArgs({
|
||||
...inputSets,
|
||||
outputPath: target.filePath,
|
||||
format: job.format
|
||||
});
|
||||
await runFfmpeg(args);
|
||||
writeComposedRecordingMetadata({
|
||||
target,
|
||||
recordingId: job.recordingId,
|
||||
inputs: compositionInputs,
|
||||
layout: job.layout,
|
||||
format: job.format,
|
||||
host: job.host,
|
||||
participants: job.participants
|
||||
});
|
||||
const deletedInputFiles = deleteServerTrackRecordingFiles(compositionInputs);
|
||||
|
||||
const completedAt = nowIso();
|
||||
job.status = 'completed';
|
||||
job.completedAt = completedAt;
|
||||
job.updatedAt = completedAt;
|
||||
job.inputFiles = compositionInputs.map((file) => file.filename);
|
||||
job.deletedInputFiles = deletedInputFiles;
|
||||
job.output = toOutput(job, target);
|
||||
} catch (error) {
|
||||
const failedAt = nowIso();
|
||||
job.status = 'failed';
|
||||
job.failedAt = failedAt;
|
||||
job.updatedAt = failedAt;
|
||||
job.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
export function startRecordingCompositionJob(input: StartCompositionInput): RecordingCompositionJob {
|
||||
const activeJob = getActiveCompositionJob(input);
|
||||
if (activeJob) {
|
||||
return activeJob;
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const inputSets = getInputSets(input);
|
||||
const job: RecordingCompositionJob = {
|
||||
id: uuid(),
|
||||
recordingId: normalizeOption(input.recordingId, ''),
|
||||
meetingId: normalizeOption(input.meetingId, ''),
|
||||
status: 'queued',
|
||||
layout: normalizeOption(input.layout, 'grid'),
|
||||
format: normalizeFormat(input.format),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
inputFiles: inputSets.videoInputs.concat(inputSets.audioInputs).map((file) => file.filename),
|
||||
host: input.host,
|
||||
participants: input.participants
|
||||
};
|
||||
jobs.set(job.id, job);
|
||||
runRecordingCompositionJob(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function getRecordingCompositionJob(jobId: string): RecordingCompositionJob | null {
|
||||
return jobs.get(jobId) || null;
|
||||
}
|
||||
|
||||
export function listRecordingCompositionJobs(meetingId?: string): RecordingCompositionJob[] {
|
||||
const allJobs = Array.from(jobs.values());
|
||||
return meetingId
|
||||
? allJobs.filter((job) => job.meetingId === meetingId)
|
||||
: allJobs;
|
||||
}
|
||||
|
||||
export function resetRecordingCompositionJobs(): void {
|
||||
jobs.clear();
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export type RecordingSessionStatus = 'recording' | 'stopped' | 'failed';
|
||||
|
||||
export type RecordingSession = {
|
||||
id: string;
|
||||
connectionId: string;
|
||||
status: RecordingSessionStatus;
|
||||
layout: string;
|
||||
format: string;
|
||||
createdAt: string;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
stoppedAt?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type StartRecordingSessionInput = {
|
||||
connectionId: string;
|
||||
layout?: string;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
const sessions: Map<string, RecordingSession> = new Map<string, RecordingSession>();
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeOption(value: unknown, fallback: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.slice(0, 40) : fallback;
|
||||
}
|
||||
|
||||
export function startRecordingSession(input: StartRecordingSessionInput): RecordingSession {
|
||||
const connectionId = normalizeOption(input.connectionId, '');
|
||||
if (!connectionId) {
|
||||
throw new Error('connectionId is required');
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const session: RecordingSession = {
|
||||
id: uuid(),
|
||||
connectionId,
|
||||
status: 'recording',
|
||||
layout: normalizeOption(input.layout, 'grid'),
|
||||
format: normalizeOption(input.format, 'webm'),
|
||||
createdAt: timestamp,
|
||||
startedAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
|
||||
sessions.set(session.id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function stopRecordingSession(recordingId: string): RecordingSession | null {
|
||||
const session = sessions.get(recordingId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const nextSession: RecordingSession = {
|
||||
...session,
|
||||
status: 'stopped',
|
||||
stoppedAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
|
||||
sessions.set(recordingId, nextSession);
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
export function getRecordingSession(recordingId: string): RecordingSession | null {
|
||||
return sessions.get(recordingId) || null;
|
||||
}
|
||||
|
||||
export function listRecordingSessions(connectionId?: string): RecordingSession[] {
|
||||
const allSessions = Array.from(sessions.values());
|
||||
return connectionId
|
||||
? allSessions.filter((session) => session.connectionId === connectionId)
|
||||
: allSessions;
|
||||
}
|
||||
|
||||
export function resetRecordingSessions(): void {
|
||||
sessions.clear();
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export type ServerTrackRecordingTarget = {
|
||||
meetingId: string;
|
||||
directory: string;
|
||||
filename: string;
|
||||
filePath: string;
|
||||
metadataPath: string;
|
||||
};
|
||||
|
||||
export type ServerTrackRecordingFile = ServerTrackRecordingTarget & {
|
||||
recordingId: string;
|
||||
participantId: string;
|
||||
trackId: string;
|
||||
trackKind: string;
|
||||
uploadedAt: string;
|
||||
recordingStartedAt?: string;
|
||||
recordingEndedAt?: string;
|
||||
metadata: any;
|
||||
};
|
||||
|
||||
export type RecordingPerson = {
|
||||
participantId?: string;
|
||||
userId?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
mediaState?: any;
|
||||
};
|
||||
|
||||
type CreateTargetInput = {
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
role?: string;
|
||||
kind: string;
|
||||
trackId: string;
|
||||
};
|
||||
|
||||
type CreateComposedTargetInput = {
|
||||
recordingId: string;
|
||||
meetingId: string;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
type WriteMetadataInput = CreateTargetInput & {
|
||||
target: ServerTrackRecordingTarget;
|
||||
};
|
||||
|
||||
type WriteComposedMetadataInput = {
|
||||
target: ServerTrackRecordingTarget;
|
||||
recordingId: string;
|
||||
inputs: ServerTrackRecordingFile[];
|
||||
layout: string;
|
||||
format: string;
|
||||
host?: RecordingPerson;
|
||||
participants?: RecordingPerson[];
|
||||
};
|
||||
|
||||
export function getRecordingRoot(): string {
|
||||
return path.resolve(process.env.RECORDING_DIR || path.join(process.cwd(), 'recordings'));
|
||||
}
|
||||
|
||||
export function sanitizeRecordingPathSegment(value: string | undefined, fallback: string): string {
|
||||
const sanitized = (value || fallback)
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
.replace(/^\.+/, '_')
|
||||
.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 timestampForFilename(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||
}
|
||||
|
||||
export function createServerTrackRecordingTarget(input: CreateTargetInput): ServerTrackRecordingTarget {
|
||||
const recordingRoot = getRecordingRoot();
|
||||
const meetingId = sanitizeRecordingPathSegment(input.connectionId, 'unknown');
|
||||
const recordingId = sanitizeRecordingPathSegment(input.recordingId, 'recording');
|
||||
const participantId = sanitizeRecordingPathSegment(input.participantId, 'participant');
|
||||
const kind = sanitizeRecordingPathSegment(input.kind, 'media');
|
||||
const trackId = sanitizeRecordingPathSegment(input.trackId, 'track');
|
||||
const directory = path.join(recordingRoot, meetingId);
|
||||
const filename = `${timestampForFilename()}-${recordingId}-${participantId}-${kind}-${trackId}.webm`;
|
||||
const filePath = path.join(directory, filename);
|
||||
const metadataPath = path.join(directory, `${filename}.json`);
|
||||
|
||||
if (!isPathInside(recordingRoot, filePath) || !isPathInside(recordingRoot, metadataPath)) {
|
||||
throw new Error('Invalid server recording path');
|
||||
}
|
||||
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
return { meetingId, directory, filename, filePath, metadataPath };
|
||||
}
|
||||
|
||||
export function createComposedRecordingTarget(input: CreateComposedTargetInput): ServerTrackRecordingTarget {
|
||||
const recordingRoot = getRecordingRoot();
|
||||
const meetingId = sanitizeRecordingPathSegment(input.meetingId, 'unknown');
|
||||
const recordingId = sanitizeRecordingPathSegment(input.recordingId, 'recording');
|
||||
const format = input.format === 'mp4' ? 'mp4' : 'webm';
|
||||
const directory = path.join(recordingRoot, meetingId);
|
||||
const filename = `${timestampForFilename()}-${recordingId}-composed.${format}`;
|
||||
const filePath = path.join(directory, filename);
|
||||
const metadataPath = path.join(directory, `${filename}.json`);
|
||||
|
||||
if (!isPathInside(recordingRoot, filePath) || !isPathInside(recordingRoot, metadataPath)) {
|
||||
throw new Error('Invalid composed recording path');
|
||||
}
|
||||
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
return { meetingId, directory, filename, filePath, metadataPath };
|
||||
}
|
||||
|
||||
export function writeServerTrackRecordingMetadata(input: WriteMetadataInput): void {
|
||||
const now = new Date().toISOString();
|
||||
const role = input.role === 'host' ? 'host' : 'participant';
|
||||
const metadata = {
|
||||
id: `${input.recordingId}-${input.participantId}-${input.kind}-${input.trackId}`,
|
||||
meetingId: input.target.meetingId,
|
||||
filename: input.target.filename,
|
||||
originalFilename: `server-recording-${input.participantId}-${input.kind}.webm`,
|
||||
mimetype: 'video/webm',
|
||||
size: 0,
|
||||
userId: 'server-recorder',
|
||||
host: {
|
||||
userId: 'server-recorder',
|
||||
id: 'server-recorder',
|
||||
name: 'Server Recorder',
|
||||
role: 'recorder'
|
||||
},
|
||||
participants: [
|
||||
{
|
||||
participantId: input.participantId,
|
||||
id: input.participantId,
|
||||
role
|
||||
}
|
||||
],
|
||||
uploadedAt: now,
|
||||
updatedAt: now,
|
||||
recordingStartedAt: now,
|
||||
recordingSource: 'server',
|
||||
recordingId: input.recordingId,
|
||||
participantId: input.participantId,
|
||||
role,
|
||||
trackId: input.trackId,
|
||||
trackKind: input.kind
|
||||
};
|
||||
|
||||
fs.writeFileSync(input.target.metadataPath, JSON.stringify(metadata, null, 2));
|
||||
}
|
||||
|
||||
export function updateServerTrackRecordingMetadataSize(target: ServerTrackRecordingTarget): void {
|
||||
if (!fs.existsSync(target.metadataPath) || !fs.existsSync(target.filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(fs.readFileSync(target.metadataPath, 'utf8'));
|
||||
metadata.size = fs.statSync(target.filePath).size;
|
||||
metadata.updatedAt = new Date().toISOString();
|
||||
metadata.recordingEndedAt = metadata.updatedAt;
|
||||
fs.writeFileSync(target.metadataPath, JSON.stringify(metadata, null, 2));
|
||||
}
|
||||
|
||||
export function listServerTrackRecordingFiles(input: {
|
||||
meetingId: string;
|
||||
recordingId?: string;
|
||||
trackKind?: string;
|
||||
}): ServerTrackRecordingFile[] {
|
||||
const recordingRoot = getRecordingRoot();
|
||||
const meetingId = sanitizeRecordingPathSegment(input.meetingId, 'unknown');
|
||||
const directory = path.join(recordingRoot, meetingId);
|
||||
if (!fs.existsSync(directory)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(directory)
|
||||
.filter((filename) => path.extname(filename).toLowerCase() === '.webm')
|
||||
.map((filename) => {
|
||||
const filePath = path.join(directory, filename);
|
||||
const metadataPath = path.join(directory, `${filename}.json`);
|
||||
if (!fs.statSync(filePath).isFile() || !fs.existsSync(metadataPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
||||
if (metadata.recordingSource !== 'server') {
|
||||
return null;
|
||||
}
|
||||
if (input.recordingId && metadata.recordingId !== input.recordingId) {
|
||||
return null;
|
||||
}
|
||||
if (input.trackKind && metadata.trackKind !== input.trackKind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
meetingId,
|
||||
directory,
|
||||
filename,
|
||||
filePath,
|
||||
metadataPath,
|
||||
metadata,
|
||||
recordingId: metadata.recordingId || '',
|
||||
participantId: metadata.participantId || '',
|
||||
trackId: metadata.trackId || '',
|
||||
trackKind: metadata.trackKind || '',
|
||||
uploadedAt: metadata.uploadedAt || fs.statSync(filePath).birthtime.toISOString(),
|
||||
recordingStartedAt: metadata.recordingStartedAt || metadata.uploadedAt,
|
||||
recordingEndedAt: metadata.recordingEndedAt || metadata.updatedAt
|
||||
};
|
||||
})
|
||||
.filter((file) => Boolean(file)) as ServerTrackRecordingFile[];
|
||||
}
|
||||
|
||||
export function deleteServerTrackRecordingFiles(files: ServerTrackRecordingFile[]): string[] {
|
||||
const deletedFiles: string[] = [];
|
||||
files.forEach((file) => {
|
||||
if (fs.existsSync(file.filePath)) {
|
||||
fs.unlinkSync(file.filePath);
|
||||
deletedFiles.push(file.filename);
|
||||
}
|
||||
if (fs.existsSync(file.metadataPath)) {
|
||||
fs.unlinkSync(file.metadataPath);
|
||||
deletedFiles.push(`${file.filename}.json`);
|
||||
}
|
||||
});
|
||||
return deletedFiles;
|
||||
}
|
||||
|
||||
function getPersonKey(person: RecordingPerson | undefined): string {
|
||||
return person?.participantId || person?.userId || person?.id || '';
|
||||
}
|
||||
|
||||
function normalizeRecordingPerson(person: RecordingPerson | undefined, fallbackRole: string): RecordingPerson | undefined {
|
||||
if (!person || typeof person !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
participantId: person.participantId || '',
|
||||
userId: person.userId || person.id || '',
|
||||
id: person.id || person.userId || person.participantId || '',
|
||||
name: person.name || person.userId || person.id || person.participantId || '',
|
||||
avatar: person.avatar || '',
|
||||
role: person.role || fallbackRole,
|
||||
status: person.status || '',
|
||||
mediaState: person.mediaState
|
||||
};
|
||||
}
|
||||
|
||||
function collectInputPeople(inputs: ServerTrackRecordingFile[]): {
|
||||
host?: RecordingPerson;
|
||||
participants: RecordingPerson[];
|
||||
} {
|
||||
const participantsByKey: { [key: string]: RecordingPerson } = {};
|
||||
let host: RecordingPerson | undefined;
|
||||
|
||||
inputs.forEach((file) => {
|
||||
const metadata = file.metadata || {};
|
||||
const fileRole = metadata.role === 'host' ? 'host' : 'participant';
|
||||
const metadataHost = normalizeRecordingPerson(metadata.host, 'host');
|
||||
if (metadataHost && metadataHost.role === 'host' && !host) {
|
||||
host = metadataHost;
|
||||
}
|
||||
|
||||
const people = Array.isArray(metadata.participants) ? metadata.participants : [];
|
||||
people.forEach((person: RecordingPerson) => {
|
||||
const normalized = normalizeRecordingPerson(person, person.role || fileRole);
|
||||
const key = getPersonKey(normalized);
|
||||
if (!normalized || !key) {
|
||||
return;
|
||||
}
|
||||
if (normalized.role === 'host' && !host) {
|
||||
host = { ...normalized, role: 'host' };
|
||||
return;
|
||||
}
|
||||
if (normalized.role !== 'host' && !participantsByKey[key]) {
|
||||
participantsByKey[key] = { ...normalized, role: 'participant' };
|
||||
}
|
||||
});
|
||||
|
||||
if (file.participantId) {
|
||||
const person = normalizeRecordingPerson({
|
||||
participantId: file.participantId,
|
||||
id: file.participantId,
|
||||
role: fileRole
|
||||
}, fileRole);
|
||||
const key = getPersonKey(person);
|
||||
if (person && key) {
|
||||
if (fileRole === 'host' && !host) {
|
||||
host = { ...person, role: 'host' };
|
||||
} else if (fileRole !== 'host' && !participantsByKey[key]) {
|
||||
participantsByKey[key] = { ...person, role: 'participant' };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
host,
|
||||
participants: Object.keys(participantsByKey).map((key) => participantsByKey[key])
|
||||
};
|
||||
}
|
||||
|
||||
export function writeComposedRecordingMetadata(input: WriteComposedMetadataInput): void {
|
||||
const now = new Date().toISOString();
|
||||
const inputPeople = collectInputPeople(input.inputs);
|
||||
const host = normalizeRecordingPerson(input.host, 'host') || inputPeople.host;
|
||||
const participants = Array.isArray(input.participants) && input.participants.length > 0
|
||||
? input.participants
|
||||
.map((participant) => normalizeRecordingPerson(participant, 'participant'))
|
||||
.filter((participant) => Boolean(participant)) as RecordingPerson[]
|
||||
: inputPeople.participants;
|
||||
|
||||
const metadata = {
|
||||
id: `${input.recordingId}-composed`,
|
||||
meetingId: input.target.meetingId,
|
||||
filename: input.target.filename,
|
||||
originalFilename: `server-recording-${input.recordingId}-composed.${input.format}`,
|
||||
mimetype: input.format === 'mp4' ? 'video/mp4' : 'video/webm',
|
||||
size: fs.existsSync(input.target.filePath) ? fs.statSync(input.target.filePath).size : 0,
|
||||
userId: host?.userId || host?.id || '',
|
||||
host,
|
||||
participants,
|
||||
uploadedAt: now,
|
||||
updatedAt: now,
|
||||
recordingSource: 'server-composed',
|
||||
recordingId: input.recordingId,
|
||||
layout: input.layout,
|
||||
inputFiles: input.inputs.map((file) => file.filename)
|
||||
};
|
||||
|
||||
fs.writeFileSync(input.target.metadataPath, JSON.stringify(metadata, null, 2));
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
import {
|
||||
incrementRecordingTrackPackets,
|
||||
registerRecordingPeerAnswer,
|
||||
registerRecordingPeerTrack
|
||||
} from './agent';
|
||||
import { log, LogLevel } from '../log';
|
||||
import {
|
||||
ServerTrackRecordingTarget,
|
||||
createServerTrackRecordingTarget,
|
||||
updateServerTrackRecordingMetadataSize,
|
||||
writeServerTrackRecordingMetadata
|
||||
} from './storage';
|
||||
|
||||
type RecordingPeerKey = string;
|
||||
type RecordingTrackRecorderKey = string;
|
||||
type WeriftPeerConnection = any;
|
||||
type WeriftMediaRecorder = any;
|
||||
|
||||
const werift = require('werift');
|
||||
const RTCPeerConnection = werift.RTCPeerConnection;
|
||||
const weriftNonstandard = require('werift/nonstandard');
|
||||
const MediaRecorder = weriftNonstandard.MediaRecorder;
|
||||
const SERVER_RECORDING_WIDTH = 2560;
|
||||
const SERVER_RECORDING_HEIGHT = 1440;
|
||||
const SERVER_RECORDING_JITTER_BUFFER_LATENCY_MS = 1000;
|
||||
const SERVER_RECORDING_JITTER_BUFFER_SIZE = 50000;
|
||||
|
||||
type RecordingPeerState = {
|
||||
pc: WeriftPeerConnection;
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
pendingCandidates: Array<{
|
||||
candidate: string;
|
||||
sdpMid?: string;
|
||||
sdpMLineIndex?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
type RecordingTrackRecorderState = {
|
||||
recorder: WeriftMediaRecorder;
|
||||
target: ServerTrackRecordingTarget;
|
||||
recordingId: string;
|
||||
participantId: string;
|
||||
trackId: string;
|
||||
};
|
||||
|
||||
type AcceptOfferInput = {
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
role?: string;
|
||||
sdp: string;
|
||||
onLocalCandidate?: (candidate: any) => void;
|
||||
};
|
||||
|
||||
const peers: Map<RecordingPeerKey, RecordingPeerState> = new Map<RecordingPeerKey, RecordingPeerState>();
|
||||
const trackRecorders: Map<RecordingTrackRecorderKey, RecordingTrackRecorderState> = new Map<RecordingTrackRecorderKey, RecordingTrackRecorderState>();
|
||||
|
||||
function peerKey(recordingId: string, participantId: string): RecordingPeerKey {
|
||||
return `${recordingId}:${participantId}`;
|
||||
}
|
||||
|
||||
function trackRecorderKey(recordingId: string, participantId: string, trackId: string): RecordingTrackRecorderKey {
|
||||
return `${recordingId}:${participantId}:${trackId}`;
|
||||
}
|
||||
|
||||
function getPeer(recordingId: string, participantId: string): RecordingPeerState | null {
|
||||
return peers.get(peerKey(recordingId, participantId)) || null;
|
||||
}
|
||||
|
||||
function createPeer(input: AcceptOfferInput): RecordingPeerState {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceUseIpv4: true,
|
||||
iceUseIpv6: false
|
||||
});
|
||||
const state: RecordingPeerState = {
|
||||
pc,
|
||||
recordingId: input.recordingId,
|
||||
connectionId: input.connectionId,
|
||||
participantId: input.participantId,
|
||||
pendingCandidates: []
|
||||
};
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && input.onLocalCandidate) {
|
||||
input.onLocalCandidate(event.candidate);
|
||||
}
|
||||
};
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
const trackId = event.track.id || event.track.uuid || `${event.track.kind}-${Date.now()}`;
|
||||
registerRecordingPeerTrack({
|
||||
recordingId: input.recordingId,
|
||||
connectionId: input.connectionId,
|
||||
participantId: input.participantId,
|
||||
kind: event.track.kind,
|
||||
trackId
|
||||
});
|
||||
startTrackRecorder({
|
||||
recordingId: input.recordingId,
|
||||
connectionId: input.connectionId,
|
||||
participantId: input.participantId,
|
||||
role: input.role,
|
||||
kind: event.track.kind,
|
||||
trackId,
|
||||
track: event.track
|
||||
});
|
||||
event.track.onReceiveRtp.subscribe(() => {
|
||||
incrementRecordingTrackPackets({
|
||||
recordingId: input.recordingId,
|
||||
participantId: input.participantId,
|
||||
trackId
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
peers.set(peerKey(input.recordingId, input.participantId), state);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function flushPendingCandidates(state: RecordingPeerState): Promise<void> {
|
||||
if (!state.pc.remoteDescription || state.pendingCandidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingCandidates = state.pendingCandidates.splice(0, state.pendingCandidates.length);
|
||||
for (const candidate of pendingCandidates) {
|
||||
await state.pc.addIceCandidate(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function startTrackRecorder(input: {
|
||||
recordingId: string;
|
||||
connectionId: string;
|
||||
participantId: string;
|
||||
role?: string;
|
||||
kind: string;
|
||||
trackId: string;
|
||||
track: any;
|
||||
}): void {
|
||||
const key = trackRecorderKey(input.recordingId, input.participantId, input.trackId);
|
||||
if (trackRecorders.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const target = createServerTrackRecordingTarget(input);
|
||||
writeServerTrackRecordingMetadata({ ...input, target });
|
||||
const recorder = new MediaRecorder({
|
||||
path: target.filePath,
|
||||
tracks: [input.track],
|
||||
width: SERVER_RECORDING_WIDTH,
|
||||
height: SERVER_RECORDING_HEIGHT,
|
||||
disableLipSync: true,
|
||||
jitterBuffer: {
|
||||
latency: SERVER_RECORDING_JITTER_BUFFER_LATENCY_MS,
|
||||
bufferSize: SERVER_RECORDING_JITTER_BUFFER_SIZE
|
||||
},
|
||||
defaultDuration: 24 * 60 * 60
|
||||
});
|
||||
|
||||
if (recorder.onError && typeof recorder.onError.subscribe === 'function') {
|
||||
recorder.onError.subscribe((error: Error) => {
|
||||
log(LogLevel.warn, 'Server recording writer failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
trackRecorders.set(key, {
|
||||
recorder,
|
||||
target,
|
||||
recordingId: input.recordingId,
|
||||
participantId: input.participantId,
|
||||
trackId: input.trackId
|
||||
});
|
||||
} catch (error) {
|
||||
log(LogLevel.error, 'Failed to start server track recorder:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopTrackRecorders(recordingId: string, participantId?: string): Promise<void> {
|
||||
const keys = Array.from(trackRecorders.keys()).filter((key) => {
|
||||
if (!recordingId) {
|
||||
return true;
|
||||
}
|
||||
if (participantId) {
|
||||
return key.startsWith(`${recordingId}:${participantId}:`);
|
||||
}
|
||||
return key.startsWith(`${recordingId}:`);
|
||||
});
|
||||
|
||||
for (const key of keys) {
|
||||
const state = trackRecorders.get(key);
|
||||
if (!state) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await state.recorder.stop();
|
||||
updateServerTrackRecordingMetadataSize(state.target);
|
||||
} catch (error) {
|
||||
log(LogLevel.warn, 'Failed to stop server track recorder:', error);
|
||||
} finally {
|
||||
trackRecorders.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptRecordingOffer(input: AcceptOfferInput): Promise<string> {
|
||||
const existing = getPeer(input.recordingId, input.participantId);
|
||||
if (existing) {
|
||||
await stopTrackRecorders(input.recordingId, input.participantId);
|
||||
await existing.pc.close();
|
||||
peers.delete(peerKey(input.recordingId, input.participantId));
|
||||
}
|
||||
|
||||
const state = createPeer(input);
|
||||
await state.pc.setRemoteDescription({
|
||||
type: 'offer',
|
||||
sdp: input.sdp
|
||||
});
|
||||
await flushPendingCandidates(state);
|
||||
const answer = await state.pc.createAnswer();
|
||||
await state.pc.setLocalDescription(answer);
|
||||
const sdp = state.pc.localDescription ? state.pc.localDescription.sdp : answer.sdp;
|
||||
|
||||
registerRecordingPeerAnswer({
|
||||
recordingId: input.recordingId,
|
||||
connectionId: input.connectionId,
|
||||
participantId: input.participantId,
|
||||
sdp
|
||||
});
|
||||
|
||||
return sdp;
|
||||
}
|
||||
|
||||
export async function addRecordingIceCandidate(input: {
|
||||
recordingId: string;
|
||||
participantId: string;
|
||||
candidate: string;
|
||||
sdpMid?: string;
|
||||
sdpMLineIndex?: number;
|
||||
}): Promise<boolean> {
|
||||
const state = getPeer(input.recordingId, input.participantId);
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
candidate: input.candidate,
|
||||
sdpMid: input.sdpMid,
|
||||
sdpMLineIndex: input.sdpMLineIndex
|
||||
};
|
||||
|
||||
if (!state.pc.remoteDescription) {
|
||||
state.pendingCandidates.push(candidate);
|
||||
return true;
|
||||
}
|
||||
|
||||
await state.pc.addIceCandidate(candidate);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function stopRecordingPeer(recordingId: string, participantId?: string): Promise<void> {
|
||||
await stopTrackRecorders(recordingId, participantId);
|
||||
const keys = Array.from(peers.keys()).filter((key) => {
|
||||
if (participantId) {
|
||||
return key === peerKey(recordingId, participantId);
|
||||
}
|
||||
return key.startsWith(`${recordingId}:`);
|
||||
});
|
||||
|
||||
for (const key of keys) {
|
||||
const state = peers.get(key);
|
||||
if (state) {
|
||||
await state.pc.close();
|
||||
peers.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetRecordingPeers(): Promise<void> {
|
||||
await stopTrackRecorders('');
|
||||
const keys = Array.from(peers.keys());
|
||||
for (const key of keys) {
|
||||
const state = peers.get(key);
|
||||
if (state) {
|
||||
await state.pc.close();
|
||||
}
|
||||
peers.delete(key);
|
||||
}
|
||||
}
|
||||
82
src/server-audio-recording-api.md
Normal file
82
src/server-audio-recording-api.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Server Audio Recording API
|
||||
|
||||
This API lets Unity keep local video recording while the server records app audio as an extra WebRTC peer. When Unity stops local recording, upload the local video to the stop endpoint and the server merges it with the recorded audio.
|
||||
|
||||
## 1. Start
|
||||
|
||||
`POST /api/server-audio-recordings/start`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"meetingId": "room-001",
|
||||
"offerSdp": "v=0...",
|
||||
"iceServers": []
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"recordingId": "uuid",
|
||||
"meetingId": "room-001",
|
||||
"answerSdp": "v=0...",
|
||||
"candidates": []
|
||||
}
|
||||
```
|
||||
|
||||
Unity should create a peer connection with an audio track only, send its offer SDP here, then set the returned answer SDP as the remote description.
|
||||
|
||||
## 2. Trickle ICE
|
||||
|
||||
`POST /api/server-audio-recordings/{recordingId}/candidate`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"candidate": "candidate:...",
|
||||
"sdpMid": "0",
|
||||
"sdpMLineIndex": 0
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Stop And Merge
|
||||
|
||||
`POST /api/server-audio-recordings/{recordingId}/stop`
|
||||
|
||||
Content type: `multipart/form-data`
|
||||
|
||||
Fields:
|
||||
|
||||
- `video`: the local video file recorded by Unity.
|
||||
- `meetingId`: optional, overrides the start meeting id.
|
||||
- `filename`: optional display filename.
|
||||
- `userId`: optional host user id.
|
||||
- `host`: optional JSON host metadata.
|
||||
- `participants`: optional JSON participant metadata array.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"recordingId": "uuid",
|
||||
"meetingId": "room-001",
|
||||
"filename": "2026-06-02T13-00-00-000Z-uuid.mp4",
|
||||
"merged": true,
|
||||
"url": "/api/recordings/room-001/2026-06-02T13-00-00-000Z-uuid.mp4/download"
|
||||
}
|
||||
```
|
||||
|
||||
The server keeps the local video track, replaces audio with the server-recorded app audio, and stores the merged file in the existing `recordings` directory.
|
||||
|
||||
## 4. Cancel
|
||||
|
||||
`DELETE /api/server-audio-recordings/{recordingId}`
|
||||
|
||||
Use this if local recording is aborted and no merged output should be saved.
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# Server Recording Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Move meeting recording from browser-only `MediaRecorder` to a server-side recorder. The server creates a recording session, asks every client in the room to publish local media to a dedicated recorder peer, and stores received media under the existing recordings directory.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
### HTTP APIs
|
||||
|
||||
- `GET /api/recording-sessions`
|
||||
- Lists active and historical in-memory recording sessions.
|
||||
- Optional query: `connectionId`.
|
||||
- `GET /api/recording-sessions/:recordingId`
|
||||
- Returns a session plus its recorder agent state.
|
||||
- `POST /api/recording-sessions`
|
||||
- Body: `{ "connectionId": "...", "layout": "grid", "format": "webm" }`
|
||||
- Creates one active server recording session for a room.
|
||||
- Broadcasts `recording-started` and `recording-peer-request`.
|
||||
- `DELETE /api/recording-sessions/:recordingId`
|
||||
- Stops the session, closes recorder peers, finalizes recorder metadata, and broadcasts `recording-stopped`.
|
||||
- Starts a background composition job by default. Use `?compose=false` to skip composition.
|
||||
- `GET /api/recording-compositions`
|
||||
- Lists background composition jobs.
|
||||
- Optional query: `meetingId`.
|
||||
- `GET /api/recording-compositions/:compositionId`
|
||||
- Returns a single composition job.
|
||||
- `POST /api/recording-compositions`
|
||||
- Body: `{ "meetingId": "...", "recordingId": "...", "layout": "grid", "format": "webm" }`
|
||||
- Starts a background composition job manually.
|
||||
|
||||
### WebSocket Messages
|
||||
|
||||
- Server to clients:
|
||||
- `recording-started`
|
||||
- `recording-peer-request`
|
||||
- `recording-stopped`
|
||||
- `recording-answer`
|
||||
- `recording-candidate`
|
||||
- `recording-status`
|
||||
- Client to server:
|
||||
- `recording-offer`
|
||||
- `recording-candidate`
|
||||
- `recording-status`
|
||||
|
||||
### Media Flow
|
||||
|
||||
1. Host clicks the existing recording button.
|
||||
2. Client calls `POST /api/recording-sessions`.
|
||||
3. Server broadcasts a recorder peer request to the whole room.
|
||||
4. Each client creates an independent `RTCPeerConnection` with local tracks as `sendonly`.
|
||||
5. Server accepts each offer through `werift`.
|
||||
6. Each received track is written as an individual WebM file with a metadata JSON file.
|
||||
7. When recording stops, the server starts a composition job.
|
||||
8. The composition job uses FFmpeg to create one grid video and mixed audio artifact.
|
||||
9. Existing `/api/recordings` list, stream, download, patch, and delete APIs can see both raw track files and composed files.
|
||||
|
||||
### Candidate Ordering
|
||||
|
||||
Recorder peer candidates can arrive before the browser has applied the server answer. The client recorder peer buffers those candidates until `setRemoteDescription(answer)` completes, then flushes them in order. This avoids `InvalidStateError: remote description was null`.
|
||||
|
||||
## Storage
|
||||
|
||||
Default root:
|
||||
|
||||
```text
|
||||
recordings/
|
||||
```
|
||||
|
||||
Override with:
|
||||
|
||||
```text
|
||||
RECORDING_DIR=/absolute/path/to/recordings
|
||||
```
|
||||
|
||||
Current server-side files are stored as:
|
||||
|
||||
```text
|
||||
recordings/<connectionId>/<timestamp>-<recordingId>-<participantId>-<kind>-<trackId>.webm
|
||||
recordings/<connectionId>/<timestamp>-<recordingId>-<participantId>-<kind>-<trackId>.webm.json
|
||||
```
|
||||
|
||||
Composed files are stored as:
|
||||
|
||||
```text
|
||||
recordings/<connectionId>/<timestamp>-<recordingId>-composed.webm
|
||||
recordings/<connectionId>/<timestamp>-<recordingId>-composed.webm.json
|
||||
```
|
||||
|
||||
## Composition Runtime
|
||||
|
||||
Composition requires FFmpeg on the server.
|
||||
|
||||
Configure either:
|
||||
|
||||
```text
|
||||
FFMPEG_PATH=/absolute/path/to/ffmpeg
|
||||
```
|
||||
|
||||
or put `ffmpeg` on the server `PATH`.
|
||||
|
||||
The default output format is WebM:
|
||||
|
||||
- Video codec: `libvpx-vp9`
|
||||
- Audio codec: `libopus`
|
||||
|
||||
MP4 output is also supported:
|
||||
|
||||
- Video codec: `libx264`
|
||||
- Audio codec: `aac`
|
||||
|
||||
## Lifecycle Rules
|
||||
|
||||
- A room can have only one active server recording session.
|
||||
- New clients joining a room with an active recording immediately receive a recorder peer request.
|
||||
- If a participant leaves, only that participant's recorder peer is closed.
|
||||
- If the host leaves, active recording sessions for that room are stopped and all recorder peers are closed.
|
||||
- If local media changes while recording is active, the client restarts its recorder peer so the server receives the latest track set.
|
||||
- After a composition job completes successfully, the raw per-track `.webm` files and their `.webm.json` metadata files are deleted. Failed composition jobs keep raw files so they can be retried.
|
||||
|
||||
## Current Limitation
|
||||
|
||||
The first composition layout is a deterministic grid plus audio mix. It does not yet support active-speaker switching, custom branding, timestamp overlays, or per-user name plates. Raw track files are still kept so failed composition jobs can be retried.
|
||||
|
||||
## Validation
|
||||
|
||||
```text
|
||||
npm.cmd run build
|
||||
npm.cmd test -- --runInBand
|
||||
npm.cmd run lint
|
||||
```
|
||||
390
src/server.ts
390
src/server.ts
@@ -2,35 +2,14 @@ import * as express from 'express';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as morgan from 'morgan';
|
||||
import { spawn } from 'child_process';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import signaling from './signaling';
|
||||
import { log, LogLevel } from './log';
|
||||
import Options from './class/options';
|
||||
import { reset as resetHandler } from './class/httphandler';
|
||||
import {
|
||||
broadcastRecordingPeerRequest,
|
||||
broadcastRecordingStarted,
|
||||
broadcastRecordingStopped,
|
||||
onGetRooms as getWebSocketRooms
|
||||
} from './class/websockethandler';
|
||||
import {
|
||||
getRecordingAgent,
|
||||
startRecordingAgent,
|
||||
stopRecordingAgent
|
||||
} from './recording/agent';
|
||||
import {
|
||||
getRecordingSession,
|
||||
listRecordingSessions,
|
||||
startRecordingSession,
|
||||
stopRecordingSession
|
||||
} from './recording/session-manager';
|
||||
import {
|
||||
getRecordingCompositionJob,
|
||||
listRecordingCompositionJobs,
|
||||
startRecordingCompositionJob
|
||||
} from './recording/composer';
|
||||
import { stopRecordingPeer } from './recording/werift-adapter';
|
||||
import { initSwagger } from './swagger';
|
||||
import { ServerAudioRecorderManager } from './class/serveraudiorecorder';
|
||||
|
||||
const cors = require('cors');
|
||||
const multer = require('multer');
|
||||
@@ -164,9 +143,9 @@ function sanitizeMetadataString(value: any, maxLength = 200): string {
|
||||
|
||||
return String(value)
|
||||
.split('')
|
||||
.filter((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code >= 32 && code !== 127;
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code > 31 && code !== 127;
|
||||
})
|
||||
.join('')
|
||||
.trim()
|
||||
@@ -316,45 +295,73 @@ function removeEmptyDirectory(directory: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveRecordingSession(connectionId: string) {
|
||||
const sessions = listRecordingSessions(connectionId);
|
||||
for (const session of sessions) {
|
||||
if (session.status === 'recording') {
|
||||
return session;
|
||||
function removeFileIfExists(filePath: string | undefined): void {
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
log(LogLevel.warn, 'Failed to remove temporary file:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function roomMemberToRecordingPerson(member: any, fallbackRole: string): RecordingPerson | undefined {
|
||||
if (!member || typeof member !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return sanitizeRecordingPerson({
|
||||
participantId: member.participantId,
|
||||
userId: member.userId || member.id,
|
||||
id: member.id || member.userId,
|
||||
name: member.name,
|
||||
avatar: member.avatar,
|
||||
role: member.role || fallbackRole,
|
||||
status: member.status,
|
||||
mediaState: member.mediaState
|
||||
}, fallbackRole);
|
||||
function getMergedRecordingExtension(videoExt: string): string {
|
||||
return videoExt.toLowerCase() === '.webm' ? '.webm' : '.mp4';
|
||||
}
|
||||
|
||||
function getRecordingRoomPeople(connectionId: string): { host?: RecordingPerson; participants: RecordingPerson[] } {
|
||||
const room = getWebSocketRooms(connectionId)[0];
|
||||
const members = Array.isArray(room?.members) ? room.members : [];
|
||||
const hostMember = members.find((member: any) => member.role === 'host')
|
||||
|| members.find((member: any) => member.socketId && member.socketId === room?.hostSocketId);
|
||||
const host = roomMemberToRecordingPerson(hostMember, 'host');
|
||||
const participants = members
|
||||
.filter((member: any) => member !== hostMember && member.role === 'participant')
|
||||
.map((member: any) => roomMemberToRecordingPerson(member, 'participant'))
|
||||
.filter((member: RecordingPerson | undefined) => Boolean(member)) as RecordingPerson[];
|
||||
function runFfmpeg(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ffmpegPath = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
const child = spawn(ffmpegPath, args, { windowsHide: true });
|
||||
let stderr = '';
|
||||
|
||||
return { host, participants };
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`ffmpeg exited with code ${code}: ${stderr.slice(-2000)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mergeVideoWithServerAudio(videoPath: string, audioPath: string, outputPath: string, outputExt: string): Promise<void> {
|
||||
const isWebmOutput = outputExt.toLowerCase() === '.webm';
|
||||
const args = isWebmOutput
|
||||
? [
|
||||
'-y',
|
||||
'-i', videoPath,
|
||||
'-i', audioPath,
|
||||
'-map', '0:v:0',
|
||||
'-map', '1:a:0',
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'libopus',
|
||||
'-shortest',
|
||||
outputPath
|
||||
]
|
||||
: [
|
||||
'-y',
|
||||
'-i', videoPath,
|
||||
'-i', audioPath,
|
||||
'-map', '0:v:0',
|
||||
'-map', '1:a:0',
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'aac',
|
||||
'-shortest',
|
||||
'-movflags', '+faststart',
|
||||
outputPath
|
||||
];
|
||||
|
||||
return runFfmpeg(args);
|
||||
}
|
||||
|
||||
export const createServer = (config: Options): express.Express => {
|
||||
@@ -491,6 +498,7 @@ export const createServer = (config: Options): express.Express => {
|
||||
|
||||
const recordingRoot = getRecordingRoot();
|
||||
const recordingTempDir = path.join(recordingRoot, '.tmp');
|
||||
const serverAudioRecordings = new ServerAudioRecorderManager(recordingTempDir);
|
||||
const recordingStorage = multer.diskStorage({
|
||||
destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => {
|
||||
if (!fs.existsSync(recordingTempDir)) {
|
||||
@@ -524,122 +532,192 @@ export const createServer = (config: Options): express.Express => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/recording-sessions', (req: express.Request, res: express.Response) => {
|
||||
const connectionId = typeof req.query.connectionId === 'string'
|
||||
? sanitizeMetadataString(req.query.connectionId, 120)
|
||||
: undefined;
|
||||
const sessions = listRecordingSessions(connectionId);
|
||||
res.json({ success: true, sessions, totalCount: sessions.length });
|
||||
});
|
||||
|
||||
app.get('/api/recording-sessions/:recordingId', (req: express.Request, res: express.Response) => {
|
||||
const session = getRecordingSession(req.params.recordingId);
|
||||
if (!session) {
|
||||
res.status(404).json({ success: false, message: 'Recording session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, session, agent: getRecordingAgent(session.id) });
|
||||
});
|
||||
|
||||
app.post('/api/recording-sessions', (req: express.Request, res: express.Response) => {
|
||||
const connectionId = sanitizeMetadataString(req.body.connectionId, 120);
|
||||
if (!connectionId) {
|
||||
res.status(400).json({ success: false, message: 'connectionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.type === 'websocket' && getWebSocketRooms(connectionId).length === 0) {
|
||||
res.status(404).json({ success: false, message: 'Active WebSocket room not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSession = getActiveRecordingSession(connectionId);
|
||||
if (activeSession) {
|
||||
res.status(409).json({ success: false, message: 'Recording is already running', session: activeSession });
|
||||
return;
|
||||
}
|
||||
|
||||
app.post('/api/server-audio-recordings/start', async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const session = startRecordingSession({
|
||||
connectionId,
|
||||
layout: req.body.layout,
|
||||
format: req.body.format
|
||||
const offerSdp = req.body.offerSdp || req.body.sdp;
|
||||
const meetingId = sanitizePathSegment(req.body.meetingId, 'unknown');
|
||||
const started = await serverAudioRecordings.start({
|
||||
meetingId,
|
||||
offerSdp,
|
||||
iceServers: Array.isArray(req.body.iceServers) ? req.body.iceServers : undefined
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
recordingId: started.recordingId,
|
||||
meetingId: started.meetingId,
|
||||
answerSdp: started.answerSdp,
|
||||
candidates: started.candidates
|
||||
});
|
||||
const agent = startRecordingAgent(session);
|
||||
const notified = broadcastRecordingStarted(session);
|
||||
const peerRequestNotified = broadcastRecordingPeerRequest(session);
|
||||
res.status(201).json({ success: true, session, agent, notified, peerRequestNotified });
|
||||
} catch (error) {
|
||||
log(LogLevel.error, 'Failed to start recording session:', error);
|
||||
res.status(500).json({ success: false, message: 'Failed to start recording session' });
|
||||
log(LogLevel.error, 'Failed to start server audio recording:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to start server audio recording'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/recording-sessions/:recordingId', async (req: express.Request, res: express.Response) => {
|
||||
const session = stopRecordingSession(req.params.recordingId);
|
||||
if (!session) {
|
||||
res.status(404).json({ success: false, message: 'Recording session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notified = broadcastRecordingStopped(session);
|
||||
const agent = stopRecordingAgent(session.id);
|
||||
app.post('/api/server-audio-recordings/:recordingId/candidate', async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
await stopRecordingPeer(session.id);
|
||||
const recordingId = sanitizePathSegment(req.params.recordingId, '');
|
||||
const candidate = req.body.candidate && typeof req.body.candidate === 'object'
|
||||
? req.body.candidate
|
||||
: {
|
||||
candidate: req.body.candidate,
|
||||
sdpMid: req.body.sdpMid,
|
||||
sdpMLineIndex: req.body.sdpMLineIndex
|
||||
};
|
||||
const added = await serverAudioRecordings.addCandidate(recordingId, candidate);
|
||||
|
||||
if (!added) {
|
||||
res.status(404).json({ success: false, message: 'Server audio recording not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
log(LogLevel.warn, 'Failed to stop recording peer:', error);
|
||||
log(LogLevel.error, 'Failed to add server audio ICE candidate:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to add server audio ICE candidate'
|
||||
});
|
||||
}
|
||||
|
||||
const shouldCompose = req.query.compose !== 'false';
|
||||
const roomPeople = getRecordingRoomPeople(session.connectionId);
|
||||
const compositionJob = shouldCompose
|
||||
? startRecordingCompositionJob({
|
||||
meetingId: session.connectionId,
|
||||
recordingId: session.id,
|
||||
layout: session.layout,
|
||||
format: session.format,
|
||||
host: roomPeople.host,
|
||||
participants: roomPeople.participants
|
||||
})
|
||||
: null;
|
||||
res.json({ success: true, session, agent, notified, compositionJob });
|
||||
});
|
||||
|
||||
app.get('/api/recording-compositions', (req: express.Request, res: express.Response) => {
|
||||
const meetingId = typeof req.query.meetingId === 'string'
|
||||
? sanitizePathSegment(req.query.meetingId, 'unknown')
|
||||
: undefined;
|
||||
const jobs = listRecordingCompositionJobs(meetingId);
|
||||
res.json({ success: true, jobs, totalCount: jobs.length });
|
||||
});
|
||||
|
||||
app.get('/api/recording-compositions/:compositionId', (req: express.Request, res: express.Response) => {
|
||||
const job = getRecordingCompositionJob(req.params.compositionId);
|
||||
if (!job) {
|
||||
res.status(404).json({ success: false, message: 'Recording composition job not found' });
|
||||
app.delete('/api/server-audio-recordings/:recordingId', async (req: express.Request, res: express.Response) => {
|
||||
const recordingId = sanitizePathSegment(req.params.recordingId, '');
|
||||
const cancelled = await serverAudioRecordings.cancel(recordingId);
|
||||
if (!cancelled) {
|
||||
res.status(404).json({ success: false, message: 'Server audio recording not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, job });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/recording-compositions', (req: express.Request, res: express.Response) => {
|
||||
const meetingId = sanitizeMetadataString(req.body.meetingId, 120);
|
||||
const recordingId = sanitizeMetadataString(req.body.recordingId, 120);
|
||||
if (!meetingId || !recordingId) {
|
||||
res.status(400).json({ success: false, message: 'meetingId and recordingId are required' });
|
||||
return;
|
||||
}
|
||||
app.post('/api/server-audio-recordings/:recordingId/stop', (req: express.Request, res: express.Response) => {
|
||||
const recordingId = sanitizePathSegment(req.params.recordingId, '');
|
||||
const stopPromise = serverAudioRecordings.stop(recordingId);
|
||||
stopPromise.catch(() => undefined);
|
||||
|
||||
const job = startRecordingCompositionJob({
|
||||
meetingId,
|
||||
recordingId,
|
||||
layout: req.body.layout,
|
||||
format: req.body.format,
|
||||
...getRecordingRoomPeople(meetingId)
|
||||
recordingUpload.single('video')(req, res, async (error: Error) => {
|
||||
const request = req as any;
|
||||
let stopped = null;
|
||||
|
||||
try {
|
||||
stopped = await stopPromise;
|
||||
|
||||
if (!stopped) {
|
||||
removeFileIfExists(request.file && request.file.path);
|
||||
res.status(404).json({ success: false, message: 'Server audio recording not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
log(LogLevel.warn, 'Server audio merge 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;
|
||||
}
|
||||
|
||||
if (!stopped.hasAudio) {
|
||||
removeFileIfExists(request.file && request.file.path);
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
res.status(400).json({ success: false, message: 'No server audio was captured' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.file) {
|
||||
res.json({
|
||||
success: true,
|
||||
recordingId: stopped.recordingId,
|
||||
meetingId: stopped.meetingId,
|
||||
audioOnly: true,
|
||||
audioTrackCount: stopped.audioTrackCount
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const videoExt = safeRecordingExtension(request.file);
|
||||
if (!videoExt) {
|
||||
removeFileIfExists(request.file.path);
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
res.status(400).json({ success: false, message: 'Unsupported recording file type' });
|
||||
return;
|
||||
}
|
||||
|
||||
const finalExt = getMergedRecordingExtension(videoExt);
|
||||
const meetingId = sanitizePathSegment(request.body.meetingId || stopped.meetingId, 'unknown');
|
||||
const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${finalExt}`);
|
||||
const userId = sanitizeMetadataString(request.body.userId, 120);
|
||||
const host = sanitizeRecordingPerson(request.body.host, 'host') || buildFallbackRecordingHost(userId);
|
||||
const participants = sanitizeRecordingParticipants(request.body.participants);
|
||||
const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${stopped.recordingId}${finalExt}`;
|
||||
const meetingDir = path.join(recordingRoot, meetingId);
|
||||
const finalPath = path.join(meetingDir, finalFilename);
|
||||
|
||||
if (!isPathInside(recordingRoot, finalPath)) {
|
||||
removeFileIfExists(request.file.path);
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
res.status(400).json({ success: false, message: 'Invalid recording path' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(meetingDir)) {
|
||||
fs.mkdirSync(meetingDir, { recursive: true });
|
||||
}
|
||||
|
||||
await mergeVideoWithServerAudio(request.file.path, stopped.audioPath, finalPath, finalExt);
|
||||
const stat = fs.statSync(finalPath);
|
||||
const metadata = {
|
||||
id: stopped.recordingId,
|
||||
meetingId,
|
||||
filename: finalFilename,
|
||||
originalFilename,
|
||||
mimetype: getRecordingMimeTypeFromExtension(finalExt),
|
||||
size: stat.size,
|
||||
userId,
|
||||
host,
|
||||
participants,
|
||||
serverAudio: {
|
||||
audioTrackCount: stopped.audioTrackCount,
|
||||
startedAt: new Date(stopped.createdAt).toISOString(),
|
||||
stoppedAt: new Date(stopped.stoppedAt).toISOString()
|
||||
},
|
||||
uploadedAt: new Date().toISOString()
|
||||
};
|
||||
fs.writeFileSync(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2));
|
||||
|
||||
removeFileIfExists(request.file.path);
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
recordingId: stopped.recordingId,
|
||||
meetingId,
|
||||
filename: finalFilename,
|
||||
originalFilename,
|
||||
size: stat.size,
|
||||
merged: true,
|
||||
url: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(finalFilename)}/download`
|
||||
});
|
||||
} catch (mergeError) {
|
||||
removeFileIfExists(request.file && request.file.path);
|
||||
if (stopped) {
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
}
|
||||
log(LogLevel.error, 'Failed to stop server audio recording:', mergeError);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: mergeError instanceof Error ? mergeError.message : 'Failed to stop server audio recording'
|
||||
});
|
||||
}
|
||||
});
|
||||
res.status(202).json({ success: true, job });
|
||||
});
|
||||
|
||||
app.get('/api/recordings', (_req: express.Request, res: express.Response) => {
|
||||
|
||||
12
src/types/werift-nonstandard.d.ts
vendored
Normal file
12
src/types/werift-nonstandard.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
declare module 'werift/nonstandard' {
|
||||
export class MediaRecorder {
|
||||
onError: {
|
||||
subscribe: (execute: (error: Error) => void) => { unSubscribe: () => void };
|
||||
};
|
||||
|
||||
constructor(props: any);
|
||||
addTrack(track: any): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import {
|
||||
incrementRecordingTrackPackets,
|
||||
registerRecordingPeerCandidate,
|
||||
registerRecordingPeerOffer,
|
||||
registerRecordingPeerTrack,
|
||||
resetRecordingAgents,
|
||||
startRecordingAgent,
|
||||
stopRecordingAgent
|
||||
} from '../src/recording/agent';
|
||||
import { RecordingSession } from '../src/recording/session-manager';
|
||||
|
||||
const session: RecordingSession = {
|
||||
id: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
status: 'recording',
|
||||
layout: 'grid',
|
||||
format: 'webm',
|
||||
createdAt: '2026-06-01T00:00:00.000Z',
|
||||
startedAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T00:00:00.000Z'
|
||||
};
|
||||
|
||||
describe('recording agent', () => {
|
||||
beforeEach(() => {
|
||||
resetRecordingAgents();
|
||||
});
|
||||
|
||||
test('starts an awaiting media adapter agent', () => {
|
||||
const agent = startRecordingAgent(session);
|
||||
|
||||
expect(agent).toEqual(expect.objectContaining({
|
||||
id: 'recorder_recording-1',
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
status: 'awaiting-media-adapter',
|
||||
mediaMode: 'webrtc-sendonly'
|
||||
}));
|
||||
});
|
||||
|
||||
test('stores peer offers for an active agent', () => {
|
||||
startRecordingAgent(session);
|
||||
const offer = registerRecordingPeerOffer({
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
participantId: 'participant-1',
|
||||
sdp: 'test-sdp'
|
||||
});
|
||||
|
||||
expect(offer).toEqual(expect.objectContaining({
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
participantId: 'participant-1',
|
||||
sdp: 'test-sdp'
|
||||
}));
|
||||
});
|
||||
|
||||
test('stores peer candidates for an active agent', () => {
|
||||
const agent = startRecordingAgent(session);
|
||||
const candidate = registerRecordingPeerCandidate({
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
participantId: 'participant-1',
|
||||
candidate: 'candidate:1',
|
||||
sdpMid: '0',
|
||||
sdpMLineIndex: 0
|
||||
});
|
||||
|
||||
expect(candidate).toEqual(expect.objectContaining({
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
participantId: 'participant-1',
|
||||
candidate: 'candidate:1'
|
||||
}));
|
||||
expect(agent.peerCandidates.get('participant-1')).toEqual([candidate]);
|
||||
});
|
||||
|
||||
test('tracks received media and packet counts', () => {
|
||||
const agent = startRecordingAgent(session);
|
||||
const track = registerRecordingPeerTrack({
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
participantId: 'participant-1',
|
||||
kind: 'video',
|
||||
trackId: 'track-1'
|
||||
});
|
||||
|
||||
incrementRecordingTrackPackets({
|
||||
recordingId: 'recording-1',
|
||||
participantId: 'participant-1',
|
||||
trackId: 'track-1'
|
||||
});
|
||||
|
||||
expect(agent.status).toBe('receiving-media');
|
||||
expect(track).toEqual(expect.objectContaining({
|
||||
recordingId: 'recording-1',
|
||||
participantId: 'participant-1',
|
||||
kind: 'video',
|
||||
trackId: 'track-1',
|
||||
rtpPackets: 1
|
||||
}));
|
||||
});
|
||||
|
||||
test('rejects offers when the agent is stopped', () => {
|
||||
startRecordingAgent(session);
|
||||
stopRecordingAgent('recording-1');
|
||||
|
||||
expect(registerRecordingPeerOffer({
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
participantId: 'participant-1',
|
||||
sdp: 'test-sdp'
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
import { buildFfmpegCompositionArgs } from '../src/recording/composer';
|
||||
import { ServerTrackRecordingFile } from '../src/recording/storage';
|
||||
|
||||
function file(
|
||||
filename: string,
|
||||
trackKind: string,
|
||||
participantId: string,
|
||||
role = 'participant',
|
||||
recordingStartedAt = '2026-06-01T00:00:00.000Z',
|
||||
recordingEndedAt = '2026-06-01T00:00:10.000Z'
|
||||
): ServerTrackRecordingFile {
|
||||
return {
|
||||
meetingId: 'room-1',
|
||||
directory: 'recordings/room-1',
|
||||
filename,
|
||||
filePath: `recordings/room-1/${filename}`,
|
||||
metadataPath: `recordings/room-1/${filename}.json`,
|
||||
recordingId: 'recording-1',
|
||||
participantId,
|
||||
trackId: `${participantId}-${trackKind}`,
|
||||
trackKind,
|
||||
uploadedAt: recordingStartedAt,
|
||||
recordingStartedAt,
|
||||
recordingEndedAt,
|
||||
metadata: { role, recordingStartedAt, recordingEndedAt, updatedAt: recordingEndedAt }
|
||||
};
|
||||
}
|
||||
|
||||
describe('recording composer', () => {
|
||||
test('builds ffmpeg args for host-led video layout and mixed audio', () => {
|
||||
const args = buildFfmpegCompositionArgs({
|
||||
videoInputs: [
|
||||
file('p1-video.webm', 'video', 'p1', 'host'),
|
||||
file('p2-video.webm', 'video', 'p2')
|
||||
],
|
||||
audioInputs: [
|
||||
file('p1-audio.webm', 'audio', 'p1'),
|
||||
file('p2-audio.webm', 'audio', 'p2')
|
||||
],
|
||||
outputPath: 'recordings/room-1/output.webm',
|
||||
format: 'webm'
|
||||
});
|
||||
|
||||
expect(args).toContain('-filter_complex');
|
||||
expect(args.join(' ')).toContain('xstack=inputs=2');
|
||||
expect(args.join(' ')).toContain('scale=2560:1080');
|
||||
expect(args.join(' ')).toContain('scale=2560:360');
|
||||
expect(args.join(' ')).toContain('layout=0_0|0_1080');
|
||||
expect(args.join(' ')).toContain('fps=60');
|
||||
expect(args.join(' ')).toContain('amix=inputs=2');
|
||||
expect(args).toContain('libvpx-vp9');
|
||||
expect(args).toContain('libopus');
|
||||
expect(args).toContain('16000k');
|
||||
expect(args).not.toContain('-shortest');
|
||||
expect(args[args.length - 1]).toBe('recordings/room-1/output.webm');
|
||||
});
|
||||
|
||||
test('places host in the first row even when host input is not first', () => {
|
||||
const args = buildFfmpegCompositionArgs({
|
||||
videoInputs: [
|
||||
file('p1-video.webm', 'video', 'p1'),
|
||||
file('host-video.webm', 'video', 'host', 'host'),
|
||||
file('p2-video.webm', 'video', 'p2')
|
||||
],
|
||||
audioInputs: [],
|
||||
outputPath: 'recordings/room-1/output.webm',
|
||||
format: 'webm'
|
||||
});
|
||||
|
||||
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||
expect(args.slice(0, 7)).toEqual([
|
||||
'-y',
|
||||
'-i',
|
||||
'recordings/room-1/host-video.webm',
|
||||
'-i',
|
||||
'recordings/room-1/p1-video.webm',
|
||||
'-i',
|
||||
'recordings/room-1/p2-video.webm'
|
||||
]);
|
||||
expect(filter).toContain('scale=2560:1080');
|
||||
expect(filter).toContain('scale=1280:360');
|
||||
expect(filter).toContain('layout=0_0|0_1080|1280_1080');
|
||||
});
|
||||
|
||||
test('builds mp4 encoder args', () => {
|
||||
const args = buildFfmpegCompositionArgs({
|
||||
videoInputs: [file('p1-video.webm', 'video', 'p1')],
|
||||
audioInputs: [],
|
||||
outputPath: 'recordings/room-1/output.mp4',
|
||||
format: 'mp4'
|
||||
});
|
||||
|
||||
expect(args).toContain('libx264');
|
||||
expect(args).toContain('-pix_fmt');
|
||||
expect(args).toContain('16000k');
|
||||
expect(args).toContain('60');
|
||||
expect(args).not.toContain('libopus');
|
||||
});
|
||||
|
||||
test('falls back to one video segment when input end timestamps are missing', () => {
|
||||
const args = buildFfmpegCompositionArgs({
|
||||
videoInputs: [
|
||||
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', ''),
|
||||
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:02.500Z', '')
|
||||
],
|
||||
audioInputs: [],
|
||||
outputPath: 'recordings/room-1/output.webm',
|
||||
format: 'webm'
|
||||
});
|
||||
|
||||
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||
expect(filter).toContain('xstack=inputs=2');
|
||||
expect(filter).toContain('trim=start=0,setpts=PTS-STARTPTS');
|
||||
expect(filter).not.toContain('concat=n=0');
|
||||
expect(args).not.toContain('-t');
|
||||
});
|
||||
|
||||
test('pads late participant tracks to keep the room timeline aligned', () => {
|
||||
const args = buildFfmpegCompositionArgs({
|
||||
videoInputs: [
|
||||
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:10.000Z'),
|
||||
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:02.500Z', '2026-06-01T00:00:10.000Z')
|
||||
],
|
||||
audioInputs: [
|
||||
file('host-audio.webm', 'audio', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:10.000Z'),
|
||||
file('p1-audio.webm', 'audio', 'p1', 'participant', '2026-06-01T00:00:02.500Z', '2026-06-01T00:00:10.000Z')
|
||||
],
|
||||
outputPath: 'recordings/room-1/output.webm',
|
||||
format: 'webm'
|
||||
});
|
||||
|
||||
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||
expect(filter).toContain('[0:v]split=2[vin0_0][vin0_1]');
|
||||
expect(filter).toContain('[vin0_0]trim=start=0:duration=2.5');
|
||||
expect(filter).toContain('tpad=stop_mode=clone:stop_duration=2.5,trim=duration=2.5');
|
||||
expect(filter).toContain('[vin0_1]trim=start=2.5:duration=7.5');
|
||||
expect(filter).toContain('tpad=stop_mode=clone:stop_duration=7.5,trim=duration=7.5');
|
||||
expect(filter).toContain('[1:v]trim=start=0:duration=7.5');
|
||||
expect(filter).toContain('concat=n=2:v=1:a=0[vout]');
|
||||
expect(filter).toContain('[2:a]aresample=async=1:first_pts=0[a0]');
|
||||
expect(filter).toContain('[3:a]aresample=async=1:first_pts=0,adelay=2500:all=1[a1]');
|
||||
expect(filter).toContain('[a0][a1]amix=inputs=2:duration=longest:dropout_transition=2,asetpts=N/SR/TB[aout]');
|
||||
});
|
||||
|
||||
test('bounds each video segment to its timeline duration before composition', () => {
|
||||
const args = buildFfmpegCompositionArgs({
|
||||
videoInputs: [
|
||||
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:24.000Z')
|
||||
],
|
||||
audioInputs: [
|
||||
file('host-audio.webm', 'audio', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:24.000Z')
|
||||
],
|
||||
outputPath: 'recordings/room-1/output.webm',
|
||||
format: 'webm'
|
||||
});
|
||||
|
||||
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||
expect(filter).toContain('trim=start=0:duration=24,setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=24,trim=duration=24,setpts=PTS-STARTPTS');
|
||||
expect(filter).toContain('[1:a]aresample=async=1:first_pts=0,asetpts=N/SR/TB[aout]');
|
||||
});
|
||||
|
||||
test('changes the layout when participants join and leave without overlapping', () => {
|
||||
const args = buildFfmpegCompositionArgs({
|
||||
videoInputs: [
|
||||
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:12.000Z'),
|
||||
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:05.000Z'),
|
||||
file('p2-video.webm', 'video', 'p2', 'participant', '2026-06-01T00:00:05.000Z', '2026-06-01T00:00:12.000Z')
|
||||
],
|
||||
audioInputs: [],
|
||||
outputPath: 'recordings/room-1/output.webm',
|
||||
format: 'webm'
|
||||
});
|
||||
|
||||
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||
expect(filter).toContain('xstack=inputs=2');
|
||||
expect(filter).toContain('layout=0_0|0_1080');
|
||||
expect(filter).toContain('[0:v]split=2[vin0_0][vin0_1]');
|
||||
expect(filter).toContain('[vin0_0]trim=start=0:duration=5');
|
||||
expect(filter).toContain('[1:v]trim=start=0:duration=5');
|
||||
expect(filter).toContain('[vin0_1]trim=start=5:duration=7');
|
||||
expect(filter).toContain('[2:v]trim=start=0:duration=7');
|
||||
expect(filter).toContain('concat=n=2:v=1:a=0[vout]');
|
||||
expect(filter).not.toContain('xstack=inputs=3');
|
||||
});
|
||||
|
||||
test('keeps separate viewports for participants whose video intervals overlap', () => {
|
||||
const args = buildFfmpegCompositionArgs({
|
||||
videoInputs: [
|
||||
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:12.000Z'),
|
||||
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:08.000Z'),
|
||||
file('p2-video.webm', 'video', 'p2', 'participant', '2026-06-01T00:00:05.000Z', '2026-06-01T00:00:12.000Z')
|
||||
],
|
||||
audioInputs: [],
|
||||
outputPath: 'recordings/room-1/output.webm',
|
||||
format: 'webm'
|
||||
});
|
||||
|
||||
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||
expect(filter).toContain('xstack=inputs=3');
|
||||
expect(filter).toContain('layout=0_0|0_1080|1280_1080');
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import {
|
||||
getRecordingSession,
|
||||
listRecordingSessions,
|
||||
resetRecordingSessions,
|
||||
startRecordingSession,
|
||||
stopRecordingSession
|
||||
} from '../src/recording/session-manager';
|
||||
|
||||
describe('recording session manager', () => {
|
||||
beforeEach(() => {
|
||||
resetRecordingSessions();
|
||||
});
|
||||
|
||||
test('starts and lists a recording session', () => {
|
||||
const session = startRecordingSession({
|
||||
connectionId: 'room-1',
|
||||
layout: 'speaker',
|
||||
format: 'mp4'
|
||||
});
|
||||
|
||||
expect(session).toEqual(expect.objectContaining({
|
||||
connectionId: 'room-1',
|
||||
status: 'recording',
|
||||
layout: 'speaker',
|
||||
format: 'mp4'
|
||||
}));
|
||||
expect(getRecordingSession(session.id)).toEqual(session);
|
||||
expect(listRecordingSessions('room-1')).toEqual([session]);
|
||||
});
|
||||
|
||||
test('stops an existing recording session', () => {
|
||||
const session = startRecordingSession({ connectionId: 'room-1' });
|
||||
const stopped = stopRecordingSession(session.id);
|
||||
|
||||
expect(stopped).toEqual(expect.objectContaining({
|
||||
id: session.id,
|
||||
connectionId: 'room-1',
|
||||
status: 'stopped'
|
||||
}));
|
||||
expect(stopped?.stoppedAt).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test('rejects missing connection id', () => {
|
||||
expect(() => startRecordingSession({ connectionId: '' })).toThrow('connectionId is required');
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
createComposedRecordingTarget,
|
||||
createServerTrackRecordingTarget,
|
||||
deleteServerTrackRecordingFiles,
|
||||
listServerTrackRecordingFiles,
|
||||
sanitizeRecordingPathSegment,
|
||||
updateServerTrackRecordingMetadataSize,
|
||||
writeComposedRecordingMetadata,
|
||||
writeServerTrackRecordingMetadata
|
||||
} from '../src/recording/storage';
|
||||
|
||||
describe('recording storage', () => {
|
||||
const originalRecordingDir = process.env.RECORDING_DIR;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'recording-storage-'));
|
||||
process.env.RECORDING_DIR = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.RECORDING_DIR = originalRecordingDir;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('sanitizes path segments', () => {
|
||||
expect(sanitizeRecordingPathSegment('../room:name', 'fallback')).toBe('__room_name');
|
||||
expect(sanitizeRecordingPathSegment('', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
test('creates server track target and updates metadata size', () => {
|
||||
const target = createServerTrackRecordingTarget({
|
||||
recordingId: 'recording/1',
|
||||
connectionId: 'room:1',
|
||||
participantId: 'participant-1',
|
||||
kind: 'video',
|
||||
trackId: 'track-1'
|
||||
});
|
||||
|
||||
expect(target.meetingId).toBe('room_1');
|
||||
expect(target.filePath.startsWith(path.join(tempDir, 'room_1'))).toBe(true);
|
||||
expect(target.filename).toContain('recording_1-participant-1-video-track-1.webm');
|
||||
|
||||
writeServerTrackRecordingMetadata({
|
||||
recordingId: 'recording-1',
|
||||
connectionId: 'room-1',
|
||||
participantId: 'participant-1',
|
||||
kind: 'video',
|
||||
trackId: 'track-1',
|
||||
target
|
||||
});
|
||||
fs.writeFileSync(target.filePath, Buffer.from('webm'));
|
||||
updateServerTrackRecordingMetadataSize(target);
|
||||
|
||||
const metadata = JSON.parse(fs.readFileSync(target.metadataPath, 'utf8'));
|
||||
expect(metadata).toEqual(expect.objectContaining({
|
||||
meetingId: 'room_1',
|
||||
filename: target.filename,
|
||||
mimetype: 'video/webm',
|
||||
size: 4,
|
||||
userId: 'server-recorder',
|
||||
recordingSource: 'server',
|
||||
participantId: 'participant-1',
|
||||
trackKind: 'video',
|
||||
recordingStartedAt: expect.any(String),
|
||||
recordingEndedAt: expect.any(String)
|
||||
}));
|
||||
const files = listServerTrackRecordingFiles({
|
||||
meetingId: 'room_1',
|
||||
recordingId: 'recording-1',
|
||||
trackKind: 'video'
|
||||
});
|
||||
expect(files).toEqual([
|
||||
expect.objectContaining({
|
||||
filename: target.filename,
|
||||
participantId: 'participant-1',
|
||||
trackKind: 'video'
|
||||
})
|
||||
]);
|
||||
|
||||
expect(deleteServerTrackRecordingFiles(files)).toEqual([
|
||||
target.filename,
|
||||
`${target.filename}.json`
|
||||
]);
|
||||
expect(fs.existsSync(target.filePath)).toBe(false);
|
||||
expect(fs.existsSync(target.metadataPath)).toBe(false);
|
||||
expect(listServerTrackRecordingFiles({
|
||||
meetingId: 'room_1',
|
||||
recordingId: 'recording-1',
|
||||
trackKind: 'video'
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
test('writes composed recording metadata', () => {
|
||||
const target = createComposedRecordingTarget({
|
||||
meetingId: 'room-1',
|
||||
recordingId: 'recording-1',
|
||||
format: 'webm'
|
||||
});
|
||||
fs.writeFileSync(target.filePath, Buffer.from('composed'));
|
||||
writeComposedRecordingMetadata({
|
||||
target,
|
||||
recordingId: 'recording-1',
|
||||
layout: 'grid',
|
||||
format: 'webm',
|
||||
host: {
|
||||
participantId: 'host-p',
|
||||
userId: 'host-user',
|
||||
id: 'host-user',
|
||||
name: 'Host User',
|
||||
avatar: '/uploads/host.png',
|
||||
role: 'host'
|
||||
},
|
||||
participants: [
|
||||
{
|
||||
participantId: 'p1',
|
||||
userId: 'participant-user',
|
||||
id: 'participant-user',
|
||||
name: 'Participant User',
|
||||
avatar: '/uploads/p1.png',
|
||||
role: 'participant'
|
||||
}
|
||||
],
|
||||
inputs: [
|
||||
{
|
||||
...target,
|
||||
filename: 'p1-video.webm',
|
||||
recordingId: 'recording-1',
|
||||
participantId: 'p1',
|
||||
trackId: 'track-1',
|
||||
trackKind: 'video',
|
||||
uploadedAt: '2026-06-01T00:00:00.000Z',
|
||||
metadata: {}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const metadata = JSON.parse(fs.readFileSync(target.metadataPath, 'utf8'));
|
||||
expect(metadata).toEqual(expect.objectContaining({
|
||||
meetingId: 'room-1',
|
||||
filename: target.filename,
|
||||
recordingSource: 'server-composed',
|
||||
size: 8,
|
||||
layout: 'grid',
|
||||
inputFiles: ['p1-video.webm']
|
||||
}));
|
||||
expect(metadata.host).toEqual(expect.objectContaining({
|
||||
participantId: 'host-p',
|
||||
userId: 'host-user',
|
||||
name: 'Host User',
|
||||
role: 'host'
|
||||
}));
|
||||
expect(metadata.participants).toEqual([
|
||||
expect.objectContaining({
|
||||
participantId: 'p1',
|
||||
userId: 'participant-user',
|
||||
name: 'Participant User',
|
||||
role: 'participant'
|
||||
})
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -8,20 +8,6 @@ Date.now = jest.fn(() => 1482363367071);
|
||||
|
||||
const anyParticipantId = expect.any(String);
|
||||
|
||||
function recordingEnvelope(connectionId: string, data: any): any {
|
||||
const innerData = { ...data };
|
||||
delete innerData.type;
|
||||
return {
|
||||
from: connectionId,
|
||||
to: "",
|
||||
type: "on-message",
|
||||
data: JSON.stringify({
|
||||
type: data.type,
|
||||
data: innerData
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
describe('websocket signaling test in public mode', () => {
|
||||
let server: WS;
|
||||
let client: WebSocket;
|
||||
@@ -204,41 +190,6 @@ describe('websocket signaling test in private mode', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('broadcast recording status to room members', async () => {
|
||||
const session = {
|
||||
id: 'recording-1',
|
||||
connectionId: connectionId,
|
||||
status: 'recording',
|
||||
layout: 'grid',
|
||||
format: 'webm',
|
||||
createdAt: '2026-06-01T00:00:00.000Z',
|
||||
startedAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T00:00:00.000Z'
|
||||
} as any;
|
||||
const expected = {
|
||||
type: 'recording-started',
|
||||
connectionId: connectionId,
|
||||
recordingId: 'recording-1',
|
||||
status: 'recording',
|
||||
layout: 'grid',
|
||||
format: 'webm',
|
||||
startedAt: '2026-06-01T00:00:00.000Z'
|
||||
};
|
||||
|
||||
expect(wsHandler.broadcastRecordingStarted(session)).toBe(true);
|
||||
await expect(server).toReceiveMessage(recordingEnvelope(connectionId, expected));
|
||||
await expect(server).toReceiveMessage(recordingEnvelope(connectionId, expected));
|
||||
|
||||
expect(wsHandler.broadcastRecordingPeerRequest(session)).toBe(true);
|
||||
const peerRequest = {
|
||||
...expected,
|
||||
type: 'recording-peer-request',
|
||||
mediaMode: 'webrtc-sendonly'
|
||||
};
|
||||
await expect(server).toReceiveMessage(recordingEnvelope(connectionId, peerRequest));
|
||||
await expect(server).toReceiveMessage(recordingEnvelope(connectionId, peerRequest));
|
||||
});
|
||||
|
||||
test('send offer from session1', async () => {
|
||||
await wsHandler.onOffer(client, { connectionId: connectionId, sdp: testsdp });
|
||||
const receiveOffer = new Offer(testsdp, Date.now(), true);
|
||||
@@ -292,53 +243,3 @@ describe('websocket signaling test in private mode', () => {
|
||||
await wsHandler.remove(client);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recording offer validation', () => {
|
||||
let server: WS;
|
||||
let client: WebSocket;
|
||||
const connectionId = "recording-room";
|
||||
|
||||
beforeAll(async () => {
|
||||
wsHandler.reset("private");
|
||||
server = new WS("ws://localhost:1234", { jsonProtocol: true });
|
||||
client = new WebSocket("ws://localhost:1234");
|
||||
await server.connected;
|
||||
await wsHandler.add(client);
|
||||
await wsHandler.onConnect(client, connectionId);
|
||||
await expect(server).toReceiveMessage({
|
||||
type: "connect",
|
||||
connectionId: connectionId,
|
||||
polite: false,
|
||||
role: "host",
|
||||
participantId: anyParticipantId
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
WS.clean();
|
||||
});
|
||||
|
||||
test('rejects recording offers without audio or video media sections', async () => {
|
||||
await wsHandler.onRecordingOffer(client, {
|
||||
recordingId: 'recording-empty-offer',
|
||||
connectionId,
|
||||
sdp: [
|
||||
'v=0',
|
||||
'o=- 25268170 0 IN IP4 0.0.0.0',
|
||||
's=-',
|
||||
't=0 0',
|
||||
'a=group:BUNDLE ',
|
||||
'a=extmap-allow-mixed',
|
||||
'a=msid-semantic:WMS *',
|
||||
''
|
||||
].join('\r\n')
|
||||
});
|
||||
|
||||
await expect(server).toReceiveMessage(expect.objectContaining({
|
||||
from: connectionId,
|
||||
to: "",
|
||||
type: "on-message",
|
||||
data: expect.stringContaining('"status":"no-media-offer"')
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
"exclude": ["node_modules", "**/*.spec.ts"],
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "es5",
|
||||
"lib": ["dom","es5"],
|
||||
"sourceMap": true,
|
||||
"outDir":"build",
|
||||
"rootDir":"src"
|
||||
"rootDir":"src",
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user