Compare commits

2 Commits

Author SHA1 Message Date
59fc4be5cc 服务器录屏1 2026-06-02 02:49:47 +08:00
66d6f92d1e 【m】修改为服务器录屏 2026-06-02 02:34:40 +08:00
25 changed files with 2924 additions and 543 deletions

View File

@@ -124,6 +124,11 @@ export class MeetingRecorder {
this.audioSources = [];
this.recordingStream = null;
this.connectionId = '';
this.layout = 'grid';
this.onChunk = null;
this.storeChunks = true;
this.mixedAudioDestination = null;
this.mixedAudioTrackIds = new Set();
}
isSupported() {
@@ -137,7 +142,7 @@ export class MeetingRecorder {
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
}
async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) {
async start({ localStream, remoteStream, remoteStreams, connectionId, layout, onChunk, storeChunks } = {}) {
if (this.isRecording()) {
throw new Error('会议正在录制中');
}
@@ -156,7 +161,11 @@ export class MeetingRecorder {
}
this.connectionId = connectionId || '';
this.layout = layout || 'grid';
this.onChunk = typeof onChunk === 'function' ? onChunk : null;
this.storeChunks = storeChunks !== false;
this.chunks = [];
this.mixedAudioTrackIds = new Set();
this.canvas = canvas;
this.context = context;
@@ -179,6 +188,16 @@ export class MeetingRecorder {
}
}
syncAudio({ localStream, remoteStream, remoteStreams } = {}) {
if (!this.isRecording() || !this.audioContext || !this.mixedAudioDestination) {
return;
}
const streams = collectStreams({ localStream, remoteStream, remoteStreams });
const audioTracks = collectLiveAudioTracks(streams);
audioTracks.forEach(track => this._connectAudioTrack(track));
}
stop() {
if (!this.isRecording()) {
return Promise.resolve(null);
@@ -203,16 +222,26 @@ export class MeetingRecorder {
}
this.audioContext = new AudioContextCtor();
const destination = this.audioContext.createMediaStreamDestination();
this.mixedAudioDestination = this.audioContext.createMediaStreamDestination();
audioTracks.forEach(track => this._connectAudioTrack(track));
audioTracks.forEach(track => {
const sourceStream = new this.window.MediaStream([track]);
const source = this.audioContext.createMediaStreamSource(sourceStream);
source.connect(destination);
this.audioSources.push(source);
});
return this.mixedAudioDestination.stream.getAudioTracks()[0] || null;
}
return destination.stream.getAudioTracks()[0] || null;
_connectAudioTrack(track) {
if (!track || track.readyState === 'ended') {
return;
}
const trackId = track.id || `${track.kind}-${Date.now()}`;
if (this.mixedAudioTrackIds.has(trackId)) {
return;
}
this.mixedAudioTrackIds.add(trackId);
const sourceStream = new this.window.MediaStream([track]);
const source = this.audioContext.createMediaStreamSource(sourceStream);
source.connect(this.mixedAudioDestination);
this.audioSources.push(source);
}
startMediaRecorder(stream) {
@@ -224,7 +253,17 @@ export class MeetingRecorder {
this.mediaRecorder = new MediaRecorderCtor(stream, options);
this.mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.chunks.push(event.data);
if (this.storeChunks) {
this.chunks.push(event.data);
}
if (this.onChunk) {
try {
this.onChunk(event.data);
}
catch (_error) {
// Ignore chunk callback failures so recording can continue.
}
}
}
};
this.mediaRecorder.onerror = (event) => {
@@ -235,9 +274,9 @@ export class MeetingRecorder {
this.cleanup();
};
this.mediaRecorder.onstop = () => {
const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType || 'video/webm' });
const filename = this.buildFilename();
const mimeType = blob.type || this.mediaRecorder.mimeType || 'video/webm';
const mimeType = this.mediaRecorder.mimeType || 'video/webm';
const blob = this.storeChunks ? new Blob(this.chunks, { type: mimeType }) : null;
this.cleanup();
if (this.pendingStop) {
this.pendingStop.resolve({ blob, filename, mimeType });
@@ -267,6 +306,15 @@ export class MeetingRecorder {
context.fillStyle = '#020617';
context.fillRect(0, 0, canvas.width, canvas.height);
if (this.layout === 'host-only') {
if (localVideo) {
drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height);
return;
}
drawEmptyFrame(context, canvas);
return;
}
if (remoteVideos.length > 0) {
drawGrid(context, remoteVideos, canvas);
if (localVideo) {
@@ -328,9 +376,13 @@ export class MeetingRecorder {
}
this.audioSources = [];
this.mixedAudioDestination = null;
this.mixedAudioTrackIds = new Set();
this.mediaRecorder = null;
this.canvas = null;
this.context = null;
this.chunks = [];
this.onChunk = null;
this.storeChunks = true;
}
}

View File

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

View File

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

View File

@@ -100,11 +100,11 @@ function formatDate(value) {
}
function getPersonId(person) {
return person?.userId || person?.id || '';
return person?.userId || person?.id || person?.participantId || '';
}
function getPersonName(person) {
return person?.name || getPersonId(person) || '-';
return person?.name || person?.displayName || getPersonId(person) || '-';
}
function getRecordingHost(recording) {

View File

@@ -448,6 +448,7 @@ body {
display: grid;
grid-template-columns: 280px minmax(420px, 1fr) 360px;
gap: 16px;
overflow: hidden;
}
.recordings-upload,
@@ -612,7 +613,33 @@ body {
.recordings-table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
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);
}
.recordings-table {
@@ -900,6 +927,7 @@ body {
.recordings-preview {
display: flex;
flex-direction: column;
overflow: visible;
}
.recordings-list,

View File

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

View File

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

View File

@@ -1,197 +0,0 @@
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;
}
}

View File

@@ -6,6 +6,11 @@ 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';
/**
* 是否为私有模式
@@ -68,6 +73,18 @@ 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;
};
interface StoredRoom {
roomId: string;
connectionId: string;
@@ -317,6 +334,95 @@ 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 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连接实例
@@ -329,12 +435,14 @@ 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离开
@@ -379,6 +487,7 @@ 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);
}
/**
@@ -399,6 +508,7 @@ 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" });
});
@@ -407,6 +517,7 @@ 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) });
@@ -677,6 +788,164 @@ 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 sendToEntireGroup(
session.connectionId,
toRecordingBroadcastPayload('recording-started', session)
);
}
function broadcastRecordingPeerRequest(session: RecordingSession): boolean {
const payload = toRecordingBroadcastPayload('recording-peer-request', session);
payload.mediaMode = 'webrtc-sendonly';
return sendToEntireGroup(session.connectionId, payload);
}
function broadcastRecordingStopped(session: RecordingSession): boolean {
return sendToEntireGroup(
session.connectionId,
toRecordingBroadcastPayload('recording-stopped', session)
);
}
function sendActiveRecordingRequests(ws: WebSocket, connectionId: string): void {
const activeSessions = getActiveRecordingSessions(connectionId);
activeSessions.forEach((session) => {
safeSend(ws, toRecordingBroadcastPayload('recording-started', session));
safeSend(ws, {
...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) {
safeSend(ws, { type: 'recording-status', recordingId, connectionId, status: 'invalid-offer' });
return;
}
const offer = registerRecordingPeerOffer({
recordingId,
connectionId,
sdp,
participantId: getParticipantId(ws) || 'unknown'
});
if (!offer) {
safeSend(ws, {
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;
safeSend(ws, {
type: 'recording-candidate',
recordingId,
connectionId,
participantId,
candidate: json.candidate,
sdpMid: json.sdpMid,
sdpMLineIndex: json.sdpMLineIndex
});
}
});
safeSend(ws, {
type: 'recording-answer',
recordingId,
connectionId,
participantId,
sdp: answerSdp
});
} catch (error) {
log(LogLevel.error, 'Failed to accept recording offer:', error);
safeSend(ws, {
type: 'recording-status',
recordingId,
connectionId,
status: 'offer-failed',
participantId: getParticipantId(ws)
});
return;
}
safeSend(ws, {
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) {
safeSend(ws, { 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);
safeSend(ws, { type: 'recording-status', recordingId, connectionId, status: 'candidate-rejected' });
}
}
function AddHeartbeat(ws: WebSocket, connectionId: string) {
// 初始化心跳检测
asAppWebSocket(ws).lastActivity = Date.now();
@@ -833,4 +1102,5 @@ 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, connectionGroup, onHostUserInfo, onInviteCall };
broadcastToGroup, broadcastRecordingStarted, broadcastRecordingPeerRequest, broadcastRecordingStopped, connectionGroup,
onHostUserInfo, onInviteCall, onRecordingCandidate, onRecordingOffer };

226
src/recording/agent.ts Normal file
View File

@@ -0,0 +1,226 @@
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();
}

333
src/recording/composer.ts Normal file
View File

@@ -0,0 +1,333 @@
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[];
};
const jobs: Map<string, RecordingCompositionJob> = new Map<string, RecordingCompositionJob>();
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 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('|');
}
export function buildFfmpegCompositionArgs(input: {
videoInputs: ServerTrackRecordingFile[];
audioInputs: ServerTrackRecordingFile[];
outputPath: string;
format: string;
}): string[] {
const outputWidth = 1280;
const outputHeight = 720;
const hostHeight = 540;
const bottomHeight = outputHeight - hostHeight;
const videoInputs = orderVideoInputsForComposition(input.videoInputs);
const args = ['-y'];
const orderedInputs = videoInputs.concat(input.audioInputs);
orderedInputs.forEach((file) => {
args.push('-i', file.filePath);
});
const filters: string[] = [];
videoInputs.forEach((_file, index) => {
const width = videoInputs.length === 1
? outputWidth
: index === 0 ? outputWidth : getBottomTileWidth(index - 1, videoInputs.length, outputWidth);
const height = videoInputs.length === 1
? outputHeight
: index === 0 ? hostHeight : bottomHeight;
filters.push(`[${index}:v]scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v${index}]`);
});
if (videoInputs.length === 1) {
filters.push('[v0]fps=30,format=yuv420p[vout]');
} else {
const videoLabels = videoInputs.map((_file, index) => `[v${index}]`).join('');
filters.push(`${videoLabels}xstack=inputs=${videoInputs.length}:layout=${createHostBottomLayout(videoInputs.length, outputWidth, hostHeight)}:fill=black,fps=30,format=yuv420p[vout]`);
}
if (input.audioInputs.length === 1) {
const audioInputIndex = videoInputs.length;
filters.push(`[${audioInputIndex}:a]aresample=async=1:first_pts=0[aout]`);
} else if (input.audioInputs.length > 1) {
const audioLabels = input.audioInputs
.map((_file, index) => `[${videoInputs.length + index}:a]`)
.join('');
filters.push(`${audioLabels}amix=inputs=${input.audioInputs.length}:duration=longest:dropout_transition=2[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');
if (input.audioInputs.length > 0) {
args.push('-c:a', 'aac');
}
} else {
args.push('-c:v', 'libvpx-vp9', '-deadline', 'realtime', '-cpu-used', '4');
if (input.audioInputs.length > 0) {
args.push('-c:a', 'libopus');
}
}
args.push('-shortest', 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`
};
}
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 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();
}

View File

@@ -0,0 +1,92 @@
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();
}

336
src/recording/storage.ts Normal file
View File

@@ -0,0 +1,336 @@
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;
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,
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();
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()
};
})
.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));
}

View File

@@ -0,0 +1,284 @@
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;
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: 1280,
height: 720,
disableLipSync: true,
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);
}
}

View File

@@ -1,82 +0,0 @@
# 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.

View File

@@ -0,0 +1,131 @@
# 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
```

View File

@@ -2,14 +2,35 @@ 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');
@@ -143,9 +164,9 @@ function sanitizeMetadataString(value: any, maxLength = 200): string {
return String(value)
.split('')
.filter((char) => {
const code = char.charCodeAt(0);
return code > 31 && code !== 127;
.filter((character) => {
const code = character.charCodeAt(0);
return code >= 32 && code !== 127;
})
.join('')
.trim()
@@ -295,73 +316,45 @@ function removeEmptyDirectory(directory: string): void {
}
}
function removeFileIfExists(filePath: string | undefined): void {
if (!filePath) {
return;
}
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
function getActiveRecordingSession(connectionId: string) {
const sessions = listRecordingSessions(connectionId);
for (const session of sessions) {
if (session.status === 'recording') {
return session;
}
} catch (error) {
log(LogLevel.warn, 'Failed to remove temporary file:', error);
}
return null;
}
function getMergedRecordingExtension(videoExt: string): string {
return videoExt.toLowerCase() === '.webm' ? '.webm' : '.mp4';
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 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 = '';
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[];
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);
return { host, participants };
}
export const createServer = (config: Options): express.Express => {
@@ -498,7 +491,6 @@ 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)) {
@@ -532,192 +524,122 @@ export const createServer = (config: Options): express.Express => {
}
});
app.post('/api/server-audio-recordings/start', async (req: express.Request, res: express.Response) => {
try {
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
});
} catch (error) {
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.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.post('/api/server-audio-recordings/:recordingId/candidate', async (req: express.Request, res: express.Response) => {
try {
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.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'
});
}
});
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' });
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 });
res.json({ success: true, session, agent: getRecordingAgent(session.id) });
});
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);
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;
}
recordingUpload.single('video')(req, res, async (error: Error) => {
const request = req as any;
let stopped = null;
if (config.type === 'websocket' && getWebSocketRooms(connectionId).length === 0) {
res.status(404).json({ success: false, message: 'Active WebSocket room not found' });
return;
}
try {
stopped = await stopPromise;
const activeSession = getActiveRecordingSession(connectionId);
if (activeSession) {
res.status(409).json({ success: false, message: 'Recording is already running', session: activeSession });
return;
}
if (!stopped) {
removeFileIfExists(request.file && request.file.path);
res.status(404).json({ success: false, message: 'Server audio recording not found' });
return;
}
try {
const session = startRecordingSession({
connectionId,
layout: req.body.layout,
format: req.body.format
});
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' });
}
});
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;
}
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;
}
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;
}
const notified = broadcastRecordingStopped(session);
const agent = stopRecordingAgent(session.id);
try {
await stopRecordingPeer(session.id);
} catch (error) {
log(LogLevel.warn, 'Failed to stop recording peer:', error);
}
if (!request.file) {
res.json({
success: true,
recordingId: stopped.recordingId,
meetingId: stopped.meetingId,
audioOnly: true,
audioTrackCount: stopped.audioTrackCount
});
return;
}
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 });
});
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;
}
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 });
});
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);
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' });
return;
}
if (!isPathInside(recordingRoot, finalPath)) {
removeFileIfExists(request.file.path);
removeFileIfExists(stopped.audioPath);
res.status(400).json({ success: false, message: 'Invalid recording path' });
return;
}
res.json({ success: true, job });
});
if (!fs.existsSync(meetingDir)) {
fs.mkdirSync(meetingDir, { recursive: 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;
}
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'
});
}
const job = startRecordingCompositionJob({
meetingId,
recordingId,
layout: req.body.layout,
format: req.body.format,
...getRecordingRoomPeople(meetingId)
});
res.status(202).json({ success: true, job });
});
app.get('/api/recordings', (_req: express.Request, res: express.Response) => {

View File

@@ -1,12 +0,0 @@
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>;
}
}

View File

@@ -17,6 +17,9 @@ const VALID_MESSAGE_TYPES = new Set([
"host-userInfo",
"invite-call",
"on-message",
"recording-offer",
"recording-candidate",
"recording-status",
]);
function sendJson(ws: WebSocket, payload: unknown): void {
@@ -204,6 +207,16 @@ export default class WSSignaling {
if (msg.from) msg.data.connectionId = msg.from;
handler.onMessage(ws, msg.data);
break;
case 'recording-offer':
if (!hasData(msg)) return;
handler.onRecordingOffer(ws, msg.data);
break;
case 'recording-candidate':
if (!hasData(msg)) return;
handler.onRecordingCandidate(ws, msg.data);
break;
case 'recording-status':
break;
default:
break;
}

View File

@@ -0,0 +1,114 @@
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();
});
});

View File

@@ -0,0 +1,85 @@
import { buildFfmpegCompositionArgs } from '../src/recording/composer';
import { ServerTrackRecordingFile } from '../src/recording/storage';
function file(filename: string, trackKind: string, participantId: string, role = 'participant'): 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: '2026-06-01T00:00:00.000Z',
metadata: { role }
};
}
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=1280:540');
expect(args.join(' ')).toContain('scale=1280:180');
expect(args.join(' ')).toContain('layout=0_0|0_540');
expect(args.join(' ')).toContain('amix=inputs=2');
expect(args).toContain('libvpx-vp9');
expect(args).toContain('libopus');
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=1280:540');
expect(filter).toContain('scale=640:180');
expect(filter).toContain('layout=0_0|0_540|640_540');
});
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).not.toContain('libopus');
});
});

View File

@@ -0,0 +1,46 @@
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');
});
});

View File

@@ -0,0 +1,163 @@
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'
}));
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'
})
]);
});
});

View File

@@ -190,6 +190,44 @@ 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(expected);
await expect(server).toReceiveMessage(expected);
expect(wsHandler.broadcastRecordingPeerRequest(session)).toBe(true);
await expect(server).toReceiveMessage({
...expected,
type: 'recording-peer-request',
mediaMode: 'webrtc-sendonly'
});
await expect(server).toReceiveMessage({
...expected,
type: 'recording-peer-request',
mediaMode: 'webrtc-sendonly'
});
});
test('send offer from session1', async () => {
await wsHandler.onOffer(client, { connectionId: connectionId, sdp: testsdp });
const receiveOffer = new Offer(testsdp, Date.now(), true);

View File

@@ -3,12 +3,10 @@
"exclude": ["node_modules", "**/*.spec.ts"],
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es5",
"lib": ["dom","es5"],
"sourceMap": true,
"outDir":"build",
"rootDir":"src",
"skipLibCheck": true
"rootDir":"src"
}
}