Compare commits
8 Commits
e6dfb28ef2
...
serverReco
| Author | SHA1 | Date | |
|---|---|---|---|
| 37f195b48c | |||
| 600f64dc6d | |||
| f742499b33 | |||
| 3e161ff995 | |||
| 206a3ac91d | |||
| 59fc4be5cc | |||
| 66d6f92d1e | |||
| d74a0c8121 |
@@ -80,7 +80,9 @@ export default {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||||
// moduleNameMapper: {},
|
moduleNameMapper: {
|
||||||
|
"^/module/(.*)$": "<rootDir>/src/$1"
|
||||||
|
},
|
||||||
|
|
||||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||||
// modulePathIgnorePatterns: [],
|
// modulePathIgnorePatterns: [],
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { TextEncoder, TextDecoder } from 'util';
|
import { TextEncoder, TextDecoder } from 'util';
|
||||||
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/peerconnectionmock';
|
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/mocks/peerconnectionmock.js';
|
||||||
import ResizeObserverMock from './test/resizeobservermock';
|
import ResizeObserverMock from './test/helpers/resizeobservermock.js';
|
||||||
|
|
||||||
// note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined.
|
// note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined.
|
||||||
|
|
||||||
@@ -32,4 +32,4 @@ if (!window.RTCIceCandidate) {
|
|||||||
|
|
||||||
if (!window.ResizeObserver) {
|
if (!window.ResizeObserver) {
|
||||||
window.ResizeObserver = ResizeObserverMock;
|
window.ResizeObserver = ResizeObserverMock;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createTextElement, textValue } from '../../shared/dom.js';
|
||||||
|
|
||||||
export function createMessageElement(message, formatTimestamp) {
|
export function createMessageElement(message, formatTimestamp) {
|
||||||
const messageDiv = document.createElement('div');
|
const messageDiv = document.createElement('div');
|
||||||
let messageClass = 'chat-bubble';
|
let messageClass = 'chat-bubble';
|
||||||
@@ -13,31 +15,45 @@ export function createMessageElement(message, formatTimestamp) {
|
|||||||
messageDiv.className = messageClass;
|
messageDiv.className = messageClass;
|
||||||
messageDiv.dataset.messageId = message.id;
|
messageDiv.dataset.messageId = message.id;
|
||||||
|
|
||||||
const contentHTML = message.type === 'file' && message.content.startsWith('data:image/')
|
const header = document.createElement('div');
|
||||||
? `
|
header.className = 'message-header';
|
||||||
<div class="message-image-container">
|
|
||||||
<img src="${message.content}" class="message-image" alt="${message.fileName || '\u56fe\u7247'}">
|
|
||||||
${message.fileName ? `<div class="message-image-name">${message.fileName}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: `
|
|
||||||
<div class="message-text">
|
|
||||||
${message.content}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
messageDiv.innerHTML = `
|
const avatar = document.createElement('img');
|
||||||
<div class="message-header">
|
avatar.className = 'message-avatar';
|
||||||
<img src="${message.senderAvatar}" class="message-avatar">
|
avatar.src = textValue(message.senderAvatar);
|
||||||
<div>
|
avatar.alt = textValue(message.senderName, '\u7528\u6237');
|
||||||
<span class="message-sender">${message.senderName}</span>
|
header.appendChild(avatar);
|
||||||
<span class="message-time">${formatTimestamp(message.timestamp)}</span>
|
|
||||||
</div>
|
const headerText = document.createElement('div');
|
||||||
</div>
|
headerText.appendChild(createTextElement('span', 'message-sender', message.senderName));
|
||||||
<div class="message-content">
|
headerText.appendChild(createTextElement('span', 'message-time', formatTimestamp(message.timestamp)));
|
||||||
${contentHTML}
|
header.appendChild(headerText);
|
||||||
</div>
|
|
||||||
`;
|
const content = document.createElement('div');
|
||||||
|
content.className = 'message-content';
|
||||||
|
const rawContent = textValue(message.content);
|
||||||
|
|
||||||
|
if (message.type === 'file' && rawContent.startsWith('data:image/')) {
|
||||||
|
const imageContainer = document.createElement('div');
|
||||||
|
imageContainer.className = 'message-image-container';
|
||||||
|
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.src = rawContent;
|
||||||
|
image.className = 'message-image';
|
||||||
|
image.alt = textValue(message.fileName, '\u56fe\u7247');
|
||||||
|
imageContainer.appendChild(image);
|
||||||
|
|
||||||
|
if (message.fileName) {
|
||||||
|
imageContainer.appendChild(createTextElement('div', 'message-image-name', message.fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
content.appendChild(imageContainer);
|
||||||
|
} else {
|
||||||
|
content.appendChild(createTextElement('div', 'message-text', rawContent));
|
||||||
|
}
|
||||||
|
|
||||||
|
messageDiv.appendChild(header);
|
||||||
|
messageDiv.appendChild(content);
|
||||||
|
|
||||||
return messageDiv;
|
return messageDiv;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ export class MeetingRecorder {
|
|||||||
this.audioSources = [];
|
this.audioSources = [];
|
||||||
this.recordingStream = null;
|
this.recordingStream = null;
|
||||||
this.connectionId = '';
|
this.connectionId = '';
|
||||||
|
this.layout = 'grid';
|
||||||
|
this.onChunk = null;
|
||||||
|
this.storeChunks = true;
|
||||||
|
this.mixedAudioDestination = null;
|
||||||
|
this.mixedAudioTrackIds = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
isSupported() {
|
isSupported() {
|
||||||
@@ -137,7 +142,7 @@ export class MeetingRecorder {
|
|||||||
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
|
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()) {
|
if (this.isRecording()) {
|
||||||
throw new Error('会议正在录制中');
|
throw new Error('会议正在录制中');
|
||||||
}
|
}
|
||||||
@@ -156,7 +161,11 @@ export class MeetingRecorder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.connectionId = connectionId || '';
|
this.connectionId = connectionId || '';
|
||||||
|
this.layout = layout || 'grid';
|
||||||
|
this.onChunk = typeof onChunk === 'function' ? onChunk : null;
|
||||||
|
this.storeChunks = storeChunks !== false;
|
||||||
this.chunks = [];
|
this.chunks = [];
|
||||||
|
this.mixedAudioTrackIds = new Set();
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.context = context;
|
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() {
|
stop() {
|
||||||
if (!this.isRecording()) {
|
if (!this.isRecording()) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
@@ -203,16 +222,26 @@ export class MeetingRecorder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.audioContext = new AudioContextCtor();
|
this.audioContext = new AudioContextCtor();
|
||||||
const destination = this.audioContext.createMediaStreamDestination();
|
this.mixedAudioDestination = this.audioContext.createMediaStreamDestination();
|
||||||
|
audioTracks.forEach(track => this._connectAudioTrack(track));
|
||||||
|
|
||||||
audioTracks.forEach(track => {
|
return this.mixedAudioDestination.stream.getAudioTracks()[0] || null;
|
||||||
const sourceStream = new this.window.MediaStream([track]);
|
}
|
||||||
const source = this.audioContext.createMediaStreamSource(sourceStream);
|
|
||||||
source.connect(destination);
|
|
||||||
this.audioSources.push(source);
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
startMediaRecorder(stream) {
|
||||||
@@ -224,7 +253,17 @@ export class MeetingRecorder {
|
|||||||
this.mediaRecorder = new MediaRecorderCtor(stream, options);
|
this.mediaRecorder = new MediaRecorderCtor(stream, options);
|
||||||
this.mediaRecorder.ondataavailable = (event) => {
|
this.mediaRecorder.ondataavailable = (event) => {
|
||||||
if (event.data && event.data.size > 0) {
|
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) => {
|
this.mediaRecorder.onerror = (event) => {
|
||||||
@@ -235,9 +274,9 @@ export class MeetingRecorder {
|
|||||||
this.cleanup();
|
this.cleanup();
|
||||||
};
|
};
|
||||||
this.mediaRecorder.onstop = () => {
|
this.mediaRecorder.onstop = () => {
|
||||||
const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType || 'video/webm' });
|
|
||||||
const filename = this.buildFilename();
|
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();
|
this.cleanup();
|
||||||
if (this.pendingStop) {
|
if (this.pendingStop) {
|
||||||
this.pendingStop.resolve({ blob, filename, mimeType });
|
this.pendingStop.resolve({ blob, filename, mimeType });
|
||||||
@@ -267,6 +306,15 @@ export class MeetingRecorder {
|
|||||||
context.fillStyle = '#020617';
|
context.fillStyle = '#020617';
|
||||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
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) {
|
if (remoteVideos.length > 0) {
|
||||||
drawGrid(context, remoteVideos, canvas);
|
drawGrid(context, remoteVideos, canvas);
|
||||||
if (localVideo) {
|
if (localVideo) {
|
||||||
@@ -328,9 +376,13 @@ export class MeetingRecorder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.audioSources = [];
|
this.audioSources = [];
|
||||||
|
this.mixedAudioDestination = null;
|
||||||
|
this.mixedAudioTrackIds = new Set();
|
||||||
this.mediaRecorder = null;
|
this.mediaRecorder = null;
|
||||||
this.canvas = null;
|
this.canvas = null;
|
||||||
this.context = null;
|
this.context = null;
|
||||||
this.chunks = [];
|
this.chunks = [];
|
||||||
|
this.onChunk = null;
|
||||||
|
this.storeChunks = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
175
client/public/call/media/server-recording-peer.js
Normal file
175
client/public/call/media/server-recording-peer.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { createLogger } from '../../shared/logger.js';
|
||||||
|
|
||||||
|
const logger = createLogger('server-recording-peer');
|
||||||
|
|
||||||
|
export class ServerRecordingPeer {
|
||||||
|
constructor({
|
||||||
|
rtcConfiguration,
|
||||||
|
getLocalStream,
|
||||||
|
getSignaling,
|
||||||
|
getConnectionId,
|
||||||
|
getParticipantId
|
||||||
|
}) {
|
||||||
|
this.rtcConfiguration = rtcConfiguration;
|
||||||
|
this.getLocalStream = getLocalStream;
|
||||||
|
this.getSignaling = getSignaling;
|
||||||
|
this.getConnectionId = getConnectionId;
|
||||||
|
this.getParticipantId = getParticipantId;
|
||||||
|
this.peers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(request) {
|
||||||
|
if (!request || !request.recordingId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stop(request.recordingId);
|
||||||
|
|
||||||
|
const localStream = this.getLocalStream();
|
||||||
|
const tracks = localStream ? localStream.getTracks().filter(track => track.readyState !== 'ended') : [];
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
this._sendStatus(request, 'no-local-media');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection(this.rtcConfiguration);
|
||||||
|
const state = {
|
||||||
|
pc,
|
||||||
|
recordingId: request.recordingId,
|
||||||
|
connectionId: request.connectionId || this.getConnectionId(),
|
||||||
|
pendingCandidates: []
|
||||||
|
};
|
||||||
|
this.peers.set(request.recordingId, state);
|
||||||
|
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (!event.candidate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._sendCandidate(state, event.candidate);
|
||||||
|
};
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
logger.debug(`recording peer ${request.recordingId} state: ${pc.connectionState}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
tracks.forEach(track => {
|
||||||
|
pc.addTransceiver(track, {
|
||||||
|
direction: 'sendonly',
|
||||||
|
streams: localStream ? [localStream] : []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
this._sendOffer(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyAnswer(answer) {
|
||||||
|
const state = this.peers.get(answer?.recordingId);
|
||||||
|
if (!state || !answer?.sdp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await state.pc.setRemoteDescription(new RTCSessionDescription({
|
||||||
|
type: 'answer',
|
||||||
|
sdp: answer.sdp
|
||||||
|
}));
|
||||||
|
await this._flushPendingCandidates(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addIceCandidate(candidate) {
|
||||||
|
const state = this.peers.get(candidate?.recordingId);
|
||||||
|
if (!state || !candidate?.candidate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iceCandidate = new RTCIceCandidate({
|
||||||
|
candidate: candidate.candidate,
|
||||||
|
sdpMid: candidate.sdpMid,
|
||||||
|
sdpMLineIndex: candidate.sdpMLineIndex
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!state.pc.remoteDescription) {
|
||||||
|
state.pendingCandidates.push(iceCandidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await state.pc.addIceCandidate(iceCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(recordingId) {
|
||||||
|
if (!recordingId) {
|
||||||
|
this.peers.forEach(peerState => this._closePeer(peerState));
|
||||||
|
this.peers.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this.peers.get(recordingId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._closePeer(state);
|
||||||
|
this.peers.delete(recordingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_closePeer(state) {
|
||||||
|
state.pendingCandidates = [];
|
||||||
|
state.pc.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _flushPendingCandidates(state) {
|
||||||
|
if (!state?.pc?.remoteDescription || !state.pendingCandidates.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCandidates = state.pendingCandidates.splice(0, state.pendingCandidates.length);
|
||||||
|
for (const candidate of pendingCandidates) {
|
||||||
|
await state.pc.addIceCandidate(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendOffer(state) {
|
||||||
|
const signaling = this.getSignaling();
|
||||||
|
if (!signaling || typeof signaling.sendRecordingOffer !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signaling.sendRecordingOffer({
|
||||||
|
recordingId: state.recordingId,
|
||||||
|
connectionId: state.connectionId,
|
||||||
|
participantId: this.getParticipantId() || '',
|
||||||
|
sdp: state.pc.localDescription?.sdp || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendCandidate(state, candidate) {
|
||||||
|
const signaling = this.getSignaling();
|
||||||
|
if (!signaling || typeof signaling.sendRecordingCandidate !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signaling.sendRecordingCandidate({
|
||||||
|
recordingId: state.recordingId,
|
||||||
|
connectionId: state.connectionId,
|
||||||
|
participantId: this.getParticipantId() || '',
|
||||||
|
candidate: candidate.candidate,
|
||||||
|
sdpMid: candidate.sdpMid,
|
||||||
|
sdpMLineIndex: candidate.sdpMLineIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendStatus(request, status) {
|
||||||
|
const signaling = this.getSignaling();
|
||||||
|
if (!signaling || typeof signaling.sendRecordingStatus !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signaling.sendRecordingStatus({
|
||||||
|
recordingId: request.recordingId,
|
||||||
|
connectionId: request.connectionId || this.getConnectionId(),
|
||||||
|
participantId: this.getParticipantId() || '',
|
||||||
|
status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
|
||||||
|
|
||||||
function createParticipantPlaceholder() {
|
function createParticipantPlaceholder() {
|
||||||
const placeholder = document.createElement('div');
|
const placeholder = document.createElement('div');
|
||||||
placeholder.className = 'participant-video-placeholder absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900/80 to-purple-900/80 hidden';
|
placeholder.className = 'participant-video-placeholder absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900/80 to-purple-900/80 hidden';
|
||||||
@@ -15,32 +17,39 @@ function createParticipantPlaceholder() {
|
|||||||
export function createParticipantTile(connectionId, displayName) {
|
export function createParticipantTile(connectionId, displayName) {
|
||||||
const tile = document.createElement('div');
|
const tile = document.createElement('div');
|
||||||
tile.className = 'relative bg-black/60 rounded-xl overflow-hidden flex items-center justify-center';
|
tile.className = 'relative bg-black/60 rounded-xl overflow-hidden flex items-center justify-center';
|
||||||
tile.dataset.participantId = connectionId;
|
tile.dataset.participantId = textValue(connectionId);
|
||||||
|
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
video.className = 'w-full h-full object-contain';
|
video.className = 'w-full h-full object-contain';
|
||||||
video.autoplay = true;
|
video.autoplay = true;
|
||||||
video.playsinline = true;
|
video.playsinline = true;
|
||||||
video.muted = false;
|
video.muted = false;
|
||||||
video.id = `participantVideo_${connectionId}`;
|
video.id = `participantVideo_${textValue(connectionId)}`;
|
||||||
tile.appendChild(video);
|
tile.appendChild(video);
|
||||||
tile.appendChild(createParticipantPlaceholder());
|
tile.appendChild(createParticipantPlaceholder());
|
||||||
|
|
||||||
const label = document.createElement('div');
|
const label = document.createElement('div');
|
||||||
label.className = 'absolute bottom-3 left-3 glass px-3 py-1 rounded-full text-xs flex items-center gap-2';
|
label.className = 'absolute bottom-3 left-3 glass px-3 py-1 rounded-full text-xs flex items-center gap-2';
|
||||||
label.innerHTML = `<i class="fas fa-user text-purple-400"></i><span>${displayName || '\u53c2\u4e0e\u8005'}</span>`;
|
label.appendChild(createIconElement('fas fa-user text-purple-400'));
|
||||||
|
label.appendChild(createTextElement('span', '', displayName, '\u53c2\u4e0e\u8005'));
|
||||||
tile.appendChild(label);
|
tile.appendChild(label);
|
||||||
|
|
||||||
const liveTag = document.createElement('div');
|
const liveTag = document.createElement('div');
|
||||||
liveTag.className = 'absolute top-3 right-3 bg-green-500/80 px-2 py-0.5 rounded-full text-xs flex items-center gap-1';
|
liveTag.className = 'absolute top-3 right-3 bg-green-500/80 px-2 py-0.5 rounded-full text-xs flex items-center gap-1';
|
||||||
liveTag.innerHTML = `<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span><span>\u5728\u7ebf</span>`;
|
const pulse = document.createElement('span');
|
||||||
|
pulse.className = 'w-1.5 h-1.5 bg-white rounded-full animate-pulse';
|
||||||
|
liveTag.appendChild(pulse);
|
||||||
|
liveTag.appendChild(createTextElement('span', '', '\u5728\u7ebf'));
|
||||||
tile.appendChild(liveTag);
|
tile.appendChild(liveTag);
|
||||||
|
|
||||||
return tile;
|
return tile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParticipantTile(grid, participantId) {
|
export function getParticipantTile(grid, participantId) {
|
||||||
return grid?.querySelector(`[data-participant-id="${participantId}"]`) || null;
|
if (!grid) return null;
|
||||||
|
const expectedId = textValue(participantId);
|
||||||
|
return Array.from(grid.querySelectorAll('[data-participant-id]'))
|
||||||
|
.find(tile => tile.dataset.participantId === expectedId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateParticipantTilePlaceholder(grid, participantId, showPlaceholder) {
|
export function updateParticipantTilePlaceholder(grid, participantId, showPlaceholder) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
|
||||||
|
|
||||||
const DEFAULT_NETWORK_QUALITY = {
|
const DEFAULT_NETWORK_QUALITY = {
|
||||||
label: '\u672a\u77e5',
|
label: '\u672a\u77e5',
|
||||||
statusIconClass: 'fas fa-question-circle text-gray-400',
|
statusIconClass: 'fas fa-question-circle text-gray-400',
|
||||||
@@ -50,18 +52,18 @@ const NETWORK_QUALITY_DISPLAY = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function getRoleTagMarkup(user, role) {
|
function getRoleTagMeta(user, role) {
|
||||||
if (role === 'local') {
|
if (role === 'local') {
|
||||||
return user.isHost
|
return user.isHost
|
||||||
? '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>'
|
? { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' }
|
||||||
: '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>';
|
: { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === 'participant') {
|
if (role === 'participant') {
|
||||||
return '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>';
|
return { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>';
|
return { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDatasetUserId(role, id) {
|
function getDatasetUserId(role, id) {
|
||||||
@@ -79,34 +81,55 @@ function getDatasetUserId(role, id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvatarMarkup(user, role) {
|
function createAvatarImage(user) {
|
||||||
if (role === 'local') {
|
const image = document.createElement('img');
|
||||||
return `<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">`;
|
image.src = textValue(user.avatar);
|
||||||
}
|
image.alt = textValue(user.name, '\u7528\u6237');
|
||||||
|
image.className = 'w-10 h-10 rounded-full object-cover';
|
||||||
return `
|
return image;
|
||||||
<div class="relative">
|
|
||||||
<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">
|
|
||||||
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRightMarkup(mediaState, role, muteIconMarkup) {
|
function createAvatarElement(user, role) {
|
||||||
if (role !== 'participant') {
|
if (role === 'local') {
|
||||||
return muteIconMarkup;
|
return createAvatarImage(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
const speakingMarkup = (mediaState.isSpeaking && mediaState.audio)
|
const wrapper = document.createElement('div');
|
||||||
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>'
|
wrapper.className = 'relative';
|
||||||
: '';
|
wrapper.appendChild(createAvatarImage(user));
|
||||||
|
|
||||||
return `
|
const statusDot = document.createElement('div');
|
||||||
<div class="flex items-center gap-2">
|
statusDot.className = 'absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900';
|
||||||
${muteIconMarkup}
|
wrapper.appendChild(statusDot);
|
||||||
${speakingMarkup}
|
|
||||||
</div>
|
return wrapper;
|
||||||
`;
|
}
|
||||||
|
|
||||||
|
function createAudioWaveElement() {
|
||||||
|
const wave = document.createElement('div');
|
||||||
|
wave.className = 'audio-wave w-6';
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
wave.appendChild(document.createElement('span'));
|
||||||
|
}
|
||||||
|
return wave;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRightElement(mediaState, role, muteIcon) {
|
||||||
|
if (role !== 'participant') {
|
||||||
|
return muteIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const right = document.createElement('div');
|
||||||
|
right.className = 'flex items-center gap-2';
|
||||||
|
|
||||||
|
if (muteIcon) {
|
||||||
|
right.appendChild(muteIcon);
|
||||||
|
}
|
||||||
|
if (mediaState.isSpeaking && mediaState.audio) {
|
||||||
|
right.appendChild(createAudioWaveElement());
|
||||||
|
}
|
||||||
|
|
||||||
|
return right.childNodes.length > 0 ? right : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCallTitle(connectionId) {
|
export function getCallTitle(connectionId) {
|
||||||
@@ -163,27 +186,40 @@ export function buildUserCountLabel(userCount) {
|
|||||||
export function createUserEntryElement({ user, role, id }) {
|
export function createUserEntryElement({ user, role, id }) {
|
||||||
const entry = document.createElement('div');
|
const entry = document.createElement('div');
|
||||||
const mediaMeta = getMediaStatusMeta(user.mediaState);
|
const mediaMeta = getMediaStatusMeta(user.mediaState);
|
||||||
const muteIconMarkup = mediaMeta.showMuteIcon
|
const muteIcon = mediaMeta.showMuteIcon
|
||||||
? `<i class="${mediaMeta.muteIconClass}"></i>`
|
? createIconElement(mediaMeta.muteIconClass)
|
||||||
: '';
|
: '';
|
||||||
const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
|
const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
|
||||||
const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : '';
|
|
||||||
|
|
||||||
entry.className = role === 'local'
|
entry.className = role === 'local'
|
||||||
? `${baseClass} hover:bg-white/5`
|
? `${baseClass} hover:bg-white/5`
|
||||||
: `${baseClass} bg-white/5`;
|
: `${baseClass} bg-white/5`;
|
||||||
entry.dataset.userId = getDatasetUserId(role, id);
|
entry.dataset.userId = getDatasetUserId(role, id);
|
||||||
entry.innerHTML = `
|
|
||||||
${getAvatarMarkup(user, role)}
|
entry.appendChild(createAvatarElement(user, role));
|
||||||
<div class="flex-1">
|
|
||||||
<div class="text-sm font-medium">
|
const details = document.createElement('div');
|
||||||
${user.name}
|
details.className = 'flex-1';
|
||||||
${getRoleTagMarkup(user, role)}
|
|
||||||
</div>
|
const nameRow = document.createElement('div');
|
||||||
<div class="${mediaMeta.className}"${dataFieldAttr}>${mediaMeta.text}</div>
|
nameRow.className = 'text-sm font-medium';
|
||||||
</div>
|
nameRow.appendChild(document.createTextNode(textValue(user.name)));
|
||||||
${getRightMarkup(user.mediaState, role, muteIconMarkup)}
|
const roleTag = getRoleTagMeta(user, role);
|
||||||
`;
|
nameRow.appendChild(createTextElement('span', roleTag.className, roleTag.label));
|
||||||
|
details.appendChild(nameRow);
|
||||||
|
|
||||||
|
const mediaStatus = createTextElement('div', mediaMeta.className, mediaMeta.text);
|
||||||
|
if (role === 'local') {
|
||||||
|
mediaStatus.dataset.field = 'localUser.mediaStatus';
|
||||||
|
}
|
||||||
|
details.appendChild(mediaStatus);
|
||||||
|
|
||||||
|
entry.appendChild(details);
|
||||||
|
|
||||||
|
const right = createRightElement(user.mediaState, role, muteIcon || null);
|
||||||
|
if (right) {
|
||||||
|
entry.appendChild(right);
|
||||||
|
}
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createTextElement, textValue } from '../../shared/dom.js';
|
||||||
|
|
||||||
const EMPTY_CONNECTION_IDS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u53ef\u7528\u7684\u8fde\u63a5ID</p>';
|
const EMPTY_CONNECTION_IDS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u53ef\u7528\u7684\u8fde\u63a5ID</p>';
|
||||||
const EMPTY_USERS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u5728\u7ebf\u7528\u6237</p>';
|
const EMPTY_USERS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u5728\u7ebf\u7528\u6237</p>';
|
||||||
const HALL_LABEL = '\u5927\u5385\uff08\u672a\u52a0\u5165\u623f\u95f4\uff09';
|
const HALL_LABEL = '\u5927\u5385\uff08\u672a\u52a0\u5165\u623f\u95f4\uff09';
|
||||||
@@ -10,13 +12,14 @@ const SELECT_LABEL = '\u9009\u62e9';
|
|||||||
const USER_COUNT_SUFFIX = '\u4eba';
|
const USER_COUNT_SUFFIX = '\u4eba';
|
||||||
const ONLINE_USERS_SUMMARY_SUFFIX = ' \u4e2aWebSocket\u7528\u6237\u5728\u7ebf';
|
const ONLINE_USERS_SUMMARY_SUFFIX = ' \u4e2aWebSocket\u7528\u6237\u5728\u7ebf';
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function getRoleTagClass(role) {
|
||||||
return String(value || '')
|
if (role === 'host') {
|
||||||
.replace(/&/g, '&')
|
return 'text-xs px-2 py-1 rounded-full bg-indigo-500/20 text-indigo-300';
|
||||||
.replace(/</g, '<')
|
}
|
||||||
.replace(/>/g, '>')
|
if (role === 'participant') {
|
||||||
.replace(/"/g, '"')
|
return 'text-xs px-2 py-1 rounded-full bg-white/10 text-gray-300';
|
||||||
.replace(/'/g, ''');
|
}
|
||||||
|
return 'text-xs px-2 py-1 rounded-full bg-emerald-500/20 text-emerald-300';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOnlineUsers() {
|
export async function fetchOnlineUsers() {
|
||||||
@@ -115,10 +118,8 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
|
|||||||
|
|
||||||
const roomTitle = document.createElement('div');
|
const roomTitle = document.createElement('div');
|
||||||
roomTitle.className = 'flex items-center justify-between mb-2';
|
roomTitle.className = 'flex items-center justify-between mb-2';
|
||||||
roomTitle.innerHTML = `
|
roomTitle.appendChild(createTextElement('span', 'text-sm font-medium text-white', groupName));
|
||||||
<span class="text-sm font-medium text-white">${escapeHtml(groupName)}</span>
|
roomTitle.appendChild(createTextElement('span', 'text-xs text-gray-400', `${roomUsers.length} ${USER_COUNT_SUFFIX}`));
|
||||||
<span class="text-xs text-gray-400">${roomUsers.length} ${USER_COUNT_SUFFIX}</span>
|
|
||||||
`;
|
|
||||||
section.appendChild(roomTitle);
|
section.appendChild(roomTitle);
|
||||||
|
|
||||||
const roomList = document.createElement('div');
|
const roomList = document.createElement('div');
|
||||||
@@ -135,19 +136,31 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
|
|||||||
|
|
||||||
const userItem = document.createElement('div');
|
const userItem = document.createElement('div');
|
||||||
userItem.className = 'flex items-center justify-between rounded-lg bg-black/20 px-3 py-2';
|
userItem.className = 'flex items-center justify-between rounded-lg bg-black/20 px-3 py-2';
|
||||||
userItem.innerHTML = `
|
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
const profile = document.createElement('div');
|
||||||
<img src="${escapeHtml(avatar)}" alt="${escapeHtml(userName)}" class="w-8 h-8 rounded-full object-cover">
|
profile.className = 'flex items-center gap-3 min-w-0';
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="text-sm text-white truncate">${escapeHtml(userName)}</div>
|
const avatarImage = document.createElement('img');
|
||||||
<div class="text-xs text-gray-400 truncate">${escapeHtml(identity)}</div>
|
avatarImage.src = textValue(avatar);
|
||||||
</div>
|
avatarImage.alt = textValue(userName);
|
||||||
</div>
|
avatarImage.className = 'w-8 h-8 rounded-full object-cover';
|
||||||
<div class="flex items-center gap-2">
|
profile.appendChild(avatarImage);
|
||||||
<span class="text-xs px-2 py-1 rounded-full ${user.role === 'host' ? 'bg-indigo-500/20 text-indigo-300' : (user.role === 'participant' ? 'bg-white/10 text-gray-300' : 'bg-emerald-500/20 text-emerald-300')}">${roleLabel}</span>
|
|
||||||
${isSelf ? `<span class="text-xs text-gray-500">${SELF_LABEL}</span>` : ''}
|
const info = document.createElement('div');
|
||||||
</div>
|
info.className = 'min-w-0';
|
||||||
`;
|
info.appendChild(createTextElement('div', 'text-sm text-white truncate', userName));
|
||||||
|
info.appendChild(createTextElement('div', 'text-xs text-gray-400 truncate', identity));
|
||||||
|
profile.appendChild(info);
|
||||||
|
|
||||||
|
const status = document.createElement('div');
|
||||||
|
status.className = 'flex items-center gap-2';
|
||||||
|
status.appendChild(createTextElement('span', getRoleTagClass(user.role), roleLabel));
|
||||||
|
if (isSelf) {
|
||||||
|
status.appendChild(createTextElement('span', 'text-xs text-gray-500', SELF_LABEL));
|
||||||
|
}
|
||||||
|
|
||||||
|
userItem.appendChild(profile);
|
||||||
|
userItem.appendChild(status);
|
||||||
roomList.appendChild(userItem);
|
roomList.appendChild(userItem);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,22 @@ import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInst
|
|||||||
import { getNetworkQualityFromSummary, summarizeInboundStats } from './media/webrtc-stats.js';
|
import { getNetworkQualityFromSummary, summarizeInboundStats } from './media/webrtc-stats.js';
|
||||||
import { createLogger } from '../shared/logger.js';
|
import { createLogger } from '../shared/logger.js';
|
||||||
import { MeetingRecorder } from './media/meeting-recorder.js';
|
import { MeetingRecorder } from './media/meeting-recorder.js';
|
||||||
|
import { ServerRecordingPeer } from './media/server-recording-peer.js';
|
||||||
|
|
||||||
const logger = createLogger('store');
|
const logger = createLogger('store');
|
||||||
|
const MEDIA_STATE_KEYS = ['audio', 'video', 'screenShare', 'recording', 'isSpeaking'];
|
||||||
|
|
||||||
|
function hasMediaStateChanged(current = {}, next = {}) {
|
||||||
|
if (!next || typeof next !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MEDIA_STATE_KEYS.some(key => (
|
||||||
|
Object.prototype.hasOwnProperty.call(next, key)
|
||||||
|
&& Boolean(current?.[key]) !== Boolean(next[key])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
class CallStateManager {
|
class CallStateManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -28,6 +42,9 @@ class CallStateManager {
|
|||||||
this.listeners = [];
|
this.listeners = [];
|
||||||
this.socketEventHandlers = {};
|
this.socketEventHandlers = {};
|
||||||
this._inviteEventSignaling = null;
|
this._inviteEventSignaling = null;
|
||||||
|
this._recordingEventSignaling = null;
|
||||||
|
this.serverRecordingSession = null;
|
||||||
|
this.serverRecordingPeer = null;
|
||||||
this.meetingRecorder = new MeetingRecorder();
|
this.meetingRecorder = new MeetingRecorder();
|
||||||
}
|
}
|
||||||
subscribe(callback) {
|
subscribe(callback) {
|
||||||
@@ -112,12 +129,73 @@ class CallStateManager {
|
|||||||
async toggleRecording() {
|
async toggleRecording() {
|
||||||
const isRecording = this.state.session.localUser.mediaState.recording || false;
|
const isRecording = this.state.session.localUser.mediaState.recording || false;
|
||||||
|
|
||||||
|
if (this.useWebSocket && this.connectionId) {
|
||||||
|
return isRecording ? this.stopServerRecording() : this.startServerRecording();
|
||||||
|
}
|
||||||
|
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
return this.stopRecording();
|
return this.stopRecording();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.startRecording();
|
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() {
|
async startRecording() {
|
||||||
if (this.state.session.status !== 'ongoing') {
|
if (this.state.session.status !== 'ongoing') {
|
||||||
throw new Error('会议连接成功后才能开始录制');
|
throw new Error('会议连接成功后才能开始录制');
|
||||||
@@ -229,6 +307,7 @@ class CallStateManager {
|
|||||||
async _updateLocalMediaRefactored(mediaType, value) {
|
async _updateLocalMediaRefactored(mediaType, value) {
|
||||||
if (mediaType === 'video' && value) {
|
if (mediaType === 'video' && value) {
|
||||||
await this._enableLocalVideo();
|
await this._enableLocalVideo();
|
||||||
|
this._refreshServerRecordingPeer();
|
||||||
this._notifyUserListUpdate();
|
this._notifyUserListUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -241,6 +320,7 @@ class CallStateManager {
|
|||||||
if (mediaType === 'audio') {
|
if (mediaType === 'audio') {
|
||||||
this._setLocalAudioTrackEnabled(value);
|
this._setLocalAudioTrackEnabled(value);
|
||||||
}
|
}
|
||||||
|
this._refreshServerRecordingPeer();
|
||||||
this._notifyUserListUpdate();
|
this._notifyUserListUpdate();
|
||||||
}
|
}
|
||||||
async _enableLocalVideo() {
|
async _enableLocalVideo() {
|
||||||
@@ -441,6 +521,8 @@ class CallStateManager {
|
|||||||
await this._startConnection(connectionId);
|
await this._startConnection(connectionId);
|
||||||
}
|
}
|
||||||
_registerCallbacks() {
|
_registerCallbacks() {
|
||||||
|
this._ensureServerRecordingPeer();
|
||||||
|
this._bindRecordingSignalHandlers();
|
||||||
this.renderstreaming.onNewPeer = (participantId) => {
|
this.renderstreaming.onNewPeer = (participantId) => {
|
||||||
logger.debug(`New peer created for ${participantId}, adding local tracks`);
|
logger.debug(`New peer created for ${participantId}, adding local tracks`);
|
||||||
if (this.state.localStream) {
|
if (this.state.localStream) {
|
||||||
@@ -534,6 +616,46 @@ class CallStateManager {
|
|||||||
this._handleRenderStreamingMessage(data);
|
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) {
|
async _startConnection(connectionId) {
|
||||||
await this.renderstreaming.start();
|
await this.renderstreaming.start();
|
||||||
await this.renderstreaming.createConnection(connectionId);
|
await this.renderstreaming.createConnection(connectionId);
|
||||||
@@ -552,6 +674,9 @@ class CallStateManager {
|
|||||||
}
|
}
|
||||||
this.clearStatsMessage();
|
this.clearStatsMessage();
|
||||||
this.stopNetworkQualityDetection();
|
this.stopNetworkQualityDetection();
|
||||||
|
if (this.serverRecordingPeer) {
|
||||||
|
this.serverRecordingPeer.stop();
|
||||||
|
}
|
||||||
if (this.durationInterval) {
|
if (this.durationInterval) {
|
||||||
clearInterval(this.durationInterval);
|
clearInterval(this.durationInterval);
|
||||||
this.durationInterval = null;
|
this.durationInterval = null;
|
||||||
@@ -674,6 +799,116 @@ class CallStateManager {
|
|||||||
break;
|
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) {
|
_handleChatMessage(data) {
|
||||||
const chatPayload = data.data || data.message;
|
const chatPayload = data.data || data.message;
|
||||||
if (!chatPayload) {
|
if (!chatPayload) {
|
||||||
@@ -710,29 +945,34 @@ class CallStateManager {
|
|||||||
_handleMediaStateChangedMessage(data) {
|
_handleMediaStateChangedMessage(data) {
|
||||||
logger.debug('收到媒体状态更新:', data.data, 'from participant:', data.participantId);
|
logger.debug('收到媒体状态更新:', data.data, 'from participant:', data.participantId);
|
||||||
if (this.role === 'host') {
|
if (this.role === 'host') {
|
||||||
if (data.participantId && this.state.participants[data.participantId]) {
|
const participantChanged = this._updateParticipantMediaStateIfChanged(data.participantId, data.data);
|
||||||
this._upsertParticipant(data.participantId, {
|
const remoteChanged = this._updateRemoteMediaIfChanged(data.data, data.participantId);
|
||||||
mediaState: data.data
|
if (participantChanged) {
|
||||||
});
|
this._notifyParticipantsUpdate();
|
||||||
|
this.broadcastParticipantsList();
|
||||||
|
}
|
||||||
|
if (!participantChanged && !remoteChanged) {
|
||||||
|
logger.debug('媒体状态未变化,跳过更新:', data.participantId);
|
||||||
}
|
}
|
||||||
this.updateRemoteMedia(data.data, data.participantId);
|
|
||||||
this._notifyParticipantsUpdate();
|
|
||||||
this.broadcastParticipantsList();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.participantId && data.participantId !== this.selfParticipantId && this.state.participants[data.participantId]) {
|
if (data.participantId && data.participantId !== this.selfParticipantId && this.state.participants[data.participantId]) {
|
||||||
this._upsertParticipant(data.participantId, {
|
if (this._updateParticipantMediaStateIfChanged(data.participantId, data.data)) {
|
||||||
mediaState: data.data
|
this._notifyParticipantsUpdate();
|
||||||
});
|
} else {
|
||||||
this._notifyParticipantsUpdate();
|
logger.debug('媒体状态未变化,跳过参与者更新:', data.participantId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.participantId === this.selfParticipantId) {
|
if (data.participantId === this.selfParticipantId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.debug('Received media-state-changed from Host, updating remoteUser:', data.data);
|
logger.debug('Received media-state-changed from Host, updating remoteUser:', data.data);
|
||||||
this.updateRemoteMedia(data.data, data.participantId);
|
if (this._updateRemoteMediaIfChanged(data.data, data.participantId)) {
|
||||||
this._notifyParticipantsUpdate();
|
this._notifyParticipantsUpdate();
|
||||||
|
} else {
|
||||||
|
logger.debug('媒体状态未变化,跳过远端用户更新:', data.participantId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_handleUserInfoMessage(data) {
|
_handleUserInfoMessage(data) {
|
||||||
logger.debug('收到用户信息:', data.data, 'from participant:', data.participantId);
|
logger.debug('收到用户信息:', data.data, 'from participant:', data.participantId);
|
||||||
@@ -824,6 +1064,25 @@ class CallStateManager {
|
|||||||
_upsertParticipant(participantId, patch = {}) {
|
_upsertParticipant(participantId, patch = {}) {
|
||||||
return upsertParticipant(this.state.participants, participantId, patch);
|
return upsertParticipant(this.state.participants, participantId, patch);
|
||||||
}
|
}
|
||||||
|
_updateParticipantMediaStateIfChanged(participantId, mediaState) {
|
||||||
|
if (!participantId || !this.state.participants[participantId]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!hasMediaStateChanged(this.state.participants[participantId].mediaState, mediaState)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._upsertParticipant(participantId, {
|
||||||
|
mediaState
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_updateRemoteMediaIfChanged(mediaState, participantId) {
|
||||||
|
if (!hasMediaStateChanged(this.state.session.remoteUser.mediaState, mediaState)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.updateRemoteMedia(mediaState, participantId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
_removeParticipant(participantId) {
|
_removeParticipant(participantId) {
|
||||||
return removeParticipant(this.state.participants, participantId);
|
return removeParticipant(this.state.participants, participantId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ function formatDate(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPersonId(person) {
|
function getPersonId(person) {
|
||||||
return person?.userId || person?.id || '';
|
return person?.userId || person?.id || person?.participantId || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPersonName(person) {
|
function getPersonName(person) {
|
||||||
return person?.name || getPersonId(person) || '-';
|
return person?.name || person?.displayName || getPersonId(person) || '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecordingHost(recording) {
|
function getRecordingHost(recording) {
|
||||||
|
|||||||
18
client/public/shared/dom.js
Normal file
18
client/public/shared/dom.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export function textValue(value, fallback = '') {
|
||||||
|
return value == null || value === '' ? fallback : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTextElement(tagName, className, value, fallback = '') {
|
||||||
|
const element = document.createElement(tagName);
|
||||||
|
if (className) {
|
||||||
|
element.className = className;
|
||||||
|
}
|
||||||
|
element.textContent = textValue(value, fallback);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIconElement(className) {
|
||||||
|
const icon = document.createElement('i');
|
||||||
|
icon.className = className;
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
@@ -448,6 +448,7 @@ body {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px minmax(420px, 1fr) 360px;
|
grid-template-columns: 280px minmax(420px, 1fr) 360px;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recordings-upload,
|
.recordings-upload,
|
||||||
@@ -612,7 +613,33 @@ body {
|
|||||||
.recordings-table-wrap {
|
.recordings-table-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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 {
|
.recordings-table {
|
||||||
@@ -900,6 +927,7 @@ body {
|
|||||||
.recordings-preview {
|
.recordings-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recordings-list,
|
.recordings-list,
|
||||||
|
|||||||
@@ -1,11 +1,48 @@
|
|||||||
import * as Logger from "../utils/logger.js";
|
import * as Logger from "../utils/logger.js";
|
||||||
|
|
||||||
|
const RECORDING_SIGNAL_EVENTS = [
|
||||||
|
'recording-started',
|
||||||
|
'recording-peer-request',
|
||||||
|
'recording-stopped',
|
||||||
|
'recording-status',
|
||||||
|
'recording-answer',
|
||||||
|
'recording-candidate'
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseOnMessageData(data) {
|
||||||
|
if (typeof data !== 'string') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch(e) {
|
||||||
|
Logger.error(`Signaling: on-message, error: ${e}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchOnMessageEvent(target, data, participantId) {
|
||||||
|
const parsed = parseOnMessageData(data);
|
||||||
|
if (participantId && parsed && typeof parsed === 'object') {
|
||||||
|
parsed.participantId = participantId;
|
||||||
|
}
|
||||||
|
target.dispatchEvent(new CustomEvent('on-message', { detail: parsed }));
|
||||||
|
if (parsed && typeof parsed.type === 'string' && RECORDING_SIGNAL_EVENTS.indexOf(parsed.type) !== -1) {
|
||||||
|
const detail = parsed.data && typeof parsed.data === 'object'
|
||||||
|
? { type: parsed.type, ...parsed.data }
|
||||||
|
: parsed;
|
||||||
|
target.dispatchEvent(new CustomEvent(parsed.type, { detail }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Signaling extends EventTarget {
|
export class Signaling extends EventTarget {
|
||||||
|
|
||||||
constructor(interval = 1000) {
|
constructor(interval = 1000, baseUrl = null) {
|
||||||
super();
|
super();
|
||||||
this.running = false;
|
this.running = false;
|
||||||
this.interval = interval;
|
this.interval = interval;
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +56,7 @@ export class Signaling extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url(method, parameter = '') {
|
url(method, parameter = '') {
|
||||||
let ret = location.origin + '/signaling';
|
let ret = (this.baseUrl || location.origin) + '/signaling';
|
||||||
if (method)
|
if (method)
|
||||||
ret += '/' + method;
|
ret += '/' + method;
|
||||||
if (parameter)
|
if (parameter)
|
||||||
@@ -72,15 +109,7 @@ export class Signaling extends EventTarget {
|
|||||||
this.dispatchEvent(new CustomEvent('candidate', { detail: msg }));
|
this.dispatchEvent(new CustomEvent('candidate', { detail: msg }));
|
||||||
break;
|
break;
|
||||||
case "on-message":
|
case "on-message":
|
||||||
{
|
dispatchOnMessageEvent(this, msg.data, msg.participantId);
|
||||||
let parsed = msg.data;
|
|
||||||
if (typeof msg.data === 'string') {
|
|
||||||
try { parsed = JSON.parse(msg.data); } catch(e) {
|
|
||||||
Logger.error(`Signaling: on-message, error: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.dispatchEvent(new CustomEvent('on-message', { detail: parsed }));
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -151,16 +180,17 @@ export class Signaling extends EventTarget {
|
|||||||
|
|
||||||
export class WebSocketSignaling extends EventTarget {
|
export class WebSocketSignaling extends EventTarget {
|
||||||
|
|
||||||
constructor(interval = 1000) {
|
constructor(interval = 1000, websocketUrl = null) {
|
||||||
super();
|
super();
|
||||||
this.interval = interval;
|
this.interval = interval;
|
||||||
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
||||||
|
|
||||||
let websocketUrl;
|
if (!websocketUrl) {
|
||||||
if (location.protocol === "https:") {
|
if (location.protocol === "https:") {
|
||||||
websocketUrl = "wss://" + location.host;
|
websocketUrl = "wss://" + location.host;
|
||||||
} else {
|
} else {
|
||||||
websocketUrl = "ws://" + location.host;
|
websocketUrl = "ws://" + location.host;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.websocket = new WebSocket(websocketUrl);
|
this.websocket = new WebSocket(websocketUrl);
|
||||||
@@ -199,18 +229,7 @@ export class WebSocketSignaling extends EventTarget {
|
|||||||
this.dispatchEvent(new CustomEvent('candidate', { detail: { connectionId: msg.from, candidate: msg.data.candidate, sdpMLineIndex: msg.data.sdpMLineIndex, sdpMid: msg.data.sdpMid, participantId: msg.participantId } }));
|
this.dispatchEvent(new CustomEvent('candidate', { detail: { connectionId: msg.from, candidate: msg.data.candidate, sdpMLineIndex: msg.data.sdpMLineIndex, sdpMid: msg.data.sdpMid, participantId: msg.participantId } }));
|
||||||
break;
|
break;
|
||||||
case "on-message":
|
case "on-message":
|
||||||
{
|
dispatchOnMessageEvent(this, msg.data, msg.participantId);
|
||||||
let parsed = msg.data;
|
|
||||||
if (typeof msg.data === 'string') {
|
|
||||||
try { parsed = JSON.parse(msg.data); } catch(e) {
|
|
||||||
Logger.error(`Signaling: on-message, error: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (msg.participantId) {
|
|
||||||
parsed.participantId = msg.participantId;
|
|
||||||
}
|
|
||||||
this.dispatchEvent(new CustomEvent('on-message', { detail: parsed }));
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case "participant-left":
|
case "participant-left":
|
||||||
this.dispatchEvent(new CustomEvent('participant-left', { detail: msg }));
|
this.dispatchEvent(new CustomEvent('participant-left', { detail: msg }));
|
||||||
@@ -233,6 +252,24 @@ export class WebSocketSignaling extends EventTarget {
|
|||||||
case "invite-failed":
|
case "invite-failed":
|
||||||
this.dispatchEvent(new CustomEvent('invite-failed', { detail: msg.data }));
|
this.dispatchEvent(new CustomEvent('invite-failed', { detail: msg.data }));
|
||||||
break;
|
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:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -324,4 +361,25 @@ export class WebSocketSignaling extends EventTarget {
|
|||||||
Logger.log(sendJson);
|
Logger.log(sendJson);
|
||||||
this.websocket.send(sendJson);
|
this.websocket.send(sendJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendRecordingOffer(payload) {
|
||||||
|
this.sendMessage(payload.connectionId || '', {
|
||||||
|
type: 'recording-offer',
|
||||||
|
data: payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRecordingCandidate(payload) {
|
||||||
|
this.sendMessage(payload.connectionId || '', {
|
||||||
|
type: 'recording-candidate',
|
||||||
|
data: payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRecordingStatus(payload) {
|
||||||
|
this.sendMessage(payload.connectionId || '', {
|
||||||
|
type: 'recording-status',
|
||||||
|
data: payload
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { jest } from '@jest/globals';
|
||||||
import { MeetingRecorder } from '../../public/call/media/meeting-recorder.js';
|
import { MeetingRecorder } from '../../public/call/media/meeting-recorder.js';
|
||||||
|
|
||||||
class MediaStreamMock {
|
class MediaStreamMock {
|
||||||
@@ -107,7 +108,7 @@ describe('MeetingRecorder', () => {
|
|||||||
const result = await recorder.stop();
|
const result = await recorder.stop();
|
||||||
|
|
||||||
expect(result.filename).toContain('meeting-recording-123-456-789');
|
expect(result.filename).toContain('meeting-recording-123-456-789');
|
||||||
expect(result.mimeType).toBe('video/mp4;codecs=avc1.42E01E,mp4a.40.2');
|
expect(result.mimeType.toLowerCase()).toBe('video/mp4;codecs=avc1.42e01e,mp4a.40.2');
|
||||||
expect(result.filename).toMatch(/\.mp4$/);
|
expect(result.filename).toMatch(/\.mp4$/);
|
||||||
expect(windowRef.URL.createObjectURL).not.toHaveBeenCalled();
|
expect(windowRef.URL.createObjectURL).not.toHaveBeenCalled();
|
||||||
expect(recorder.isRecording()).toBe(false);
|
expect(recorder.isRecording()).toBe(false);
|
||||||
|
|||||||
88
client/test/unit/rendering-safety.test.js
Normal file
88
client/test/unit/rendering-safety.test.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { createMessageElement } from '../../public/call/chat/renderer-chat.js';
|
||||||
|
import { createParticipantTile, getParticipantTile } from '../../public/call/participants/renderer-participant-grid.js';
|
||||||
|
import { createUserEntryElement } from '../../public/call/renderers/renderer-ui.js';
|
||||||
|
import { renderOnlineUsers } from '../../public/call/signaling/connect-directory.js';
|
||||||
|
|
||||||
|
const formatTimestamp = value => value;
|
||||||
|
const unsafeText = '<img src=x onerror=alert(1)>Alice';
|
||||||
|
|
||||||
|
function mediaState(overrides = {}) {
|
||||||
|
return {
|
||||||
|
audio: true,
|
||||||
|
video: true,
|
||||||
|
isSpeaking: false,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('safe dynamic rendering', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders chat text as text, not markup', () => {
|
||||||
|
const element = createMessageElement({
|
||||||
|
id: 'msg-1',
|
||||||
|
type: 'text',
|
||||||
|
isSelf: false,
|
||||||
|
senderName: unsafeText,
|
||||||
|
senderAvatar: '/images/p1.png',
|
||||||
|
content: unsafeText,
|
||||||
|
timestamp: 'now'
|
||||||
|
}, formatTimestamp);
|
||||||
|
|
||||||
|
expect(element.querySelector('.message-text').textContent).toBe(unsafeText);
|
||||||
|
expect(element.querySelector('.message-content img')).toBeNull();
|
||||||
|
expect(element.querySelector('.message-sender').textContent).toBe(unsafeText);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders participant names safely and finds ids without selector injection', () => {
|
||||||
|
const participantId = 'room"] [data-bad="1';
|
||||||
|
const tile = createParticipantTile(participantId, unsafeText);
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.appendChild(tile);
|
||||||
|
|
||||||
|
expect(tile.querySelector('.absolute.bottom-3 span').textContent).toBe(unsafeText);
|
||||||
|
expect(tile.querySelector('.absolute.bottom-3 img')).toBeNull();
|
||||||
|
expect(getParticipantTile(grid, participantId)).toBe(tile);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders user list entries without interpreting user profile fields as HTML', () => {
|
||||||
|
const entry = createUserEntryElement({
|
||||||
|
role: 'participant',
|
||||||
|
id: 'participant-1',
|
||||||
|
user: {
|
||||||
|
name: unsafeText,
|
||||||
|
avatar: '/images/p2.png',
|
||||||
|
mediaState: mediaState({ audio: false })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entry.textContent).toContain(unsafeText);
|
||||||
|
expect(entry.querySelectorAll('img')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders online users without injecting markup from directory data', () => {
|
||||||
|
const onlineUsersList = document.createElement('div');
|
||||||
|
const usersContainer = document.createElement('div');
|
||||||
|
const onlineUsersSummary = document.createElement('div');
|
||||||
|
|
||||||
|
renderOnlineUsers({
|
||||||
|
users: [{
|
||||||
|
name: unsafeText,
|
||||||
|
userId: unsafeText,
|
||||||
|
avatar: '/images/p1.png',
|
||||||
|
role: 'participant',
|
||||||
|
connectionId: 'room-1'
|
||||||
|
}],
|
||||||
|
currentUserId: 'other-user',
|
||||||
|
onlineUsersList,
|
||||||
|
usersContainer,
|
||||||
|
onlineUsersSummary
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(usersContainer.textContent).toContain(unsafeText);
|
||||||
|
expect(usersContainer.querySelector('button')).toBeNull();
|
||||||
|
expect(usersContainer.querySelectorAll('img')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
client/test/unit/server-recording-peer.test.js
Normal file
108
client/test/unit/server-recording-peer.test.js
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,132 @@
|
|||||||
import { jest } from '@jest/globals';
|
import { jest } from '@jest/globals';
|
||||||
|
import fs from 'fs';
|
||||||
import * as Path from 'path';
|
import * as Path from 'path';
|
||||||
|
import process from 'process';
|
||||||
import { setup, teardown } from 'jest-dev-server';
|
import { setup, teardown } from 'jest-dev-server';
|
||||||
import { Signaling, WebSocketSignaling } from "../../src/core/signaling.js";
|
import { Signaling, WebSocketSignaling } from "../../src/core/signaling.js";
|
||||||
import { MockSignaling, reset } from "../mocks/mocksignaling.js";
|
import { MockSignaling, reset } from "../mocks/mocksignaling.js";
|
||||||
import { waitFor, sleep, serverExeName } from "../helpers/testutils.js";
|
import { waitFor, sleep, serverExeName } from "../helpers/testutils.js";
|
||||||
|
|
||||||
const portNumber = 8081;
|
const portNumber = 8081;
|
||||||
|
const runSignalingIntegration = process.env.RUN_SIGNALING_INTEGRATION === '1';
|
||||||
|
const signalingModes = runSignalingIntegration
|
||||||
|
? [{ mode: "mock" }, { mode: "http" }, { mode: "websocket" }]
|
||||||
|
: [{ mode: "mock" }];
|
||||||
|
|
||||||
jest.setTimeout(10000);
|
jest.setTimeout(10000);
|
||||||
|
|
||||||
describe.each([
|
function buildServerCommand(args = '') {
|
||||||
{ mode: "mock" },
|
const binaryPath = Path.resolve(`../bin~/${serverExeName()}`);
|
||||||
{ mode: "http" },
|
const buildEntryPath = Path.resolve('../build/index.js');
|
||||||
{ mode: "websocket" },
|
const serverCommand = fs.existsSync(binaryPath)
|
||||||
])('signaling test in public mode', ({ mode }) => {
|
? `"${binaryPath}"`
|
||||||
|
: `"${process.execPath}" "${buildEntryPath}"`;
|
||||||
|
|
||||||
|
return `${serverCommand} ${args}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPortForMode(mode, isPrivate) {
|
||||||
|
if (mode === 'mock') {
|
||||||
|
return portNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicPorts = { http: portNumber + 1, websocket: portNumber + 2 };
|
||||||
|
const privatePorts = { http: portNumber + 3, websocket: portNumber + 4 };
|
||||||
|
return (isPrivate ? privatePorts : publicPorts)[mode];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHttpSignaling(port) {
|
||||||
|
return new Signaling(1, `http://localhost:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWebSocketSignaling(port) {
|
||||||
|
return new WebSocketSignaling(1, `ws://localhost:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('recording signaling message envelope', () => {
|
||||||
|
const OriginalWebSocket = window.WebSocket;
|
||||||
|
let sentMessages;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sentMessages = [];
|
||||||
|
window.WebSocket = class {
|
||||||
|
constructor() {
|
||||||
|
this.readyState = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message) {
|
||||||
|
sentMessages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.onclose) {
|
||||||
|
this.onclose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.WebSocket = OriginalWebSocket;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sends recording offer through on-message', () => {
|
||||||
|
const signaling = new WebSocketSignaling(1, 'ws://localhost:1234');
|
||||||
|
|
||||||
|
signaling.sendRecordingOffer({
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
connectionId: 'room-1',
|
||||||
|
participantId: 'participant-1',
|
||||||
|
sdp: 'offer-sdp'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sentMessages).toHaveLength(1);
|
||||||
|
const outer = JSON.parse(sentMessages[0]);
|
||||||
|
expect(outer.type).toBe('on-message');
|
||||||
|
expect(outer.data.connectionId).toBe('room-1');
|
||||||
|
expect(outer.data.message).toEqual({
|
||||||
|
type: 'recording-offer',
|
||||||
|
data: {
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
connectionId: 'room-1',
|
||||||
|
participantId: 'participant-1',
|
||||||
|
sdp: 'offer-sdp'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispatches wrapped recording messages as recording events', () => {
|
||||||
|
const signaling = new WebSocketSignaling(1, 'ws://localhost:1234');
|
||||||
|
let recordingAnswer;
|
||||||
|
signaling.addEventListener('recording-answer', (event) => {
|
||||||
|
recordingAnswer = event.detail;
|
||||||
|
});
|
||||||
|
|
||||||
|
signaling.websocket.onmessage({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: 'on-message',
|
||||||
|
from: 'room-1',
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: 'recording-answer',
|
||||||
|
data: {
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
connectionId: 'room-1',
|
||||||
|
sdp: 'answer-sdp'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordingAnswer).toEqual({
|
||||||
|
type: 'recording-answer',
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
connectionId: 'room-1',
|
||||||
|
sdp: 'answer-sdp'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each(signalingModes)('signaling test in public mode', ({ mode }) => {
|
||||||
let signaling1;
|
let signaling1;
|
||||||
let signaling2;
|
let signaling2;
|
||||||
const connectionId1 = "12345";
|
const connectionId1 = "12345";
|
||||||
@@ -26,22 +140,22 @@ describe.each([
|
|||||||
signaling1 = new MockSignaling(1);
|
signaling1 = new MockSignaling(1);
|
||||||
signaling2 = new MockSignaling(1);
|
signaling2 = new MockSignaling(1);
|
||||||
} else {
|
} else {
|
||||||
const path = Path.resolve(`../bin~/${serverExeName()}`);
|
const serverPort = getPortForMode(mode, false);
|
||||||
let cmd = `${path} -p ${portNumber}`;
|
let cmd = buildServerCommand(`-p ${serverPort}`);
|
||||||
if (mode == "http") {
|
if (mode == "http") {
|
||||||
cmd += " -t http";
|
cmd += " -t http";
|
||||||
}
|
}
|
||||||
|
|
||||||
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' });
|
await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
|
||||||
|
|
||||||
if (mode == "http") {
|
if (mode == "http") {
|
||||||
signaling1 = new Signaling(1);
|
signaling1 = createHttpSignaling(serverPort);
|
||||||
signaling2 = new Signaling(1);
|
signaling2 = createHttpSignaling(serverPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode == "websocket") {
|
if (mode == "websocket") {
|
||||||
signaling1 = new WebSocketSignaling(1);
|
signaling1 = createWebSocketSignaling(serverPort);
|
||||||
signaling2 = new WebSocketSignaling(1);
|
signaling2 = createWebSocketSignaling(serverPort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,8 +164,8 @@ describe.each([
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await signaling1.stop();
|
await signaling1?.stop();
|
||||||
await signaling2.stop();
|
await signaling2?.stop();
|
||||||
signaling1 = null;
|
signaling1 = null;
|
||||||
signaling2 = null;
|
signaling2 = null;
|
||||||
|
|
||||||
@@ -207,11 +321,7 @@ describe.each([
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.each([
|
describe.each(signalingModes)('signaling test in private mode', ({ mode }) => {
|
||||||
{ mode: "mock" },
|
|
||||||
{ mode: "http" },
|
|
||||||
{ mode: "websocket" },
|
|
||||||
])('signaling test in private mode', ({ mode }) => {
|
|
||||||
let signaling1;
|
let signaling1;
|
||||||
let signaling2;
|
let signaling2;
|
||||||
const connectionId = "12345";
|
const connectionId = "12345";
|
||||||
@@ -226,22 +336,22 @@ describe.each([
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = Path.resolve(`../bin~/${serverExeName()}`);
|
const serverPort = getPortForMode(mode, true);
|
||||||
let cmd = `${path} -p ${portNumber} -m private`;
|
let cmd = buildServerCommand(`-p ${serverPort} -m private`);
|
||||||
if (mode == "http") {
|
if (mode == "http") {
|
||||||
cmd += " -t http";
|
cmd += " -t http";
|
||||||
}
|
}
|
||||||
|
|
||||||
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' });
|
await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
|
||||||
|
|
||||||
if (mode == "http") {
|
if (mode == "http") {
|
||||||
signaling1 = new Signaling(1);
|
signaling1 = createHttpSignaling(serverPort);
|
||||||
signaling2 = new Signaling(1);
|
signaling2 = createHttpSignaling(serverPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode == "websocket") {
|
if (mode == "websocket") {
|
||||||
signaling1 = new WebSocketSignaling(1);
|
signaling1 = createWebSocketSignaling(serverPort);
|
||||||
signaling2 = new WebSocketSignaling(1);
|
signaling2 = createWebSocketSignaling(serverPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
await signaling1.start();
|
await signaling1.start();
|
||||||
@@ -249,8 +359,8 @@ describe.each([
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await signaling1.stop();
|
await signaling1?.stop();
|
||||||
await signaling2.stop();
|
await signaling2?.stop();
|
||||||
signaling1 = null;
|
signaling1 = null;
|
||||||
signaling2 = null;
|
signaling2 = null;
|
||||||
|
|
||||||
|
|||||||
88
client/test/unit/store-media-state.test.js
Normal file
88
client/test/unit/store-media-state.test.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
|
const { default: store } = await import('../../public/call/store.js');
|
||||||
|
|
||||||
|
function mediaState(overrides = {}) {
|
||||||
|
return {
|
||||||
|
audio: true,
|
||||||
|
video: true,
|
||||||
|
screenShare: false,
|
||||||
|
recording: false,
|
||||||
|
isSpeaking: false,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetStore() {
|
||||||
|
store.role = 'host';
|
||||||
|
store.selfParticipantId = 'host';
|
||||||
|
store.renderstreaming = {
|
||||||
|
sendMessage: jest.fn()
|
||||||
|
};
|
||||||
|
store.state = {
|
||||||
|
session: {
|
||||||
|
duration: 0,
|
||||||
|
localUser: {
|
||||||
|
id: 'host-user',
|
||||||
|
name: 'Host',
|
||||||
|
avatar: '/images/p1.png',
|
||||||
|
mediaState: mediaState()
|
||||||
|
},
|
||||||
|
remoteUser: {
|
||||||
|
id: 'remote-user',
|
||||||
|
name: 'Remote',
|
||||||
|
avatar: '/images/p2.png',
|
||||||
|
status: 'online',
|
||||||
|
mediaState: mediaState()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
'participant-1': {
|
||||||
|
id: 'participant-user',
|
||||||
|
name: 'Participant',
|
||||||
|
avatar: '/images/p2.png',
|
||||||
|
mediaState: mediaState(),
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
store.notify = jest.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('media-state-changed handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips updates when participant media state is unchanged', () => {
|
||||||
|
store._handleMediaStateChangedMessage({
|
||||||
|
participantId: 'participant-1',
|
||||||
|
data: {
|
||||||
|
userId: 'participant-user',
|
||||||
|
...mediaState()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.notify).not.toHaveBeenCalled();
|
||||||
|
expect(store.renderstreaming.sendMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates and broadcasts when participant media state changes', () => {
|
||||||
|
store._handleMediaStateChangedMessage({
|
||||||
|
participantId: 'participant-1',
|
||||||
|
data: {
|
||||||
|
userId: 'participant-user',
|
||||||
|
...mediaState({ audio: false })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.state.participants['participant-1'].mediaState.audio).toBe(false);
|
||||||
|
expect(store.notify).toHaveBeenCalledWith({
|
||||||
|
type: 'PARTICIPANTS_UPDATE',
|
||||||
|
participants: store.state.participants
|
||||||
|
});
|
||||||
|
expect(store.renderstreaming.sendMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'participants-sync'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
1335
package-lock.json
generated
1335
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
|||||||
"swagger-jsdoc": "^6.2.1",
|
"swagger-jsdoc": "^6.2.1",
|
||||||
"swagger-ui-express": "^4.5.0",
|
"swagger-ui-express": "^4.5.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
|
"werift": "^0.23.0",
|
||||||
"ws": "^8.8.1"
|
"ws": "^8.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Offer from './offer';
|
|||||||
import Answer from './answer';
|
import Answer from './answer';
|
||||||
import Candidate from './candidate';
|
import Candidate from './candidate';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { onGetAllConnectionIds, onGetOnlineUsers as onGetWsOnlineUsers, onGetRooms as onGetWsRooms } from './websockethandler';
|
import { onGetAllConnectionIds, onGetOnlineUsers as onGetWsOnlineUsers, } from './websockethandler';
|
||||||
import { log, LogLevel } from '../log';
|
import { log, LogLevel } from '../log';
|
||||||
/**
|
/**
|
||||||
* 断开连接记录类
|
* 断开连接记录类
|
||||||
@@ -996,57 +996,6 @@ function postCandidate(req: Request, res: Response): void {
|
|||||||
arr.push(candidate);
|
arr.push(candidate);
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /signaling/rooms:
|
|
||||||
* get:
|
|
||||||
* summary: 获取房间和用户信息
|
|
||||||
* description: 获取所有房间的信息,包括房间ID和链接的用户
|
|
||||||
* security:
|
|
||||||
* - sessionAuth: []
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: 成功获取房间和用户信息
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* rooms:
|
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* roomId:
|
|
||||||
* type: string
|
|
||||||
* description: 房间ID
|
|
||||||
* users:
|
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* sessionId:
|
|
||||||
* type: string
|
|
||||||
* description: 会话ID
|
|
||||||
* connected:
|
|
||||||
* type: boolean
|
|
||||||
* description: 连接状态
|
|
||||||
* userCount:
|
|
||||||
* type: number
|
|
||||||
* description: 用户数量
|
|
||||||
* totalRooms:
|
|
||||||
* type: number
|
|
||||||
* description: 总房间数
|
|
||||||
*/
|
|
||||||
function onGetConnections(req: Request, res: Response): void {
|
|
||||||
const connectionId = typeof req.query.connectionId === 'string' ? req.query.connectionId : undefined;
|
|
||||||
const wsRooms = onGetWsRooms(connectionId).map((room) => ({
|
|
||||||
...room,
|
|
||||||
users: room.members
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json({ rooms: wsRooms, totalRooms: wsRooms.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -1160,7 +1109,6 @@ export {
|
|||||||
postOffer, // 处理offer信令消息
|
postOffer, // 处理offer信令消息
|
||||||
postAnswer, // 处理answer信令消息
|
postAnswer, // 处理answer信令消息
|
||||||
postCandidate, // 处理candidate信令消息
|
postCandidate, // 处理candidate信令消息
|
||||||
onGetConnections, // 获取房间和用户信息
|
|
||||||
getAllConnectionIds, // 获取所有连接ID
|
getAllConnectionIds, // 获取所有连接ID
|
||||||
getOnlineUsers // 获取在线WebSocket用户列表
|
getOnlineUsers // 获取在线WebSocket用户列表
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import Offer from './offer';
|
|||||||
import Answer from './answer';
|
import Answer from './answer';
|
||||||
import Candidate from './candidate';
|
import Candidate from './candidate';
|
||||||
import { log, LogLevel } from '../log';
|
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,20 @@ interface RoomSnapshot {
|
|||||||
userCount: number;
|
userCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecordingBroadcastPayload = {
|
||||||
|
type: 'recording-started' | 'recording-stopped' | 'recording-status' | 'recording-peer-request';
|
||||||
|
connectionId: string;
|
||||||
|
recordingId: string;
|
||||||
|
status: string;
|
||||||
|
layout?: string;
|
||||||
|
format?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
stoppedAt?: string;
|
||||||
|
mediaMode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecordingClientMessageType = 'recording-offer' | 'recording-candidate' | 'recording-status';
|
||||||
|
|
||||||
interface StoredRoom {
|
interface StoredRoom {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
@@ -317,6 +336,163 @@ function broadcastToGroup(connectionId: string, senderWs: WebSocket, message: an
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendToEntireGroup(connectionId: string, message: any): boolean {
|
||||||
|
const group = connectionGroup.get(connectionId);
|
||||||
|
if (!group) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeSend(group.host, message);
|
||||||
|
group.participants.forEach(participantWs => {
|
||||||
|
safeSend(participantWs, message);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTypedDataMessage(message: any): any {
|
||||||
|
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
Object.keys(message).forEach((key) => {
|
||||||
|
if (key !== 'type') {
|
||||||
|
data[key] = message[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
type: message.type,
|
||||||
|
data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOnMessageEnvelope(connectionId: string, message: any, participantId?: string): any {
|
||||||
|
const envelope: any = {
|
||||||
|
from: connectionId,
|
||||||
|
to: "",
|
||||||
|
type: "on-message",
|
||||||
|
data: JSON.stringify(toTypedDataMessage(message))
|
||||||
|
};
|
||||||
|
if (participantId) {
|
||||||
|
envelope.participantId = participantId;
|
||||||
|
}
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSendOnMessage(ws: WebSocket, connectionId: string, message: any, participantId?: string): boolean {
|
||||||
|
return safeSend(ws, toOnMessageEnvelope(connectionId, message, participantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendOnMessageToEntireGroup(connectionId: string, message: any): boolean {
|
||||||
|
return sendToEntireGroup(connectionId, toOnMessageEnvelope(connectionId, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecordingClientMessage(message: any): boolean {
|
||||||
|
return message
|
||||||
|
&& typeof message === 'object'
|
||||||
|
&& (message.type === 'recording-offer'
|
||||||
|
|| message.type === 'recording-candidate'
|
||||||
|
|| message.type === 'recording-status');
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapRecordingClientPayload(message: any): any {
|
||||||
|
const payload = message.data && typeof message.data === 'object' ? message.data : message;
|
||||||
|
if (!payload.connectionId && message.connectionId) {
|
||||||
|
payload.connectionId = message.connectionId;
|
||||||
|
}
|
||||||
|
if (!payload.participantId && message.participantId) {
|
||||||
|
payload.participantId = message.participantId;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSdpMediaSections(sdp: string): string[] {
|
||||||
|
return sdp
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => /^m=(audio|video)\s/i.test(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRecordableSdpMedia(sdp: string): boolean {
|
||||||
|
return getSdpMediaSections(sdp).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveRecordingSessions(connectionId: string): RecordingSession[] {
|
||||||
|
return listRecordingSessions(connectionId).filter((session) => session.status === 'recording');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecordingPeersForSocket(ws: WebSocket, connectionId: string): void {
|
||||||
|
const participantId = getParticipantId(ws);
|
||||||
|
if (!participantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveRecordingSessions(connectionId).forEach((session) => {
|
||||||
|
stopRecordingPeer(session.id, participantId).catch((error) => {
|
||||||
|
log(LogLevel.warn, 'Failed to stop participant recording peer:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomMemberToRecordingPerson(member: RoomMemberInfo | undefined, fallbackRole: string): RecordingPerson | undefined {
|
||||||
|
if (!member) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
participantId: member.participantId || '',
|
||||||
|
userId: member.userId || '',
|
||||||
|
id: member.userId || member.participantId || '',
|
||||||
|
name: member.name || member.userId || member.participantId || '',
|
||||||
|
avatar: member.avatar || '',
|
||||||
|
role: member.role || fallbackRole,
|
||||||
|
status: 'online'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordingRoomPeople(connectionId: string): { host?: RecordingPerson; participants: RecordingPerson[] } {
|
||||||
|
const room = rooms.get(connectionId);
|
||||||
|
if (!room) {
|
||||||
|
return { participants: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = Array.from(room.members.values());
|
||||||
|
const hostMember = members.find((member) => member.role === 'host')
|
||||||
|
|| members.find((member) => member.socketId === room.hostSocketId);
|
||||||
|
const host = roomMemberToRecordingPerson(hostMember, 'host');
|
||||||
|
const participants = members
|
||||||
|
.filter((member) => member !== hostMember && member.role === 'participant')
|
||||||
|
.map((member) => roomMemberToRecordingPerson(member, 'participant'))
|
||||||
|
.filter((member) => Boolean(member)) as RecordingPerson[];
|
||||||
|
|
||||||
|
return { host, participants };
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopActiveRecordingSessions(connectionId: string): void {
|
||||||
|
const roomPeople = getRecordingRoomPeople(connectionId);
|
||||||
|
getActiveRecordingSessions(connectionId).forEach((session) => {
|
||||||
|
const stoppedSession = stopRecordingSession(session.id);
|
||||||
|
if (stoppedSession) {
|
||||||
|
broadcastRecordingStopped(stoppedSession);
|
||||||
|
stopRecordingAgent(stoppedSession.id);
|
||||||
|
}
|
||||||
|
stopRecordingPeer(session.id)
|
||||||
|
.then(() => {
|
||||||
|
startRecordingCompositionJob({
|
||||||
|
meetingId: session.connectionId,
|
||||||
|
recordingId: session.id,
|
||||||
|
layout: session.layout,
|
||||||
|
format: session.format,
|
||||||
|
host: roomPeople.host,
|
||||||
|
participants: roomPeople.participants
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
log(LogLevel.warn, 'Failed to stop room recording peers:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除WebSocket连接
|
* 移除WebSocket连接
|
||||||
* @param ws WebSocket连接实例
|
* @param ws WebSocket连接实例
|
||||||
@@ -329,12 +505,14 @@ function remove(ws: WebSocket): void {
|
|||||||
const group = connectionGroup.get(connectionId);
|
const group = connectionGroup.get(connectionId);
|
||||||
if (group) {
|
if (group) {
|
||||||
if (group.host === ws) {
|
if (group.host === ws) {
|
||||||
|
stopActiveRecordingSessions(connectionId);
|
||||||
group.participants.forEach(participantWs => {
|
group.participants.forEach(participantWs => {
|
||||||
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
|
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
|
||||||
});
|
});
|
||||||
rooms.delete(connectionId);
|
rooms.delete(connectionId);
|
||||||
connectionGroup.delete(connectionId);
|
connectionGroup.delete(connectionId);
|
||||||
} else {
|
} else {
|
||||||
|
stopRecordingPeersForSocket(ws, connectionId);
|
||||||
group.participants.delete(ws);
|
group.participants.delete(ws);
|
||||||
removeRoomMember(ws, connectionId);
|
removeRoomMember(ws, connectionId);
|
||||||
// 包含participantId,让host能识别是哪个participant离开
|
// 包含participantId,让host能识别是哪个participant离开
|
||||||
@@ -379,6 +557,7 @@ function onConnect(ws: WebSocket, connectionId: string): void {
|
|||||||
const role = polite ? 'participant' : 'host';
|
const role = polite ? 'participant' : 'host';
|
||||||
saveRoomMember(ws, connectionId);
|
saveRoomMember(ws, connectionId);
|
||||||
safeSend(ws, { type: "connect", connectionId: connectionId, polite: polite, role: role, participantId: participantId });
|
safeSend(ws, { type: "connect", connectionId: connectionId, polite: polite, role: role, participantId: participantId });
|
||||||
|
sendActiveRecordingRequests(ws, connectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -399,6 +578,7 @@ function onDisconnect(ws: WebSocket, connectionId: string): void {
|
|||||||
if (group) {
|
if (group) {
|
||||||
if (group.host === ws) {
|
if (group.host === ws) {
|
||||||
// host断开连接,通知所有participants房间已关闭,并删除连接组
|
// host断开连接,通知所有participants房间已关闭,并删除连接组
|
||||||
|
stopActiveRecordingSessions(connectionId);
|
||||||
group.participants.forEach(participantWs => {
|
group.participants.forEach(participantWs => {
|
||||||
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
|
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
|
||||||
});
|
});
|
||||||
@@ -407,6 +587,7 @@ function onDisconnect(ws: WebSocket, connectionId: string): void {
|
|||||||
log(LogLevel.log, `Host disconnected, room ${connectionId} deleted, notified ${group.participants.size} participants`);
|
log(LogLevel.log, `Host disconnected, room ${connectionId} deleted, notified ${group.participants.size} participants`);
|
||||||
} else {
|
} else {
|
||||||
// participant断开连接,从组中移除并通知host(使用participant-left类型,host不会关闭房间)
|
// participant断开连接,从组中移除并通知host(使用participant-left类型,host不会关闭房间)
|
||||||
|
stopRecordingPeersForSocket(ws, connectionId);
|
||||||
group.participants.delete(ws);
|
group.participants.delete(ws);
|
||||||
removeRoomMember(ws, connectionId);
|
removeRoomMember(ws, connectionId);
|
||||||
safeSend(group.host, { type: "participant-left", connectionId: connectionId, participantId: getParticipantId(ws) });
|
safeSend(group.host, { type: "participant-left", connectionId: connectionId, participantId: getParticipantId(ws) });
|
||||||
@@ -677,6 +858,181 @@ function onBroadcast(ws: WebSocket, message: any): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toRecordingBroadcastPayload(type: RecordingBroadcastPayload['type'], session: RecordingSession): RecordingBroadcastPayload {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
connectionId: session.connectionId,
|
||||||
|
recordingId: session.id,
|
||||||
|
status: session.status,
|
||||||
|
layout: session.layout,
|
||||||
|
format: session.format,
|
||||||
|
startedAt: session.startedAt,
|
||||||
|
stoppedAt: session.stoppedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastRecordingStarted(session: RecordingSession): boolean {
|
||||||
|
return sendOnMessageToEntireGroup(
|
||||||
|
session.connectionId,
|
||||||
|
toRecordingBroadcastPayload('recording-started', session)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastRecordingPeerRequest(session: RecordingSession): boolean {
|
||||||
|
const payload = toRecordingBroadcastPayload('recording-peer-request', session);
|
||||||
|
payload.mediaMode = 'webrtc-sendonly';
|
||||||
|
return sendOnMessageToEntireGroup(session.connectionId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastRecordingStopped(session: RecordingSession): boolean {
|
||||||
|
return sendOnMessageToEntireGroup(
|
||||||
|
session.connectionId,
|
||||||
|
toRecordingBroadcastPayload('recording-stopped', session)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendActiveRecordingRequests(ws: WebSocket, connectionId: string): void {
|
||||||
|
const activeSessions = getActiveRecordingSessions(connectionId);
|
||||||
|
activeSessions.forEach((session) => {
|
||||||
|
safeSendOnMessage(ws, connectionId, toRecordingBroadcastPayload('recording-started', session));
|
||||||
|
safeSendOnMessage(ws, connectionId, {
|
||||||
|
...toRecordingBroadcastPayload('recording-peer-request', session),
|
||||||
|
mediaMode: 'webrtc-sendonly'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRecordingOffer(ws: WebSocket, message: any): Promise<void> {
|
||||||
|
const recordingId = typeof message.recordingId === 'string' ? message.recordingId : '';
|
||||||
|
const connectionId = typeof message.connectionId === 'string' ? message.connectionId : '';
|
||||||
|
const sdp = typeof message.sdp === 'string' ? message.sdp : '';
|
||||||
|
if (!recordingId || !connectionId || !sdp) {
|
||||||
|
safeSendOnMessage(ws, connectionId, { type: 'recording-status', recordingId, connectionId, status: 'invalid-offer' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasRecordableSdpMedia(sdp)) {
|
||||||
|
const participantId = getParticipantId(ws) || message.participantId || 'unknown';
|
||||||
|
log(LogLevel.warn, 'Rejected recording offer without audio/video media sections:', {
|
||||||
|
recordingId,
|
||||||
|
connectionId,
|
||||||
|
participantId,
|
||||||
|
mediaSections: getSdpMediaSections(sdp)
|
||||||
|
});
|
||||||
|
safeSendOnMessage(ws, connectionId, {
|
||||||
|
type: 'recording-status',
|
||||||
|
recordingId,
|
||||||
|
connectionId,
|
||||||
|
status: 'no-media-offer',
|
||||||
|
participantId
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offer = registerRecordingPeerOffer({
|
||||||
|
recordingId,
|
||||||
|
connectionId,
|
||||||
|
sdp,
|
||||||
|
participantId: getParticipantId(ws) || 'unknown'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!offer) {
|
||||||
|
safeSendOnMessage(ws, connectionId, {
|
||||||
|
type: 'recording-status',
|
||||||
|
recordingId,
|
||||||
|
connectionId,
|
||||||
|
status: 'recorder-unavailable',
|
||||||
|
participantId: getParticipantId(ws)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const participantId = getParticipantId(ws) || 'unknown';
|
||||||
|
const role = getSocketRoleInRoom(ws, connectionId);
|
||||||
|
const answerSdp = await acceptRecordingOffer({
|
||||||
|
recordingId,
|
||||||
|
connectionId,
|
||||||
|
sdp,
|
||||||
|
participantId,
|
||||||
|
role,
|
||||||
|
onLocalCandidate: (candidate) => {
|
||||||
|
const json = typeof candidate.toJSON === 'function' ? candidate.toJSON() : candidate;
|
||||||
|
safeSendOnMessage(ws, connectionId, {
|
||||||
|
type: 'recording-candidate',
|
||||||
|
recordingId,
|
||||||
|
connectionId,
|
||||||
|
participantId,
|
||||||
|
candidate: json.candidate,
|
||||||
|
sdpMid: json.sdpMid,
|
||||||
|
sdpMLineIndex: json.sdpMLineIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
safeSendOnMessage(ws, connectionId, {
|
||||||
|
type: 'recording-answer',
|
||||||
|
recordingId,
|
||||||
|
connectionId,
|
||||||
|
participantId,
|
||||||
|
sdp: answerSdp
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log(LogLevel.error, 'Failed to accept recording offer:', error);
|
||||||
|
safeSendOnMessage(ws, connectionId, {
|
||||||
|
type: 'recording-status',
|
||||||
|
recordingId,
|
||||||
|
connectionId,
|
||||||
|
status: 'offer-failed',
|
||||||
|
participantId: getParticipantId(ws)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeSendOnMessage(ws, connectionId, {
|
||||||
|
type: 'recording-status',
|
||||||
|
recordingId,
|
||||||
|
connectionId,
|
||||||
|
status: 'offer-received',
|
||||||
|
participantId: getParticipantId(ws)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRecordingCandidate(ws: WebSocket, message: any): Promise<void> {
|
||||||
|
const recordingId = typeof message.recordingId === 'string' ? message.recordingId : '';
|
||||||
|
const connectionId = typeof message.connectionId === 'string' ? message.connectionId : '';
|
||||||
|
const candidateText = typeof message.candidate === 'string' ? message.candidate : '';
|
||||||
|
if (!recordingId || !connectionId || !candidateText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = registerRecordingPeerCandidate({
|
||||||
|
recordingId,
|
||||||
|
connectionId,
|
||||||
|
candidate: candidateText,
|
||||||
|
participantId: getParticipantId(ws) || message.participantId || 'unknown',
|
||||||
|
sdpMid: typeof message.sdpMid === 'string' ? message.sdpMid : undefined,
|
||||||
|
sdpMLineIndex: typeof message.sdpMLineIndex === 'number' ? message.sdpMLineIndex : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
safeSendOnMessage(ws, connectionId, { type: 'recording-status', recordingId, connectionId, status: 'candidate-rejected' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addRecordingIceCandidate({
|
||||||
|
recordingId,
|
||||||
|
participantId: candidate.participantId,
|
||||||
|
candidate: candidate.candidate,
|
||||||
|
sdpMid: candidate.sdpMid,
|
||||||
|
sdpMLineIndex: candidate.sdpMLineIndex
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log(LogLevel.warn, 'Failed to add recording ICE candidate:', error);
|
||||||
|
safeSendOnMessage(ws, connectionId, { type: 'recording-status', recordingId, connectionId, status: 'candidate-rejected' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function AddHeartbeat(ws: WebSocket, connectionId: string) {
|
function AddHeartbeat(ws: WebSocket, connectionId: string) {
|
||||||
// 初始化心跳检测
|
// 初始化心跳检测
|
||||||
asAppWebSocket(ws).lastActivity = Date.now();
|
asAppWebSocket(ws).lastActivity = Date.now();
|
||||||
@@ -808,6 +1164,23 @@ function onMessage(ws: WebSocket, message: any): void {
|
|||||||
}
|
}
|
||||||
chatMessage.participantId = senderParticipantId;
|
chatMessage.participantId = senderParticipantId;
|
||||||
chatMessage.connectionId = connectionId;
|
chatMessage.connectionId = connectionId;
|
||||||
|
if (isRecordingClientMessage(chatMessage)) {
|
||||||
|
const recordingPayload = unwrapRecordingClientPayload(chatMessage);
|
||||||
|
switch (chatMessage.type as RecordingClientMessageType) {
|
||||||
|
case 'recording-offer':
|
||||||
|
onRecordingOffer(ws, recordingPayload);
|
||||||
|
break;
|
||||||
|
case 'recording-candidate':
|
||||||
|
onRecordingCandidate(ws, recordingPayload);
|
||||||
|
break;
|
||||||
|
case 'recording-status':
|
||||||
|
log(LogLevel.log, 'Received recording status:', recordingPayload);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (connectionGroup.has(connectionId)) {
|
if (connectionGroup.has(connectionId)) {
|
||||||
const group = connectionGroup.get(connectionId);
|
const group = connectionGroup.get(connectionId);
|
||||||
if (group.host === ws) {
|
if (group.host === ws) {
|
||||||
@@ -833,4 +1206,5 @@ function onMessage(ws: WebSocket, message: any): void {
|
|||||||
*/
|
*/
|
||||||
export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId,
|
export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId,
|
||||||
onBroadcast, onGetAllConnectionIds, onGetOnlineUsers, onGetRooms, AddHeartbeat, RemoveHeartbeat, onMessage, isHost,
|
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
226
src/recording/agent.ts
Normal 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();
|
||||||
|
}
|
||||||
591
src/recording/composer.ts
Normal file
591
src/recording/composer.ts
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import {
|
||||||
|
RecordingPerson,
|
||||||
|
ServerTrackRecordingFile,
|
||||||
|
ServerTrackRecordingTarget,
|
||||||
|
createComposedRecordingTarget,
|
||||||
|
deleteServerTrackRecordingFiles,
|
||||||
|
listServerTrackRecordingFiles,
|
||||||
|
writeComposedRecordingMetadata
|
||||||
|
} from './storage';
|
||||||
|
|
||||||
|
export type RecordingCompositionStatus = 'queued' | 'running' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export type RecordingCompositionJob = {
|
||||||
|
id: string;
|
||||||
|
recordingId: string;
|
||||||
|
meetingId: string;
|
||||||
|
status: RecordingCompositionStatus;
|
||||||
|
layout: string;
|
||||||
|
format: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
failedAt?: string;
|
||||||
|
error?: string;
|
||||||
|
inputFiles: string[];
|
||||||
|
host?: RecordingPerson;
|
||||||
|
participants?: RecordingPerson[];
|
||||||
|
deletedInputFiles?: string[];
|
||||||
|
output?: {
|
||||||
|
meetingId: string;
|
||||||
|
filename: string;
|
||||||
|
filePath: string;
|
||||||
|
metadataPath: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
streamUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type StartCompositionInput = {
|
||||||
|
meetingId: string;
|
||||||
|
recordingId: string;
|
||||||
|
layout?: string;
|
||||||
|
format?: string;
|
||||||
|
host?: RecordingPerson;
|
||||||
|
participants?: RecordingPerson[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompositionInputSets = {
|
||||||
|
videoInputs: ServerTrackRecordingFile[];
|
||||||
|
audioInputs: ServerTrackRecordingFile[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type VideoTimelineSegment = {
|
||||||
|
startMs: number;
|
||||||
|
endMs: number | null;
|
||||||
|
activeInputs: ServerTrackRecordingFile[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const jobs: Map<string, RecordingCompositionJob> = new Map<string, RecordingCompositionJob>();
|
||||||
|
const COMPOSITION_OUTPUT_WIDTH = 2560;
|
||||||
|
const COMPOSITION_OUTPUT_HEIGHT = 1440;
|
||||||
|
const COMPOSITION_OUTPUT_FPS = 60;
|
||||||
|
const COMPOSITION_HOST_HEIGHT = 1080;
|
||||||
|
const COMPOSITION_VIDEO_BITRATE = '16000k';
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOption(value: unknown, fallback: string): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed.slice(0, 40) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFormat(value: unknown): string {
|
||||||
|
return normalizeOption(value, 'webm') === 'mp4' ? 'mp4' : 'webm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFfmpegPath(): string {
|
||||||
|
return process.env.FFMPEG_PATH || 'ffmpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortInputs(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] {
|
||||||
|
return files.slice().sort((a, b) => {
|
||||||
|
const participantCompare = a.participantId.localeCompare(b.participantId);
|
||||||
|
if (participantCompare !== 0) {
|
||||||
|
return participantCompare;
|
||||||
|
}
|
||||||
|
return Date.parse(a.uploadedAt) - Date.parse(b.uploadedAt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputSets(input: StartCompositionInput): CompositionInputSets {
|
||||||
|
const files = listServerTrackRecordingFiles({
|
||||||
|
meetingId: input.meetingId,
|
||||||
|
recordingId: input.recordingId
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
videoInputs: sortInputs(files.filter((file) => file.trackKind === 'video')),
|
||||||
|
audioInputs: sortInputs(files.filter((file) => file.trackKind === 'audio'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHostInput(file: ServerTrackRecordingFile): boolean {
|
||||||
|
if (file.metadata && file.metadata.role === 'host') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstParticipant = file.metadata && Array.isArray(file.metadata.participants)
|
||||||
|
? file.metadata.participants[0]
|
||||||
|
: null;
|
||||||
|
return Boolean(firstParticipant && firstParticipant.role === 'host');
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderVideoInputsForComposition(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] {
|
||||||
|
const hostIndex = files.findIndex(isHostInput);
|
||||||
|
if (hostIndex <= 0) {
|
||||||
|
return files.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
files[hostIndex],
|
||||||
|
...files.slice(0, hostIndex),
|
||||||
|
...files.slice(hostIndex + 1)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInputTimestamp(value: unknown): number | null {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return isFinite(timestamp) ? timestamp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputStartMs(file: ServerTrackRecordingFile): number | null {
|
||||||
|
const metadata = file.metadata || {};
|
||||||
|
return parseInputTimestamp(metadata.recordingStartedAt)
|
||||||
|
|| parseInputTimestamp(file.recordingStartedAt)
|
||||||
|
|| parseInputTimestamp(metadata.startedAt)
|
||||||
|
|| parseInputTimestamp(file.uploadedAt)
|
||||||
|
|| parseInputTimestamp(metadata.uploadedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputEndMs(file: ServerTrackRecordingFile): number | null {
|
||||||
|
const metadata = file.metadata || {};
|
||||||
|
return parseInputTimestamp(metadata.recordingEndedAt)
|
||||||
|
|| parseInputTimestamp(file.recordingEndedAt)
|
||||||
|
|| parseInputTimestamp(metadata.endedAt)
|
||||||
|
|| parseInputTimestamp(metadata.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimelineOriginMs(files: ServerTrackRecordingFile[]): number | null {
|
||||||
|
const starts = files
|
||||||
|
.map(getInputStartMs)
|
||||||
|
.filter((timestamp) => timestamp !== null) as number[];
|
||||||
|
if (starts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(...starts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimelineDurationSeconds(files: ServerTrackRecordingFile[], timelineOriginMs: number | null): number | null {
|
||||||
|
if (timelineOriginMs === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ends = files
|
||||||
|
.map(getInputEndMs)
|
||||||
|
.filter((timestamp) => timestamp !== null) as number[];
|
||||||
|
if (ends.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationSeconds = (Math.max(...ends) - timelineOriginMs) / 1000;
|
||||||
|
return durationSeconds > 0 ? durationSeconds : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimelineEndMs(files: ServerTrackRecordingFile[]): number | null {
|
||||||
|
const ends = files
|
||||||
|
.map(getInputEndMs)
|
||||||
|
.filter((timestamp) => timestamp !== null) as number[];
|
||||||
|
if (ends.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(...ends);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputOffsetSeconds(file: ServerTrackRecordingFile, timelineOriginMs: number | null): number {
|
||||||
|
const startMs = getInputStartMs(file);
|
||||||
|
if (startMs === null || timelineOriginMs === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, (startMs - timelineOriginMs) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(value: number): string {
|
||||||
|
if (value <= 0.001) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toFixed(3).replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDurationBoundVideoFilters(segmentDurationSeconds: number | null): string[] {
|
||||||
|
if (segmentDurationSeconds === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = formatSeconds(segmentDurationSeconds);
|
||||||
|
return [
|
||||||
|
`tpad=stop_mode=clone:stop_duration=${duration}`,
|
||||||
|
`trim=duration=${duration}`,
|
||||||
|
'setpts=PTS-STARTPTS'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBottomTileWidth(index: number, inputCount: number, outputWidth: number): number {
|
||||||
|
const sideCount = inputCount - 1;
|
||||||
|
if (sideCount <= 1) {
|
||||||
|
return outputWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawWidth = Math.floor(outputWidth / sideCount);
|
||||||
|
const tileWidth = rawWidth % 2 === 0 ? rawWidth : rawWidth - 1;
|
||||||
|
return index === sideCount - 1 ? outputWidth - (tileWidth * index) : tileWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHostBottomLayout(inputCount: number, outputWidth: number, hostHeight: number): string {
|
||||||
|
const positions = ['0_0'];
|
||||||
|
const sideCount = inputCount - 1;
|
||||||
|
let x = 0;
|
||||||
|
for (let sideIndex = 0; sideIndex < sideCount; sideIndex += 1) {
|
||||||
|
positions.push(`${x}_${hostHeight}`);
|
||||||
|
x += getBottomTileWidth(sideIndex, inputCount, outputWidth);
|
||||||
|
}
|
||||||
|
return positions.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortActiveVideoInputs(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] {
|
||||||
|
return orderVideoInputsForComposition(files).sort((a, b) => {
|
||||||
|
const aIsHost = isHostInput(a);
|
||||||
|
const bIsHost = isHostInput(b);
|
||||||
|
if (aIsHost !== bIsHost) {
|
||||||
|
return aIsHost ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.participantId.localeCompare(b.participantId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoTimelineSegments(
|
||||||
|
files: ServerTrackRecordingFile[],
|
||||||
|
timelineOriginMs: number | null,
|
||||||
|
timelineEndMs: number | null
|
||||||
|
): VideoTimelineSegment[] {
|
||||||
|
if (timelineOriginMs === null || timelineEndMs === null || timelineEndMs <= timelineOriginMs) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointsByMs: { [timestamp: string]: boolean } = {};
|
||||||
|
pointsByMs[String(timelineOriginMs)] = true;
|
||||||
|
pointsByMs[String(timelineEndMs)] = true;
|
||||||
|
files.forEach((file) => {
|
||||||
|
const startMs = getInputStartMs(file);
|
||||||
|
const endMs = getInputEndMs(file);
|
||||||
|
if (startMs !== null && startMs > timelineOriginMs && startMs < timelineEndMs) {
|
||||||
|
pointsByMs[String(startMs)] = true;
|
||||||
|
}
|
||||||
|
if (endMs !== null && endMs > timelineOriginMs && endMs < timelineEndMs) {
|
||||||
|
pointsByMs[String(endMs)] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const points = Object.keys(pointsByMs)
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const segments: VideoTimelineSegment[] = [];
|
||||||
|
for (let index = 0; index < points.length - 1; index += 1) {
|
||||||
|
const startMs = points[index];
|
||||||
|
const endMs = points[index + 1];
|
||||||
|
if (endMs <= startMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeInputs = sortActiveVideoInputs(files.filter((file) => {
|
||||||
|
const fileStartMs = getInputStartMs(file);
|
||||||
|
const fileEndMs = getInputEndMs(file);
|
||||||
|
const inputStartMs = fileStartMs === null ? timelineOriginMs : fileStartMs;
|
||||||
|
const inputEndMs = fileEndMs === null ? timelineEndMs : fileEndMs;
|
||||||
|
return inputStartMs < endMs && inputEndMs > startMs;
|
||||||
|
}));
|
||||||
|
segments.push({ startMs, endMs, activeInputs });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallbackVideoTimelineSegment(
|
||||||
|
files: ServerTrackRecordingFile[],
|
||||||
|
timelineOriginMs: number | null
|
||||||
|
): VideoTimelineSegment {
|
||||||
|
return {
|
||||||
|
startMs: timelineOriginMs === null ? 0 : timelineOriginMs,
|
||||||
|
endMs: null,
|
||||||
|
activeInputs: sortActiveVideoInputs(files)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFfmpegCompositionArgs(input: {
|
||||||
|
videoInputs: ServerTrackRecordingFile[];
|
||||||
|
audioInputs: ServerTrackRecordingFile[];
|
||||||
|
outputPath: string;
|
||||||
|
format: string;
|
||||||
|
}): string[] {
|
||||||
|
const outputWidth = COMPOSITION_OUTPUT_WIDTH;
|
||||||
|
const outputHeight = COMPOSITION_OUTPUT_HEIGHT;
|
||||||
|
const outputFps = COMPOSITION_OUTPUT_FPS;
|
||||||
|
const hostHeight = COMPOSITION_HOST_HEIGHT;
|
||||||
|
const bottomHeight = outputHeight - hostHeight;
|
||||||
|
const videoInputs = orderVideoInputsForComposition(input.videoInputs);
|
||||||
|
const timelineOriginMs = getTimelineOriginMs(videoInputs.concat(input.audioInputs));
|
||||||
|
const timelineEndMs = getTimelineEndMs(videoInputs.concat(input.audioInputs));
|
||||||
|
const timelineDurationSeconds = getTimelineDurationSeconds(videoInputs.concat(input.audioInputs), timelineOriginMs);
|
||||||
|
const timelineVideoSegments = getVideoTimelineSegments(videoInputs, timelineOriginMs, timelineEndMs);
|
||||||
|
const videoSegments = timelineVideoSegments.length > 0
|
||||||
|
? timelineVideoSegments
|
||||||
|
: [getFallbackVideoTimelineSegment(videoInputs, timelineOriginMs)];
|
||||||
|
const args = ['-y'];
|
||||||
|
const orderedInputs = videoInputs.concat(input.audioInputs);
|
||||||
|
orderedInputs.forEach((file) => {
|
||||||
|
args.push('-i', file.filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filters: string[] = [];
|
||||||
|
const videoInputUseCounts = videoInputs.map((file) => videoSegments.filter((segment) => segment.activeInputs.indexOf(file) >= 0).length);
|
||||||
|
const videoInputUsePositions = videoInputs.map(() => 0);
|
||||||
|
videoInputUseCounts.forEach((useCount, inputIndex) => {
|
||||||
|
if (useCount <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitLabels = [];
|
||||||
|
for (let splitIndex = 0; splitIndex < useCount; splitIndex += 1) {
|
||||||
|
splitLabels.push(`[vin${inputIndex}_${splitIndex}]`);
|
||||||
|
}
|
||||||
|
filters.push(`[${inputIndex}:v]split=${useCount}${splitLabels.join('')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
videoSegments.forEach((segment, segmentIndex) => {
|
||||||
|
const segmentDurationSeconds = segment.endMs === null ? null : (segment.endMs - segment.startMs) / 1000;
|
||||||
|
if (segment.activeInputs.length === 0) {
|
||||||
|
if (segmentDurationSeconds === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filters.push(`color=color=black:size=${outputWidth}x${outputHeight}:rate=${outputFps}:duration=${formatSeconds(segmentDurationSeconds)},format=yuv420p[seg${segmentIndex}]`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
segment.activeInputs.forEach((file, activeIndex) => {
|
||||||
|
const inputIndex = videoInputs.indexOf(file);
|
||||||
|
const inputLabel = videoInputUseCounts[inputIndex] > 1
|
||||||
|
? `vin${inputIndex}_${videoInputUsePositions[inputIndex]++}`
|
||||||
|
: `${inputIndex}:v`;
|
||||||
|
const fileStartMs = getInputStartMs(file);
|
||||||
|
const inputStartMs = fileStartMs === null ? segment.startMs : fileStartMs;
|
||||||
|
const trimStartSeconds = Math.max(0, (segment.startMs - inputStartMs) / 1000);
|
||||||
|
const width = segment.activeInputs.length === 1
|
||||||
|
? outputWidth
|
||||||
|
: activeIndex === 0 ? outputWidth : getBottomTileWidth(activeIndex - 1, segment.activeInputs.length, outputWidth);
|
||||||
|
const height = segment.activeInputs.length === 1
|
||||||
|
? outputHeight
|
||||||
|
: activeIndex === 0 ? hostHeight : bottomHeight;
|
||||||
|
const trimOptions = [`start=${formatSeconds(trimStartSeconds)}`];
|
||||||
|
if (segmentDurationSeconds !== null) {
|
||||||
|
trimOptions.push(`duration=${formatSeconds(segmentDurationSeconds)}`);
|
||||||
|
}
|
||||||
|
const videoFilters = [
|
||||||
|
`trim=${trimOptions.join(':')}`,
|
||||||
|
'setpts=PTS-STARTPTS',
|
||||||
|
...getDurationBoundVideoFilters(segmentDurationSeconds),
|
||||||
|
`scale=${width}:${height}:force_original_aspect_ratio=decrease`,
|
||||||
|
`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`,
|
||||||
|
'setsar=1'
|
||||||
|
];
|
||||||
|
filters.push(`[${inputLabel}]${videoFilters.join(',')}[seg${segmentIndex}v${activeIndex}]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (segment.activeInputs.length === 1) {
|
||||||
|
filters.push(`[seg${segmentIndex}v0]fps=${outputFps},format=yuv420p[seg${segmentIndex}]`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentVideoLabels = segment.activeInputs.map((_file, activeIndex) => `[seg${segmentIndex}v${activeIndex}]`).join('');
|
||||||
|
filters.push(`${segmentVideoLabels}xstack=inputs=${segment.activeInputs.length}:layout=${createHostBottomLayout(segment.activeInputs.length, outputWidth, hostHeight)}:fill=black,fps=${outputFps},format=yuv420p[seg${segmentIndex}]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videoSegments.length === 1) {
|
||||||
|
filters.push('[seg0]null[vout]');
|
||||||
|
} else {
|
||||||
|
const videoLabels = videoSegments.map((_segment, index) => `[seg${index}]`).join('');
|
||||||
|
filters.push(`${videoLabels}concat=n=${videoSegments.length}:v=1:a=0[vout]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.audioInputs.length === 1) {
|
||||||
|
const audioInputIndex = videoInputs.length;
|
||||||
|
const offsetMs = Math.round(getInputOffsetSeconds(input.audioInputs[0], timelineOriginMs) * 1000);
|
||||||
|
const offsetFilter = offsetMs > 1 ? `,adelay=${offsetMs}:all=1` : '';
|
||||||
|
filters.push(`[${audioInputIndex}:a]aresample=async=1:first_pts=0${offsetFilter},asetpts=N/SR/TB[aout]`);
|
||||||
|
} else if (input.audioInputs.length > 1) {
|
||||||
|
const audioLabels = input.audioInputs.map((file, index) => {
|
||||||
|
const audioInputIndex = videoInputs.length + index;
|
||||||
|
const offsetMs = Math.round(getInputOffsetSeconds(file, timelineOriginMs) * 1000);
|
||||||
|
const offsetFilter = offsetMs > 1 ? `,adelay=${offsetMs}:all=1` : '';
|
||||||
|
filters.push(`[${audioInputIndex}:a]aresample=async=1:first_pts=0${offsetFilter}[a${index}]`);
|
||||||
|
return `[a${index}]`;
|
||||||
|
}).join('');
|
||||||
|
filters.push(`${audioLabels}amix=inputs=${input.audioInputs.length}:duration=longest:dropout_transition=2,asetpts=N/SR/TB[aout]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push('-filter_complex', filters.join(';'), '-map', '[vout]');
|
||||||
|
if (input.audioInputs.length > 0) {
|
||||||
|
args.push('-map', '[aout]');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.format === 'mp4') {
|
||||||
|
args.push('-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-b:v', COMPOSITION_VIDEO_BITRATE, '-r', String(outputFps));
|
||||||
|
if (input.audioInputs.length > 0) {
|
||||||
|
args.push('-c:a', 'aac');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args.push('-c:v', 'libvpx-vp9', '-deadline', 'good', '-cpu-used', '4', '-b:v', COMPOSITION_VIDEO_BITRATE, '-r', String(outputFps));
|
||||||
|
if (input.audioInputs.length > 0) {
|
||||||
|
args.push('-c:a', 'libopus');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timelineDurationSeconds !== null) {
|
||||||
|
args.push('-t', formatSeconds(timelineDurationSeconds));
|
||||||
|
}
|
||||||
|
args.push(input.outputPath);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFfmpeg(args: string[]): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(getFfmpegPath(), args, { windowsHide: true });
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on('error', (error: any) => {
|
||||||
|
if (error && error.code === 'ENOENT') {
|
||||||
|
reject(new Error('ffmpeg was not found. Install ffmpeg or set FFMPEG_PATH.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr || `ffmpeg exited with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOutput(job: RecordingCompositionJob, target: ServerTrackRecordingTarget): RecordingCompositionJob['output'] {
|
||||||
|
return {
|
||||||
|
meetingId: target.meetingId,
|
||||||
|
filename: target.filename,
|
||||||
|
filePath: target.filePath,
|
||||||
|
metadataPath: target.metadataPath,
|
||||||
|
downloadUrl: `/api/recordings/${encodeURIComponent(target.meetingId)}/${encodeURIComponent(target.filename)}/download`,
|
||||||
|
streamUrl: `/api/recordings/${encodeURIComponent(target.meetingId)}/${encodeURIComponent(target.filename)}/stream`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveCompositionJob(input: StartCompositionInput): RecordingCompositionJob | null {
|
||||||
|
const recordingId = normalizeOption(input.recordingId, '');
|
||||||
|
const meetingId = normalizeOption(input.meetingId, '');
|
||||||
|
return Array.from(jobs.values()).find((job) => {
|
||||||
|
return job.recordingId === recordingId
|
||||||
|
&& job.meetingId === meetingId
|
||||||
|
&& (job.status === 'queued' || job.status === 'running');
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise<RecordingCompositionJob> {
|
||||||
|
const timestamp = nowIso();
|
||||||
|
job.status = 'running';
|
||||||
|
job.startedAt = timestamp;
|
||||||
|
job.updatedAt = timestamp;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inputSets = getInputSets(job);
|
||||||
|
if (inputSets.videoInputs.length === 0) {
|
||||||
|
throw new Error('No server-side video track files are available for composition.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = createComposedRecordingTarget({
|
||||||
|
meetingId: job.meetingId,
|
||||||
|
recordingId: job.recordingId,
|
||||||
|
format: job.format
|
||||||
|
});
|
||||||
|
const compositionInputs = inputSets.videoInputs.concat(inputSets.audioInputs);
|
||||||
|
const args = buildFfmpegCompositionArgs({
|
||||||
|
...inputSets,
|
||||||
|
outputPath: target.filePath,
|
||||||
|
format: job.format
|
||||||
|
});
|
||||||
|
await runFfmpeg(args);
|
||||||
|
writeComposedRecordingMetadata({
|
||||||
|
target,
|
||||||
|
recordingId: job.recordingId,
|
||||||
|
inputs: compositionInputs,
|
||||||
|
layout: job.layout,
|
||||||
|
format: job.format,
|
||||||
|
host: job.host,
|
||||||
|
participants: job.participants
|
||||||
|
});
|
||||||
|
const deletedInputFiles = deleteServerTrackRecordingFiles(compositionInputs);
|
||||||
|
|
||||||
|
const completedAt = nowIso();
|
||||||
|
job.status = 'completed';
|
||||||
|
job.completedAt = completedAt;
|
||||||
|
job.updatedAt = completedAt;
|
||||||
|
job.inputFiles = compositionInputs.map((file) => file.filename);
|
||||||
|
job.deletedInputFiles = deletedInputFiles;
|
||||||
|
job.output = toOutput(job, target);
|
||||||
|
} catch (error) {
|
||||||
|
const failedAt = nowIso();
|
||||||
|
job.status = 'failed';
|
||||||
|
job.failedAt = failedAt;
|
||||||
|
job.updatedAt = failedAt;
|
||||||
|
job.error = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRecordingCompositionJob(input: StartCompositionInput): RecordingCompositionJob {
|
||||||
|
const activeJob = getActiveCompositionJob(input);
|
||||||
|
if (activeJob) {
|
||||||
|
return activeJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = nowIso();
|
||||||
|
const inputSets = getInputSets(input);
|
||||||
|
const job: RecordingCompositionJob = {
|
||||||
|
id: uuid(),
|
||||||
|
recordingId: normalizeOption(input.recordingId, ''),
|
||||||
|
meetingId: normalizeOption(input.meetingId, ''),
|
||||||
|
status: 'queued',
|
||||||
|
layout: normalizeOption(input.layout, 'grid'),
|
||||||
|
format: normalizeFormat(input.format),
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
inputFiles: inputSets.videoInputs.concat(inputSets.audioInputs).map((file) => file.filename),
|
||||||
|
host: input.host,
|
||||||
|
participants: input.participants
|
||||||
|
};
|
||||||
|
jobs.set(job.id, job);
|
||||||
|
runRecordingCompositionJob(job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecordingCompositionJob(jobId: string): RecordingCompositionJob | null {
|
||||||
|
return jobs.get(jobId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRecordingCompositionJobs(meetingId?: string): RecordingCompositionJob[] {
|
||||||
|
const allJobs = Array.from(jobs.values());
|
||||||
|
return meetingId
|
||||||
|
? allJobs.filter((job) => job.meetingId === meetingId)
|
||||||
|
: allJobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetRecordingCompositionJobs(): void {
|
||||||
|
jobs.clear();
|
||||||
|
}
|
||||||
92
src/recording/session-manager.ts
Normal file
92
src/recording/session-manager.ts
Normal 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();
|
||||||
|
}
|
||||||
342
src/recording/storage.ts
Normal file
342
src/recording/storage.ts
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export type ServerTrackRecordingTarget = {
|
||||||
|
meetingId: string;
|
||||||
|
directory: string;
|
||||||
|
filename: string;
|
||||||
|
filePath: string;
|
||||||
|
metadataPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServerTrackRecordingFile = ServerTrackRecordingTarget & {
|
||||||
|
recordingId: string;
|
||||||
|
participantId: string;
|
||||||
|
trackId: string;
|
||||||
|
trackKind: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
recordingStartedAt?: string;
|
||||||
|
recordingEndedAt?: string;
|
||||||
|
metadata: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecordingPerson = {
|
||||||
|
participantId?: string;
|
||||||
|
userId?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
avatar?: string;
|
||||||
|
role?: string;
|
||||||
|
status?: string;
|
||||||
|
mediaState?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateTargetInput = {
|
||||||
|
recordingId: string;
|
||||||
|
connectionId: string;
|
||||||
|
participantId: string;
|
||||||
|
role?: string;
|
||||||
|
kind: string;
|
||||||
|
trackId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateComposedTargetInput = {
|
||||||
|
recordingId: string;
|
||||||
|
meetingId: string;
|
||||||
|
format?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WriteMetadataInput = CreateTargetInput & {
|
||||||
|
target: ServerTrackRecordingTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WriteComposedMetadataInput = {
|
||||||
|
target: ServerTrackRecordingTarget;
|
||||||
|
recordingId: string;
|
||||||
|
inputs: ServerTrackRecordingFile[];
|
||||||
|
layout: string;
|
||||||
|
format: string;
|
||||||
|
host?: RecordingPerson;
|
||||||
|
participants?: RecordingPerson[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRecordingRoot(): string {
|
||||||
|
return path.resolve(process.env.RECORDING_DIR || path.join(process.cwd(), 'recordings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeRecordingPathSegment(value: string | undefined, fallback: string): string {
|
||||||
|
const sanitized = (value || fallback)
|
||||||
|
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||||
|
.replace(/^\.+/, '_')
|
||||||
|
.slice(0, 120);
|
||||||
|
return sanitized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInside(parent: string, child: string): boolean {
|
||||||
|
const relative = path.relative(parent, child);
|
||||||
|
return relative.length === 0 || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampForFilename(): string {
|
||||||
|
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerTrackRecordingTarget(input: CreateTargetInput): ServerTrackRecordingTarget {
|
||||||
|
const recordingRoot = getRecordingRoot();
|
||||||
|
const meetingId = sanitizeRecordingPathSegment(input.connectionId, 'unknown');
|
||||||
|
const recordingId = sanitizeRecordingPathSegment(input.recordingId, 'recording');
|
||||||
|
const participantId = sanitizeRecordingPathSegment(input.participantId, 'participant');
|
||||||
|
const kind = sanitizeRecordingPathSegment(input.kind, 'media');
|
||||||
|
const trackId = sanitizeRecordingPathSegment(input.trackId, 'track');
|
||||||
|
const directory = path.join(recordingRoot, meetingId);
|
||||||
|
const filename = `${timestampForFilename()}-${recordingId}-${participantId}-${kind}-${trackId}.webm`;
|
||||||
|
const filePath = path.join(directory, filename);
|
||||||
|
const metadataPath = path.join(directory, `${filename}.json`);
|
||||||
|
|
||||||
|
if (!isPathInside(recordingRoot, filePath) || !isPathInside(recordingRoot, metadataPath)) {
|
||||||
|
throw new Error('Invalid server recording path');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
|
return { meetingId, directory, filename, filePath, metadataPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createComposedRecordingTarget(input: CreateComposedTargetInput): ServerTrackRecordingTarget {
|
||||||
|
const recordingRoot = getRecordingRoot();
|
||||||
|
const meetingId = sanitizeRecordingPathSegment(input.meetingId, 'unknown');
|
||||||
|
const recordingId = sanitizeRecordingPathSegment(input.recordingId, 'recording');
|
||||||
|
const format = input.format === 'mp4' ? 'mp4' : 'webm';
|
||||||
|
const directory = path.join(recordingRoot, meetingId);
|
||||||
|
const filename = `${timestampForFilename()}-${recordingId}-composed.${format}`;
|
||||||
|
const filePath = path.join(directory, filename);
|
||||||
|
const metadataPath = path.join(directory, `${filename}.json`);
|
||||||
|
|
||||||
|
if (!isPathInside(recordingRoot, filePath) || !isPathInside(recordingRoot, metadataPath)) {
|
||||||
|
throw new Error('Invalid composed recording path');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
|
return { meetingId, directory, filename, filePath, metadataPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeServerTrackRecordingMetadata(input: WriteMetadataInput): void {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const role = input.role === 'host' ? 'host' : 'participant';
|
||||||
|
const metadata = {
|
||||||
|
id: `${input.recordingId}-${input.participantId}-${input.kind}-${input.trackId}`,
|
||||||
|
meetingId: input.target.meetingId,
|
||||||
|
filename: input.target.filename,
|
||||||
|
originalFilename: `server-recording-${input.participantId}-${input.kind}.webm`,
|
||||||
|
mimetype: 'video/webm',
|
||||||
|
size: 0,
|
||||||
|
userId: 'server-recorder',
|
||||||
|
host: {
|
||||||
|
userId: 'server-recorder',
|
||||||
|
id: 'server-recorder',
|
||||||
|
name: 'Server Recorder',
|
||||||
|
role: 'recorder'
|
||||||
|
},
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
participantId: input.participantId,
|
||||||
|
id: input.participantId,
|
||||||
|
role
|
||||||
|
}
|
||||||
|
],
|
||||||
|
uploadedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
recordingStartedAt: now,
|
||||||
|
recordingSource: 'server',
|
||||||
|
recordingId: input.recordingId,
|
||||||
|
participantId: input.participantId,
|
||||||
|
role,
|
||||||
|
trackId: input.trackId,
|
||||||
|
trackKind: input.kind
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(input.target.metadataPath, JSON.stringify(metadata, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateServerTrackRecordingMetadataSize(target: ServerTrackRecordingTarget): void {
|
||||||
|
if (!fs.existsSync(target.metadataPath) || !fs.existsSync(target.filePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = JSON.parse(fs.readFileSync(target.metadataPath, 'utf8'));
|
||||||
|
metadata.size = fs.statSync(target.filePath).size;
|
||||||
|
metadata.updatedAt = new Date().toISOString();
|
||||||
|
metadata.recordingEndedAt = metadata.updatedAt;
|
||||||
|
fs.writeFileSync(target.metadataPath, JSON.stringify(metadata, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listServerTrackRecordingFiles(input: {
|
||||||
|
meetingId: string;
|
||||||
|
recordingId?: string;
|
||||||
|
trackKind?: string;
|
||||||
|
}): ServerTrackRecordingFile[] {
|
||||||
|
const recordingRoot = getRecordingRoot();
|
||||||
|
const meetingId = sanitizeRecordingPathSegment(input.meetingId, 'unknown');
|
||||||
|
const directory = path.join(recordingRoot, meetingId);
|
||||||
|
if (!fs.existsSync(directory)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.readdirSync(directory)
|
||||||
|
.filter((filename) => path.extname(filename).toLowerCase() === '.webm')
|
||||||
|
.map((filename) => {
|
||||||
|
const filePath = path.join(directory, filename);
|
||||||
|
const metadataPath = path.join(directory, `${filename}.json`);
|
||||||
|
if (!fs.statSync(filePath).isFile() || !fs.existsSync(metadataPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
||||||
|
if (metadata.recordingSource !== 'server') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (input.recordingId && metadata.recordingId !== input.recordingId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (input.trackKind && metadata.trackKind !== input.trackKind) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
meetingId,
|
||||||
|
directory,
|
||||||
|
filename,
|
||||||
|
filePath,
|
||||||
|
metadataPath,
|
||||||
|
metadata,
|
||||||
|
recordingId: metadata.recordingId || '',
|
||||||
|
participantId: metadata.participantId || '',
|
||||||
|
trackId: metadata.trackId || '',
|
||||||
|
trackKind: metadata.trackKind || '',
|
||||||
|
uploadedAt: metadata.uploadedAt || fs.statSync(filePath).birthtime.toISOString(),
|
||||||
|
recordingStartedAt: metadata.recordingStartedAt || metadata.uploadedAt,
|
||||||
|
recordingEndedAt: metadata.recordingEndedAt || metadata.updatedAt
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((file) => Boolean(file)) as ServerTrackRecordingFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteServerTrackRecordingFiles(files: ServerTrackRecordingFile[]): string[] {
|
||||||
|
const deletedFiles: string[] = [];
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (fs.existsSync(file.filePath)) {
|
||||||
|
fs.unlinkSync(file.filePath);
|
||||||
|
deletedFiles.push(file.filename);
|
||||||
|
}
|
||||||
|
if (fs.existsSync(file.metadataPath)) {
|
||||||
|
fs.unlinkSync(file.metadataPath);
|
||||||
|
deletedFiles.push(`${file.filename}.json`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return deletedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonKey(person: RecordingPerson | undefined): string {
|
||||||
|
return person?.participantId || person?.userId || person?.id || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRecordingPerson(person: RecordingPerson | undefined, fallbackRole: string): RecordingPerson | undefined {
|
||||||
|
if (!person || typeof person !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
participantId: person.participantId || '',
|
||||||
|
userId: person.userId || person.id || '',
|
||||||
|
id: person.id || person.userId || person.participantId || '',
|
||||||
|
name: person.name || person.userId || person.id || person.participantId || '',
|
||||||
|
avatar: person.avatar || '',
|
||||||
|
role: person.role || fallbackRole,
|
||||||
|
status: person.status || '',
|
||||||
|
mediaState: person.mediaState
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectInputPeople(inputs: ServerTrackRecordingFile[]): {
|
||||||
|
host?: RecordingPerson;
|
||||||
|
participants: RecordingPerson[];
|
||||||
|
} {
|
||||||
|
const participantsByKey: { [key: string]: RecordingPerson } = {};
|
||||||
|
let host: RecordingPerson | undefined;
|
||||||
|
|
||||||
|
inputs.forEach((file) => {
|
||||||
|
const metadata = file.metadata || {};
|
||||||
|
const fileRole = metadata.role === 'host' ? 'host' : 'participant';
|
||||||
|
const metadataHost = normalizeRecordingPerson(metadata.host, 'host');
|
||||||
|
if (metadataHost && metadataHost.role === 'host' && !host) {
|
||||||
|
host = metadataHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
const people = Array.isArray(metadata.participants) ? metadata.participants : [];
|
||||||
|
people.forEach((person: RecordingPerson) => {
|
||||||
|
const normalized = normalizeRecordingPerson(person, person.role || fileRole);
|
||||||
|
const key = getPersonKey(normalized);
|
||||||
|
if (!normalized || !key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (normalized.role === 'host' && !host) {
|
||||||
|
host = { ...normalized, role: 'host' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (normalized.role !== 'host' && !participantsByKey[key]) {
|
||||||
|
participantsByKey[key] = { ...normalized, role: 'participant' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (file.participantId) {
|
||||||
|
const person = normalizeRecordingPerson({
|
||||||
|
participantId: file.participantId,
|
||||||
|
id: file.participantId,
|
||||||
|
role: fileRole
|
||||||
|
}, fileRole);
|
||||||
|
const key = getPersonKey(person);
|
||||||
|
if (person && key) {
|
||||||
|
if (fileRole === 'host' && !host) {
|
||||||
|
host = { ...person, role: 'host' };
|
||||||
|
} else if (fileRole !== 'host' && !participantsByKey[key]) {
|
||||||
|
participantsByKey[key] = { ...person, role: 'participant' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
participants: Object.keys(participantsByKey).map((key) => participantsByKey[key])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeComposedRecordingMetadata(input: WriteComposedMetadataInput): void {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const inputPeople = collectInputPeople(input.inputs);
|
||||||
|
const host = normalizeRecordingPerson(input.host, 'host') || inputPeople.host;
|
||||||
|
const participants = Array.isArray(input.participants) && input.participants.length > 0
|
||||||
|
? input.participants
|
||||||
|
.map((participant) => normalizeRecordingPerson(participant, 'participant'))
|
||||||
|
.filter((participant) => Boolean(participant)) as RecordingPerson[]
|
||||||
|
: inputPeople.participants;
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
id: `${input.recordingId}-composed`,
|
||||||
|
meetingId: input.target.meetingId,
|
||||||
|
filename: input.target.filename,
|
||||||
|
originalFilename: `server-recording-${input.recordingId}-composed.${input.format}`,
|
||||||
|
mimetype: input.format === 'mp4' ? 'video/mp4' : 'video/webm',
|
||||||
|
size: fs.existsSync(input.target.filePath) ? fs.statSync(input.target.filePath).size : 0,
|
||||||
|
userId: host?.userId || host?.id || '',
|
||||||
|
host,
|
||||||
|
participants,
|
||||||
|
uploadedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
recordingSource: 'server-composed',
|
||||||
|
recordingId: input.recordingId,
|
||||||
|
layout: input.layout,
|
||||||
|
inputFiles: input.inputs.map((file) => file.filename)
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(input.target.metadataPath, JSON.stringify(metadata, null, 2));
|
||||||
|
}
|
||||||
292
src/recording/werift-adapter.ts
Normal file
292
src/recording/werift-adapter.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import {
|
||||||
|
incrementRecordingTrackPackets,
|
||||||
|
registerRecordingPeerAnswer,
|
||||||
|
registerRecordingPeerTrack
|
||||||
|
} from './agent';
|
||||||
|
import { log, LogLevel } from '../log';
|
||||||
|
import {
|
||||||
|
ServerTrackRecordingTarget,
|
||||||
|
createServerTrackRecordingTarget,
|
||||||
|
updateServerTrackRecordingMetadataSize,
|
||||||
|
writeServerTrackRecordingMetadata
|
||||||
|
} from './storage';
|
||||||
|
|
||||||
|
type RecordingPeerKey = string;
|
||||||
|
type RecordingTrackRecorderKey = string;
|
||||||
|
type WeriftPeerConnection = any;
|
||||||
|
type WeriftMediaRecorder = any;
|
||||||
|
|
||||||
|
const werift = require('werift');
|
||||||
|
const RTCPeerConnection = werift.RTCPeerConnection;
|
||||||
|
const weriftNonstandard = require('werift/nonstandard');
|
||||||
|
const MediaRecorder = weriftNonstandard.MediaRecorder;
|
||||||
|
const SERVER_RECORDING_WIDTH = 2560;
|
||||||
|
const SERVER_RECORDING_HEIGHT = 1440;
|
||||||
|
const SERVER_RECORDING_JITTER_BUFFER_LATENCY_MS = 1000;
|
||||||
|
const SERVER_RECORDING_JITTER_BUFFER_SIZE = 50000;
|
||||||
|
|
||||||
|
type RecordingPeerState = {
|
||||||
|
pc: WeriftPeerConnection;
|
||||||
|
recordingId: string;
|
||||||
|
connectionId: string;
|
||||||
|
participantId: string;
|
||||||
|
pendingCandidates: Array<{
|
||||||
|
candidate: string;
|
||||||
|
sdpMid?: string;
|
||||||
|
sdpMLineIndex?: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecordingTrackRecorderState = {
|
||||||
|
recorder: WeriftMediaRecorder;
|
||||||
|
target: ServerTrackRecordingTarget;
|
||||||
|
recordingId: string;
|
||||||
|
participantId: string;
|
||||||
|
trackId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AcceptOfferInput = {
|
||||||
|
recordingId: string;
|
||||||
|
connectionId: string;
|
||||||
|
participantId: string;
|
||||||
|
role?: string;
|
||||||
|
sdp: string;
|
||||||
|
onLocalCandidate?: (candidate: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const peers: Map<RecordingPeerKey, RecordingPeerState> = new Map<RecordingPeerKey, RecordingPeerState>();
|
||||||
|
const trackRecorders: Map<RecordingTrackRecorderKey, RecordingTrackRecorderState> = new Map<RecordingTrackRecorderKey, RecordingTrackRecorderState>();
|
||||||
|
|
||||||
|
function peerKey(recordingId: string, participantId: string): RecordingPeerKey {
|
||||||
|
return `${recordingId}:${participantId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackRecorderKey(recordingId: string, participantId: string, trackId: string): RecordingTrackRecorderKey {
|
||||||
|
return `${recordingId}:${participantId}:${trackId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPeer(recordingId: string, participantId: string): RecordingPeerState | null {
|
||||||
|
return peers.get(peerKey(recordingId, participantId)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPeer(input: AcceptOfferInput): RecordingPeerState {
|
||||||
|
const pc = new RTCPeerConnection({
|
||||||
|
iceUseIpv4: true,
|
||||||
|
iceUseIpv6: false
|
||||||
|
});
|
||||||
|
const state: RecordingPeerState = {
|
||||||
|
pc,
|
||||||
|
recordingId: input.recordingId,
|
||||||
|
connectionId: input.connectionId,
|
||||||
|
participantId: input.participantId,
|
||||||
|
pendingCandidates: []
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (event.candidate && input.onLocalCandidate) {
|
||||||
|
input.onLocalCandidate(event.candidate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.ontrack = (event) => {
|
||||||
|
const trackId = event.track.id || event.track.uuid || `${event.track.kind}-${Date.now()}`;
|
||||||
|
registerRecordingPeerTrack({
|
||||||
|
recordingId: input.recordingId,
|
||||||
|
connectionId: input.connectionId,
|
||||||
|
participantId: input.participantId,
|
||||||
|
kind: event.track.kind,
|
||||||
|
trackId
|
||||||
|
});
|
||||||
|
startTrackRecorder({
|
||||||
|
recordingId: input.recordingId,
|
||||||
|
connectionId: input.connectionId,
|
||||||
|
participantId: input.participantId,
|
||||||
|
role: input.role,
|
||||||
|
kind: event.track.kind,
|
||||||
|
trackId,
|
||||||
|
track: event.track
|
||||||
|
});
|
||||||
|
event.track.onReceiveRtp.subscribe(() => {
|
||||||
|
incrementRecordingTrackPackets({
|
||||||
|
recordingId: input.recordingId,
|
||||||
|
participantId: input.participantId,
|
||||||
|
trackId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
peers.set(peerKey(input.recordingId, input.participantId), state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPendingCandidates(state: RecordingPeerState): Promise<void> {
|
||||||
|
if (!state.pc.remoteDescription || state.pendingCandidates.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCandidates = state.pendingCandidates.splice(0, state.pendingCandidates.length);
|
||||||
|
for (const candidate of pendingCandidates) {
|
||||||
|
await state.pc.addIceCandidate(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTrackRecorder(input: {
|
||||||
|
recordingId: string;
|
||||||
|
connectionId: string;
|
||||||
|
participantId: string;
|
||||||
|
role?: string;
|
||||||
|
kind: string;
|
||||||
|
trackId: string;
|
||||||
|
track: any;
|
||||||
|
}): void {
|
||||||
|
const key = trackRecorderKey(input.recordingId, input.participantId, input.trackId);
|
||||||
|
if (trackRecorders.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = createServerTrackRecordingTarget(input);
|
||||||
|
writeServerTrackRecordingMetadata({ ...input, target });
|
||||||
|
const recorder = new MediaRecorder({
|
||||||
|
path: target.filePath,
|
||||||
|
tracks: [input.track],
|
||||||
|
width: SERVER_RECORDING_WIDTH,
|
||||||
|
height: SERVER_RECORDING_HEIGHT,
|
||||||
|
disableLipSync: true,
|
||||||
|
jitterBuffer: {
|
||||||
|
latency: SERVER_RECORDING_JITTER_BUFFER_LATENCY_MS,
|
||||||
|
bufferSize: SERVER_RECORDING_JITTER_BUFFER_SIZE
|
||||||
|
},
|
||||||
|
defaultDuration: 24 * 60 * 60
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recorder.onError && typeof recorder.onError.subscribe === 'function') {
|
||||||
|
recorder.onError.subscribe((error: Error) => {
|
||||||
|
log(LogLevel.warn, 'Server recording writer failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
trackRecorders.set(key, {
|
||||||
|
recorder,
|
||||||
|
target,
|
||||||
|
recordingId: input.recordingId,
|
||||||
|
participantId: input.participantId,
|
||||||
|
trackId: input.trackId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log(LogLevel.error, 'Failed to start server track recorder:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopTrackRecorders(recordingId: string, participantId?: string): Promise<void> {
|
||||||
|
const keys = Array.from(trackRecorders.keys()).filter((key) => {
|
||||||
|
if (!recordingId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (participantId) {
|
||||||
|
return key.startsWith(`${recordingId}:${participantId}:`);
|
||||||
|
}
|
||||||
|
return key.startsWith(`${recordingId}:`);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const state = trackRecorders.get(key);
|
||||||
|
if (!state) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await state.recorder.stop();
|
||||||
|
updateServerTrackRecordingMetadataSize(state.target);
|
||||||
|
} catch (error) {
|
||||||
|
log(LogLevel.warn, 'Failed to stop server track recorder:', error);
|
||||||
|
} finally {
|
||||||
|
trackRecorders.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptRecordingOffer(input: AcceptOfferInput): Promise<string> {
|
||||||
|
const existing = getPeer(input.recordingId, input.participantId);
|
||||||
|
if (existing) {
|
||||||
|
await stopTrackRecorders(input.recordingId, input.participantId);
|
||||||
|
await existing.pc.close();
|
||||||
|
peers.delete(peerKey(input.recordingId, input.participantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = createPeer(input);
|
||||||
|
await state.pc.setRemoteDescription({
|
||||||
|
type: 'offer',
|
||||||
|
sdp: input.sdp
|
||||||
|
});
|
||||||
|
await flushPendingCandidates(state);
|
||||||
|
const answer = await state.pc.createAnswer();
|
||||||
|
await state.pc.setLocalDescription(answer);
|
||||||
|
const sdp = state.pc.localDescription ? state.pc.localDescription.sdp : answer.sdp;
|
||||||
|
|
||||||
|
registerRecordingPeerAnswer({
|
||||||
|
recordingId: input.recordingId,
|
||||||
|
connectionId: input.connectionId,
|
||||||
|
participantId: input.participantId,
|
||||||
|
sdp
|
||||||
|
});
|
||||||
|
|
||||||
|
return sdp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addRecordingIceCandidate(input: {
|
||||||
|
recordingId: string;
|
||||||
|
participantId: string;
|
||||||
|
candidate: string;
|
||||||
|
sdpMid?: string;
|
||||||
|
sdpMLineIndex?: number;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const state = getPeer(input.recordingId, input.participantId);
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = {
|
||||||
|
candidate: input.candidate,
|
||||||
|
sdpMid: input.sdpMid,
|
||||||
|
sdpMLineIndex: input.sdpMLineIndex
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!state.pc.remoteDescription) {
|
||||||
|
state.pendingCandidates.push(candidate);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await state.pc.addIceCandidate(candidate);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopRecordingPeer(recordingId: string, participantId?: string): Promise<void> {
|
||||||
|
await stopTrackRecorders(recordingId, participantId);
|
||||||
|
const keys = Array.from(peers.keys()).filter((key) => {
|
||||||
|
if (participantId) {
|
||||||
|
return key === peerKey(recordingId, participantId);
|
||||||
|
}
|
||||||
|
return key.startsWith(`${recordingId}:`);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const state = peers.get(key);
|
||||||
|
if (state) {
|
||||||
|
await state.pc.close();
|
||||||
|
peers.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetRecordingPeers(): Promise<void> {
|
||||||
|
await stopTrackRecorders('');
|
||||||
|
const keys = Array.from(peers.keys());
|
||||||
|
for (const key of keys) {
|
||||||
|
const state = peers.get(key);
|
||||||
|
if (state) {
|
||||||
|
await state.pc.close();
|
||||||
|
}
|
||||||
|
peers.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/server-recording-plan.md
Normal file
131
src/server-recording-plan.md
Normal 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
|
||||||
|
```
|
||||||
192
src/server.ts
192
src/server.ts
@@ -7,6 +7,29 @@ import signaling from './signaling';
|
|||||||
import { log, LogLevel } from './log';
|
import { log, LogLevel } from './log';
|
||||||
import Options from './class/options';
|
import Options from './class/options';
|
||||||
import { reset as resetHandler } from './class/httphandler';
|
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 { initSwagger } from './swagger';
|
||||||
|
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
@@ -139,7 +162,15 @@ function sanitizeMetadataString(value: any, maxLength = 200): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength);
|
return String(value)
|
||||||
|
.split('')
|
||||||
|
.filter((character) => {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
return code >= 32 && code !== 127;
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
.slice(0, maxLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeRecordingPerson(value: any, fallbackRole: string): RecordingPerson | undefined {
|
function sanitizeRecordingPerson(value: any, fallbackRole: string): RecordingPerson | undefined {
|
||||||
@@ -285,6 +316,47 @@ function removeEmptyDirectory(directory: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getActiveRecordingSession(connectionId: string) {
|
||||||
|
const sessions = listRecordingSessions(connectionId);
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (session.status === 'recording') {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomMemberToRecordingPerson(member: any, fallbackRole: string): RecordingPerson | undefined {
|
||||||
|
if (!member || typeof member !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizeRecordingPerson({
|
||||||
|
participantId: member.participantId,
|
||||||
|
userId: member.userId || member.id,
|
||||||
|
id: member.id || member.userId,
|
||||||
|
name: member.name,
|
||||||
|
avatar: member.avatar,
|
||||||
|
role: member.role || fallbackRole,
|
||||||
|
status: member.status,
|
||||||
|
mediaState: member.mediaState
|
||||||
|
}, fallbackRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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[];
|
||||||
|
|
||||||
|
return { host, participants };
|
||||||
|
}
|
||||||
|
|
||||||
export const createServer = (config: Options): express.Express => {
|
export const createServer = (config: Options): express.Express => {
|
||||||
const app: express.Express = express();
|
const app: express.Express = express();
|
||||||
resetHandler(config.mode);
|
resetHandler(config.mode);
|
||||||
@@ -452,6 +524,124 @@ export const createServer = (config: Options): express.Express => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/recording-sessions', (req: express.Request, res: express.Response) => {
|
||||||
|
const connectionId = typeof req.query.connectionId === 'string'
|
||||||
|
? sanitizeMetadataString(req.query.connectionId, 120)
|
||||||
|
: undefined;
|
||||||
|
const sessions = listRecordingSessions(connectionId);
|
||||||
|
res.json({ success: true, sessions, totalCount: sessions.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/recording-sessions/:recordingId', (req: express.Request, res: express.Response) => {
|
||||||
|
const session = getRecordingSession(req.params.recordingId);
|
||||||
|
if (!session) {
|
||||||
|
res.status(404).json({ success: false, message: 'Recording session not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, session, agent: getRecordingAgent(session.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/recording-sessions', (req: express.Request, res: express.Response) => {
|
||||||
|
const connectionId = sanitizeMetadataString(req.body.connectionId, 120);
|
||||||
|
if (!connectionId) {
|
||||||
|
res.status(400).json({ success: false, message: 'connectionId is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'websocket' && getWebSocketRooms(connectionId).length === 0) {
|
||||||
|
res.status(404).json({ success: false, message: 'Active WebSocket room not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSession = getActiveRecordingSession(connectionId);
|
||||||
|
if (activeSession) {
|
||||||
|
res.status(409).json({ success: false, message: 'Recording is already running', session: activeSession });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/recording-sessions/:recordingId', async (req: express.Request, res: express.Response) => {
|
||||||
|
const session = stopRecordingSession(req.params.recordingId);
|
||||||
|
if (!session) {
|
||||||
|
res.status(404).json({ success: false, message: 'Recording session not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notified = broadcastRecordingStopped(session);
|
||||||
|
const agent = stopRecordingAgent(session.id);
|
||||||
|
try {
|
||||||
|
await stopRecordingPeer(session.id);
|
||||||
|
} catch (error) {
|
||||||
|
log(LogLevel.warn, 'Failed to stop recording peer:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldCompose = req.query.compose !== 'false';
|
||||||
|
const roomPeople = getRecordingRoomPeople(session.connectionId);
|
||||||
|
const compositionJob = shouldCompose
|
||||||
|
? startRecordingCompositionJob({
|
||||||
|
meetingId: session.connectionId,
|
||||||
|
recordingId: session.id,
|
||||||
|
layout: session.layout,
|
||||||
|
format: session.format,
|
||||||
|
host: roomPeople.host,
|
||||||
|
participants: roomPeople.participants
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
res.json({ success: true, session, agent, notified, compositionJob });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/recording-compositions', (req: express.Request, res: express.Response) => {
|
||||||
|
const meetingId = typeof req.query.meetingId === 'string'
|
||||||
|
? sanitizePathSegment(req.query.meetingId, 'unknown')
|
||||||
|
: undefined;
|
||||||
|
const jobs = listRecordingCompositionJobs(meetingId);
|
||||||
|
res.json({ success: true, jobs, totalCount: jobs.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/recording-compositions/:compositionId', (req: express.Request, res: express.Response) => {
|
||||||
|
const job = getRecordingCompositionJob(req.params.compositionId);
|
||||||
|
if (!job) {
|
||||||
|
res.status(404).json({ success: false, message: 'Recording composition job not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, job });
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
app.get('/api/recordings', (_req: express.Request, res: express.Response) => {
|
||||||
try {
|
try {
|
||||||
const recordings = listRecordings(recordingRoot);
|
const recordings = listRecordings(recordingRoot);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const router: express.Router = express.Router();
|
|||||||
// 不需要会话ID的路由
|
// 不需要会话ID的路由
|
||||||
router.get('/connection-ids', handler.getAllConnectionIds);
|
router.get('/connection-ids', handler.getAllConnectionIds);
|
||||||
router.get('/users', handler.getOnlineUsers);
|
router.get('/users', handler.getOnlineUsers);
|
||||||
router.get('/rooms', handler.onGetConnections);
|
// router.get('/rooms', handler.onGetConnections);
|
||||||
|
|
||||||
// 需要会话ID的路由
|
// 需要会话ID的路由
|
||||||
router.use(handler.checkSessionId);
|
router.use(handler.checkSessionId);
|
||||||
|
|||||||
114
test/recording-agent.test.ts
Normal file
114
test/recording-agent.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
202
test/recording-composer.test.ts
Normal file
202
test/recording-composer.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { buildFfmpegCompositionArgs } from '../src/recording/composer';
|
||||||
|
import { ServerTrackRecordingFile } from '../src/recording/storage';
|
||||||
|
|
||||||
|
function file(
|
||||||
|
filename: string,
|
||||||
|
trackKind: string,
|
||||||
|
participantId: string,
|
||||||
|
role = 'participant',
|
||||||
|
recordingStartedAt = '2026-06-01T00:00:00.000Z',
|
||||||
|
recordingEndedAt = '2026-06-01T00:00:10.000Z'
|
||||||
|
): ServerTrackRecordingFile {
|
||||||
|
return {
|
||||||
|
meetingId: 'room-1',
|
||||||
|
directory: 'recordings/room-1',
|
||||||
|
filename,
|
||||||
|
filePath: `recordings/room-1/${filename}`,
|
||||||
|
metadataPath: `recordings/room-1/${filename}.json`,
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
participantId,
|
||||||
|
trackId: `${participantId}-${trackKind}`,
|
||||||
|
trackKind,
|
||||||
|
uploadedAt: recordingStartedAt,
|
||||||
|
recordingStartedAt,
|
||||||
|
recordingEndedAt,
|
||||||
|
metadata: { role, recordingStartedAt, recordingEndedAt, updatedAt: recordingEndedAt }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('recording composer', () => {
|
||||||
|
test('builds ffmpeg args for host-led video layout and mixed audio', () => {
|
||||||
|
const args = buildFfmpegCompositionArgs({
|
||||||
|
videoInputs: [
|
||||||
|
file('p1-video.webm', 'video', 'p1', 'host'),
|
||||||
|
file('p2-video.webm', 'video', 'p2')
|
||||||
|
],
|
||||||
|
audioInputs: [
|
||||||
|
file('p1-audio.webm', 'audio', 'p1'),
|
||||||
|
file('p2-audio.webm', 'audio', 'p2')
|
||||||
|
],
|
||||||
|
outputPath: 'recordings/room-1/output.webm',
|
||||||
|
format: 'webm'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(args).toContain('-filter_complex');
|
||||||
|
expect(args.join(' ')).toContain('xstack=inputs=2');
|
||||||
|
expect(args.join(' ')).toContain('scale=2560:1080');
|
||||||
|
expect(args.join(' ')).toContain('scale=2560:360');
|
||||||
|
expect(args.join(' ')).toContain('layout=0_0|0_1080');
|
||||||
|
expect(args.join(' ')).toContain('fps=60');
|
||||||
|
expect(args.join(' ')).toContain('amix=inputs=2');
|
||||||
|
expect(args).toContain('libvpx-vp9');
|
||||||
|
expect(args).toContain('libopus');
|
||||||
|
expect(args).toContain('16000k');
|
||||||
|
expect(args).not.toContain('-shortest');
|
||||||
|
expect(args[args.length - 1]).toBe('recordings/room-1/output.webm');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('places host in the first row even when host input is not first', () => {
|
||||||
|
const args = buildFfmpegCompositionArgs({
|
||||||
|
videoInputs: [
|
||||||
|
file('p1-video.webm', 'video', 'p1'),
|
||||||
|
file('host-video.webm', 'video', 'host', 'host'),
|
||||||
|
file('p2-video.webm', 'video', 'p2')
|
||||||
|
],
|
||||||
|
audioInputs: [],
|
||||||
|
outputPath: 'recordings/room-1/output.webm',
|
||||||
|
format: 'webm'
|
||||||
|
});
|
||||||
|
|
||||||
|
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||||
|
expect(args.slice(0, 7)).toEqual([
|
||||||
|
'-y',
|
||||||
|
'-i',
|
||||||
|
'recordings/room-1/host-video.webm',
|
||||||
|
'-i',
|
||||||
|
'recordings/room-1/p1-video.webm',
|
||||||
|
'-i',
|
||||||
|
'recordings/room-1/p2-video.webm'
|
||||||
|
]);
|
||||||
|
expect(filter).toContain('scale=2560:1080');
|
||||||
|
expect(filter).toContain('scale=1280:360');
|
||||||
|
expect(filter).toContain('layout=0_0|0_1080|1280_1080');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds mp4 encoder args', () => {
|
||||||
|
const args = buildFfmpegCompositionArgs({
|
||||||
|
videoInputs: [file('p1-video.webm', 'video', 'p1')],
|
||||||
|
audioInputs: [],
|
||||||
|
outputPath: 'recordings/room-1/output.mp4',
|
||||||
|
format: 'mp4'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(args).toContain('libx264');
|
||||||
|
expect(args).toContain('-pix_fmt');
|
||||||
|
expect(args).toContain('16000k');
|
||||||
|
expect(args).toContain('60');
|
||||||
|
expect(args).not.toContain('libopus');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to one video segment when input end timestamps are missing', () => {
|
||||||
|
const args = buildFfmpegCompositionArgs({
|
||||||
|
videoInputs: [
|
||||||
|
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', ''),
|
||||||
|
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:02.500Z', '')
|
||||||
|
],
|
||||||
|
audioInputs: [],
|
||||||
|
outputPath: 'recordings/room-1/output.webm',
|
||||||
|
format: 'webm'
|
||||||
|
});
|
||||||
|
|
||||||
|
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||||
|
expect(filter).toContain('xstack=inputs=2');
|
||||||
|
expect(filter).toContain('trim=start=0,setpts=PTS-STARTPTS');
|
||||||
|
expect(filter).not.toContain('concat=n=0');
|
||||||
|
expect(args).not.toContain('-t');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pads late participant tracks to keep the room timeline aligned', () => {
|
||||||
|
const args = buildFfmpegCompositionArgs({
|
||||||
|
videoInputs: [
|
||||||
|
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:10.000Z'),
|
||||||
|
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:02.500Z', '2026-06-01T00:00:10.000Z')
|
||||||
|
],
|
||||||
|
audioInputs: [
|
||||||
|
file('host-audio.webm', 'audio', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:10.000Z'),
|
||||||
|
file('p1-audio.webm', 'audio', 'p1', 'participant', '2026-06-01T00:00:02.500Z', '2026-06-01T00:00:10.000Z')
|
||||||
|
],
|
||||||
|
outputPath: 'recordings/room-1/output.webm',
|
||||||
|
format: 'webm'
|
||||||
|
});
|
||||||
|
|
||||||
|
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||||
|
expect(filter).toContain('[0:v]split=2[vin0_0][vin0_1]');
|
||||||
|
expect(filter).toContain('[vin0_0]trim=start=0:duration=2.5');
|
||||||
|
expect(filter).toContain('tpad=stop_mode=clone:stop_duration=2.5,trim=duration=2.5');
|
||||||
|
expect(filter).toContain('[vin0_1]trim=start=2.5:duration=7.5');
|
||||||
|
expect(filter).toContain('tpad=stop_mode=clone:stop_duration=7.5,trim=duration=7.5');
|
||||||
|
expect(filter).toContain('[1:v]trim=start=0:duration=7.5');
|
||||||
|
expect(filter).toContain('concat=n=2:v=1:a=0[vout]');
|
||||||
|
expect(filter).toContain('[2:a]aresample=async=1:first_pts=0[a0]');
|
||||||
|
expect(filter).toContain('[3:a]aresample=async=1:first_pts=0,adelay=2500:all=1[a1]');
|
||||||
|
expect(filter).toContain('[a0][a1]amix=inputs=2:duration=longest:dropout_transition=2,asetpts=N/SR/TB[aout]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bounds each video segment to its timeline duration before composition', () => {
|
||||||
|
const args = buildFfmpegCompositionArgs({
|
||||||
|
videoInputs: [
|
||||||
|
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:24.000Z')
|
||||||
|
],
|
||||||
|
audioInputs: [
|
||||||
|
file('host-audio.webm', 'audio', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:24.000Z')
|
||||||
|
],
|
||||||
|
outputPath: 'recordings/room-1/output.webm',
|
||||||
|
format: 'webm'
|
||||||
|
});
|
||||||
|
|
||||||
|
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||||
|
expect(filter).toContain('trim=start=0:duration=24,setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=24,trim=duration=24,setpts=PTS-STARTPTS');
|
||||||
|
expect(filter).toContain('[1:a]aresample=async=1:first_pts=0,asetpts=N/SR/TB[aout]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changes the layout when participants join and leave without overlapping', () => {
|
||||||
|
const args = buildFfmpegCompositionArgs({
|
||||||
|
videoInputs: [
|
||||||
|
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:12.000Z'),
|
||||||
|
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:05.000Z'),
|
||||||
|
file('p2-video.webm', 'video', 'p2', 'participant', '2026-06-01T00:00:05.000Z', '2026-06-01T00:00:12.000Z')
|
||||||
|
],
|
||||||
|
audioInputs: [],
|
||||||
|
outputPath: 'recordings/room-1/output.webm',
|
||||||
|
format: 'webm'
|
||||||
|
});
|
||||||
|
|
||||||
|
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||||
|
expect(filter).toContain('xstack=inputs=2');
|
||||||
|
expect(filter).toContain('layout=0_0|0_1080');
|
||||||
|
expect(filter).toContain('[0:v]split=2[vin0_0][vin0_1]');
|
||||||
|
expect(filter).toContain('[vin0_0]trim=start=0:duration=5');
|
||||||
|
expect(filter).toContain('[1:v]trim=start=0:duration=5');
|
||||||
|
expect(filter).toContain('[vin0_1]trim=start=5:duration=7');
|
||||||
|
expect(filter).toContain('[2:v]trim=start=0:duration=7');
|
||||||
|
expect(filter).toContain('concat=n=2:v=1:a=0[vout]');
|
||||||
|
expect(filter).not.toContain('xstack=inputs=3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps separate viewports for participants whose video intervals overlap', () => {
|
||||||
|
const args = buildFfmpegCompositionArgs({
|
||||||
|
videoInputs: [
|
||||||
|
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:12.000Z'),
|
||||||
|
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:08.000Z'),
|
||||||
|
file('p2-video.webm', 'video', 'p2', 'participant', '2026-06-01T00:00:05.000Z', '2026-06-01T00:00:12.000Z')
|
||||||
|
],
|
||||||
|
audioInputs: [],
|
||||||
|
outputPath: 'recordings/room-1/output.webm',
|
||||||
|
format: 'webm'
|
||||||
|
});
|
||||||
|
|
||||||
|
const filter = args[args.indexOf('-filter_complex') + 1];
|
||||||
|
expect(filter).toContain('xstack=inputs=3');
|
||||||
|
expect(filter).toContain('layout=0_0|0_1080|1280_1080');
|
||||||
|
});
|
||||||
|
});
|
||||||
46
test/recording-session-manager.test.ts
Normal file
46
test/recording-session-manager.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
165
test/recording-storage.test.ts
Normal file
165
test/recording-storage.test.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
createComposedRecordingTarget,
|
||||||
|
createServerTrackRecordingTarget,
|
||||||
|
deleteServerTrackRecordingFiles,
|
||||||
|
listServerTrackRecordingFiles,
|
||||||
|
sanitizeRecordingPathSegment,
|
||||||
|
updateServerTrackRecordingMetadataSize,
|
||||||
|
writeComposedRecordingMetadata,
|
||||||
|
writeServerTrackRecordingMetadata
|
||||||
|
} from '../src/recording/storage';
|
||||||
|
|
||||||
|
describe('recording storage', () => {
|
||||||
|
const originalRecordingDir = process.env.RECORDING_DIR;
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'recording-storage-'));
|
||||||
|
process.env.RECORDING_DIR = tempDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.RECORDING_DIR = originalRecordingDir;
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizes path segments', () => {
|
||||||
|
expect(sanitizeRecordingPathSegment('../room:name', 'fallback')).toBe('__room_name');
|
||||||
|
expect(sanitizeRecordingPathSegment('', 'fallback')).toBe('fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates server track target and updates metadata size', () => {
|
||||||
|
const target = createServerTrackRecordingTarget({
|
||||||
|
recordingId: 'recording/1',
|
||||||
|
connectionId: 'room:1',
|
||||||
|
participantId: 'participant-1',
|
||||||
|
kind: 'video',
|
||||||
|
trackId: 'track-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(target.meetingId).toBe('room_1');
|
||||||
|
expect(target.filePath.startsWith(path.join(tempDir, 'room_1'))).toBe(true);
|
||||||
|
expect(target.filename).toContain('recording_1-participant-1-video-track-1.webm');
|
||||||
|
|
||||||
|
writeServerTrackRecordingMetadata({
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
connectionId: 'room-1',
|
||||||
|
participantId: 'participant-1',
|
||||||
|
kind: 'video',
|
||||||
|
trackId: 'track-1',
|
||||||
|
target
|
||||||
|
});
|
||||||
|
fs.writeFileSync(target.filePath, Buffer.from('webm'));
|
||||||
|
updateServerTrackRecordingMetadataSize(target);
|
||||||
|
|
||||||
|
const metadata = JSON.parse(fs.readFileSync(target.metadataPath, 'utf8'));
|
||||||
|
expect(metadata).toEqual(expect.objectContaining({
|
||||||
|
meetingId: 'room_1',
|
||||||
|
filename: target.filename,
|
||||||
|
mimetype: 'video/webm',
|
||||||
|
size: 4,
|
||||||
|
userId: 'server-recorder',
|
||||||
|
recordingSource: 'server',
|
||||||
|
participantId: 'participant-1',
|
||||||
|
trackKind: 'video',
|
||||||
|
recordingStartedAt: expect.any(String),
|
||||||
|
recordingEndedAt: expect.any(String)
|
||||||
|
}));
|
||||||
|
const files = listServerTrackRecordingFiles({
|
||||||
|
meetingId: 'room_1',
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
trackKind: 'video'
|
||||||
|
});
|
||||||
|
expect(files).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
filename: target.filename,
|
||||||
|
participantId: 'participant-1',
|
||||||
|
trackKind: 'video'
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(deleteServerTrackRecordingFiles(files)).toEqual([
|
||||||
|
target.filename,
|
||||||
|
`${target.filename}.json`
|
||||||
|
]);
|
||||||
|
expect(fs.existsSync(target.filePath)).toBe(false);
|
||||||
|
expect(fs.existsSync(target.metadataPath)).toBe(false);
|
||||||
|
expect(listServerTrackRecordingFiles({
|
||||||
|
meetingId: 'room_1',
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
trackKind: 'video'
|
||||||
|
})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes composed recording metadata', () => {
|
||||||
|
const target = createComposedRecordingTarget({
|
||||||
|
meetingId: 'room-1',
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
format: 'webm'
|
||||||
|
});
|
||||||
|
fs.writeFileSync(target.filePath, Buffer.from('composed'));
|
||||||
|
writeComposedRecordingMetadata({
|
||||||
|
target,
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
layout: 'grid',
|
||||||
|
format: 'webm',
|
||||||
|
host: {
|
||||||
|
participantId: 'host-p',
|
||||||
|
userId: 'host-user',
|
||||||
|
id: 'host-user',
|
||||||
|
name: 'Host User',
|
||||||
|
avatar: '/uploads/host.png',
|
||||||
|
role: 'host'
|
||||||
|
},
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
participantId: 'p1',
|
||||||
|
userId: 'participant-user',
|
||||||
|
id: 'participant-user',
|
||||||
|
name: 'Participant User',
|
||||||
|
avatar: '/uploads/p1.png',
|
||||||
|
role: 'participant'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
...target,
|
||||||
|
filename: 'p1-video.webm',
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
participantId: 'p1',
|
||||||
|
trackId: 'track-1',
|
||||||
|
trackKind: 'video',
|
||||||
|
uploadedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadata = JSON.parse(fs.readFileSync(target.metadataPath, 'utf8'));
|
||||||
|
expect(metadata).toEqual(expect.objectContaining({
|
||||||
|
meetingId: 'room-1',
|
||||||
|
filename: target.filename,
|
||||||
|
recordingSource: 'server-composed',
|
||||||
|
size: 8,
|
||||||
|
layout: 'grid',
|
||||||
|
inputFiles: ['p1-video.webm']
|
||||||
|
}));
|
||||||
|
expect(metadata.host).toEqual(expect.objectContaining({
|
||||||
|
participantId: 'host-p',
|
||||||
|
userId: 'host-user',
|
||||||
|
name: 'Host User',
|
||||||
|
role: 'host'
|
||||||
|
}));
|
||||||
|
expect(metadata.participants).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
participantId: 'p1',
|
||||||
|
userId: 'participant-user',
|
||||||
|
name: 'Participant User',
|
||||||
|
role: 'participant'
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,20 @@ Date.now = jest.fn(() => 1482363367071);
|
|||||||
|
|
||||||
const anyParticipantId = expect.any(String);
|
const anyParticipantId = expect.any(String);
|
||||||
|
|
||||||
|
function recordingEnvelope(connectionId: string, data: any): any {
|
||||||
|
const innerData = { ...data };
|
||||||
|
delete innerData.type;
|
||||||
|
return {
|
||||||
|
from: connectionId,
|
||||||
|
to: "",
|
||||||
|
type: "on-message",
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: data.type,
|
||||||
|
data: innerData
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('websocket signaling test in public mode', () => {
|
describe('websocket signaling test in public mode', () => {
|
||||||
let server: WS;
|
let server: WS;
|
||||||
let client: WebSocket;
|
let client: WebSocket;
|
||||||
@@ -190,6 +204,41 @@ describe('websocket signaling test in private mode', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('broadcast recording status to room members', async () => {
|
||||||
|
const session = {
|
||||||
|
id: 'recording-1',
|
||||||
|
connectionId: connectionId,
|
||||||
|
status: 'recording',
|
||||||
|
layout: 'grid',
|
||||||
|
format: 'webm',
|
||||||
|
createdAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
startedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-01T00:00:00.000Z'
|
||||||
|
} as any;
|
||||||
|
const expected = {
|
||||||
|
type: 'recording-started',
|
||||||
|
connectionId: connectionId,
|
||||||
|
recordingId: 'recording-1',
|
||||||
|
status: 'recording',
|
||||||
|
layout: 'grid',
|
||||||
|
format: 'webm',
|
||||||
|
startedAt: '2026-06-01T00:00:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(wsHandler.broadcastRecordingStarted(session)).toBe(true);
|
||||||
|
await expect(server).toReceiveMessage(recordingEnvelope(connectionId, expected));
|
||||||
|
await expect(server).toReceiveMessage(recordingEnvelope(connectionId, expected));
|
||||||
|
|
||||||
|
expect(wsHandler.broadcastRecordingPeerRequest(session)).toBe(true);
|
||||||
|
const peerRequest = {
|
||||||
|
...expected,
|
||||||
|
type: 'recording-peer-request',
|
||||||
|
mediaMode: 'webrtc-sendonly'
|
||||||
|
};
|
||||||
|
await expect(server).toReceiveMessage(recordingEnvelope(connectionId, peerRequest));
|
||||||
|
await expect(server).toReceiveMessage(recordingEnvelope(connectionId, peerRequest));
|
||||||
|
});
|
||||||
|
|
||||||
test('send offer from session1', async () => {
|
test('send offer from session1', async () => {
|
||||||
await wsHandler.onOffer(client, { connectionId: connectionId, sdp: testsdp });
|
await wsHandler.onOffer(client, { connectionId: connectionId, sdp: testsdp });
|
||||||
const receiveOffer = new Offer(testsdp, Date.now(), true);
|
const receiveOffer = new Offer(testsdp, Date.now(), true);
|
||||||
@@ -243,3 +292,53 @@ describe('websocket signaling test in private mode', () => {
|
|||||||
await wsHandler.remove(client);
|
await wsHandler.remove(client);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('recording offer validation', () => {
|
||||||
|
let server: WS;
|
||||||
|
let client: WebSocket;
|
||||||
|
const connectionId = "recording-room";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
wsHandler.reset("private");
|
||||||
|
server = new WS("ws://localhost:1234", { jsonProtocol: true });
|
||||||
|
client = new WebSocket("ws://localhost:1234");
|
||||||
|
await server.connected;
|
||||||
|
await wsHandler.add(client);
|
||||||
|
await wsHandler.onConnect(client, connectionId);
|
||||||
|
await expect(server).toReceiveMessage({
|
||||||
|
type: "connect",
|
||||||
|
connectionId: connectionId,
|
||||||
|
polite: false,
|
||||||
|
role: "host",
|
||||||
|
participantId: anyParticipantId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
WS.clean();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects recording offers without audio or video media sections', async () => {
|
||||||
|
await wsHandler.onRecordingOffer(client, {
|
||||||
|
recordingId: 'recording-empty-offer',
|
||||||
|
connectionId,
|
||||||
|
sdp: [
|
||||||
|
'v=0',
|
||||||
|
'o=- 25268170 0 IN IP4 0.0.0.0',
|
||||||
|
's=-',
|
||||||
|
't=0 0',
|
||||||
|
'a=group:BUNDLE ',
|
||||||
|
'a=extmap-allow-mixed',
|
||||||
|
'a=msid-semantic:WMS *',
|
||||||
|
''
|
||||||
|
].join('\r\n')
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(server).toReceiveMessage(expect.objectContaining({
|
||||||
|
from: connectionId,
|
||||||
|
to: "",
|
||||||
|
type: "on-message",
|
||||||
|
data: expect.stringContaining('"status":"no-media-offer"')
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user