Compare commits
2 Commits
e6dfb28ef2
...
本地音视频合并
| Author | SHA1 | Date | |
|---|---|---|---|
| 83cf098c5f | |||
| d74a0c8121 |
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable no-undef */
|
||||
import fetch from 'node-fetch';
|
||||
import { TextEncoder, TextDecoder } from 'util';
|
||||
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/peerconnectionmock';
|
||||
import ResizeObserverMock from './test/resizeobservermock';
|
||||
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/mocks/peerconnectionmock.js';
|
||||
import ResizeObserverMock from './test/helpers/resizeobservermock.js';
|
||||
|
||||
// note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined.
|
||||
|
||||
@@ -32,4 +32,4 @@ if (!window.RTCIceCandidate) {
|
||||
|
||||
if (!window.ResizeObserver) {
|
||||
window.ResizeObserver = ResizeObserverMock;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createTextElement, textValue } from '../../shared/dom.js';
|
||||
|
||||
export function createMessageElement(message, formatTimestamp) {
|
||||
const messageDiv = document.createElement('div');
|
||||
let messageClass = 'chat-bubble';
|
||||
@@ -13,31 +15,45 @@ export function createMessageElement(message, formatTimestamp) {
|
||||
messageDiv.className = messageClass;
|
||||
messageDiv.dataset.messageId = message.id;
|
||||
|
||||
const contentHTML = message.type === 'file' && message.content.startsWith('data:image/')
|
||||
? `
|
||||
<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>
|
||||
`;
|
||||
const header = document.createElement('div');
|
||||
header.className = 'message-header';
|
||||
|
||||
messageDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<img src="${message.senderAvatar}" class="message-avatar">
|
||||
<div>
|
||||
<span class="message-sender">${message.senderName}</span>
|
||||
<span class="message-time">${formatTimestamp(message.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
${contentHTML}
|
||||
</div>
|
||||
`;
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'message-avatar';
|
||||
avatar.src = textValue(message.senderAvatar);
|
||||
avatar.alt = textValue(message.senderName, '\u7528\u6237');
|
||||
header.appendChild(avatar);
|
||||
|
||||
const headerText = document.createElement('div');
|
||||
headerText.appendChild(createTextElement('span', 'message-sender', message.senderName));
|
||||
headerText.appendChild(createTextElement('span', 'message-time', formatTimestamp(message.timestamp)));
|
||||
header.appendChild(headerText);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
|
||||
|
||||
function createParticipantPlaceholder() {
|
||||
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';
|
||||
@@ -15,32 +17,39 @@ function createParticipantPlaceholder() {
|
||||
export function createParticipantTile(connectionId, displayName) {
|
||||
const tile = document.createElement('div');
|
||||
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');
|
||||
video.className = 'w-full h-full object-contain';
|
||||
video.autoplay = true;
|
||||
video.playsinline = true;
|
||||
video.muted = false;
|
||||
video.id = `participantVideo_${connectionId}`;
|
||||
video.id = `participantVideo_${textValue(connectionId)}`;
|
||||
tile.appendChild(video);
|
||||
tile.appendChild(createParticipantPlaceholder());
|
||||
|
||||
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.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);
|
||||
|
||||
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.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);
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
|
||||
|
||||
const DEFAULT_NETWORK_QUALITY = {
|
||||
label: '\u672a\u77e5',
|
||||
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') {
|
||||
return user.isHost
|
||||
? '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>'
|
||||
: '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>';
|
||||
? { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' }
|
||||
: { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -79,34 +81,55 @@ function getDatasetUserId(role, id) {
|
||||
}
|
||||
}
|
||||
|
||||
function getAvatarMarkup(user, role) {
|
||||
if (role === 'local') {
|
||||
return `<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">`;
|
||||
}
|
||||
|
||||
return `
|
||||
<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 createAvatarImage(user) {
|
||||
const image = document.createElement('img');
|
||||
image.src = textValue(user.avatar);
|
||||
image.alt = textValue(user.name, '\u7528\u6237');
|
||||
image.className = 'w-10 h-10 rounded-full object-cover';
|
||||
return image;
|
||||
}
|
||||
|
||||
function getRightMarkup(mediaState, role, muteIconMarkup) {
|
||||
if (role !== 'participant') {
|
||||
return muteIconMarkup;
|
||||
function createAvatarElement(user, role) {
|
||||
if (role === 'local') {
|
||||
return createAvatarImage(user);
|
||||
}
|
||||
|
||||
const speakingMarkup = (mediaState.isSpeaking && mediaState.audio)
|
||||
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>'
|
||||
: '';
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'relative';
|
||||
wrapper.appendChild(createAvatarImage(user));
|
||||
|
||||
return `
|
||||
<div class="flex items-center gap-2">
|
||||
${muteIconMarkup}
|
||||
${speakingMarkup}
|
||||
</div>
|
||||
`;
|
||||
const statusDot = document.createElement('div');
|
||||
statusDot.className = 'absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900';
|
||||
wrapper.appendChild(statusDot);
|
||||
|
||||
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) {
|
||||
@@ -163,27 +186,40 @@ export function buildUserCountLabel(userCount) {
|
||||
export function createUserEntryElement({ user, role, id }) {
|
||||
const entry = document.createElement('div');
|
||||
const mediaMeta = getMediaStatusMeta(user.mediaState);
|
||||
const muteIconMarkup = mediaMeta.showMuteIcon
|
||||
? `<i class="${mediaMeta.muteIconClass}"></i>`
|
||||
const muteIcon = mediaMeta.showMuteIcon
|
||||
? createIconElement(mediaMeta.muteIconClass)
|
||||
: '';
|
||||
const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
|
||||
const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : '';
|
||||
|
||||
entry.className = role === 'local'
|
||||
? `${baseClass} hover:bg-white/5`
|
||||
: `${baseClass} bg-white/5`;
|
||||
entry.dataset.userId = getDatasetUserId(role, id);
|
||||
entry.innerHTML = `
|
||||
${getAvatarMarkup(user, role)}
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">
|
||||
${user.name}
|
||||
${getRoleTagMarkup(user, role)}
|
||||
</div>
|
||||
<div class="${mediaMeta.className}"${dataFieldAttr}>${mediaMeta.text}</div>
|
||||
</div>
|
||||
${getRightMarkup(user.mediaState, role, muteIconMarkup)}
|
||||
`;
|
||||
|
||||
entry.appendChild(createAvatarElement(user, role));
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'flex-1';
|
||||
|
||||
const nameRow = document.createElement('div');
|
||||
nameRow.className = 'text-sm font-medium';
|
||||
nameRow.appendChild(document.createTextNode(textValue(user.name)));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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_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';
|
||||
@@ -10,13 +12,14 @@ const SELECT_LABEL = '\u9009\u62e9';
|
||||
const USER_COUNT_SUFFIX = '\u4eba';
|
||||
const ONLINE_USERS_SUMMARY_SUFFIX = ' \u4e2aWebSocket\u7528\u6237\u5728\u7ebf';
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
function getRoleTagClass(role) {
|
||||
if (role === 'host') {
|
||||
return 'text-xs px-2 py-1 rounded-full bg-indigo-500/20 text-indigo-300';
|
||||
}
|
||||
if (role === 'participant') {
|
||||
return 'text-xs px-2 py-1 rounded-full bg-white/10 text-gray-300';
|
||||
}
|
||||
return 'text-xs px-2 py-1 rounded-full bg-emerald-500/20 text-emerald-300';
|
||||
}
|
||||
|
||||
export async function fetchOnlineUsers() {
|
||||
@@ -115,10 +118,8 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
|
||||
|
||||
const roomTitle = document.createElement('div');
|
||||
roomTitle.className = 'flex items-center justify-between mb-2';
|
||||
roomTitle.innerHTML = `
|
||||
<span class="text-sm font-medium text-white">${escapeHtml(groupName)}</span>
|
||||
<span class="text-xs text-gray-400">${roomUsers.length} ${USER_COUNT_SUFFIX}</span>
|
||||
`;
|
||||
roomTitle.appendChild(createTextElement('span', 'text-sm font-medium text-white', groupName));
|
||||
roomTitle.appendChild(createTextElement('span', 'text-xs text-gray-400', `${roomUsers.length} ${USER_COUNT_SUFFIX}`));
|
||||
section.appendChild(roomTitle);
|
||||
|
||||
const roomList = document.createElement('div');
|
||||
@@ -135,19 +136,31 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
|
||||
|
||||
const userItem = document.createElement('div');
|
||||
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">
|
||||
<img src="${escapeHtml(avatar)}" alt="${escapeHtml(userName)}" class="w-8 h-8 rounded-full object-cover">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm text-white truncate">${escapeHtml(userName)}</div>
|
||||
<div class="text-xs text-gray-400 truncate">${escapeHtml(identity)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const profile = document.createElement('div');
|
||||
profile.className = 'flex items-center gap-3 min-w-0';
|
||||
|
||||
const avatarImage = document.createElement('img');
|
||||
avatarImage.src = textValue(avatar);
|
||||
avatarImage.alt = textValue(userName);
|
||||
avatarImage.className = 'w-8 h-8 rounded-full object-cover';
|
||||
profile.appendChild(avatarImage);
|
||||
|
||||
const info = document.createElement('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);
|
||||
});
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import * as Logger from "../utils/logger.js";
|
||||
|
||||
export class Signaling extends EventTarget {
|
||||
|
||||
constructor(interval = 1000) {
|
||||
constructor(interval = 1000, baseUrl = null) {
|
||||
super();
|
||||
this.running = false;
|
||||
this.interval = interval;
|
||||
this.baseUrl = baseUrl;
|
||||
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
||||
}
|
||||
|
||||
@@ -19,7 +20,7 @@ export class Signaling extends EventTarget {
|
||||
}
|
||||
|
||||
url(method, parameter = '') {
|
||||
let ret = location.origin + '/signaling';
|
||||
let ret = (this.baseUrl || location.origin) + '/signaling';
|
||||
if (method)
|
||||
ret += '/' + method;
|
||||
if (parameter)
|
||||
@@ -151,16 +152,17 @@ export class Signaling extends EventTarget {
|
||||
|
||||
export class WebSocketSignaling extends EventTarget {
|
||||
|
||||
constructor(interval = 1000) {
|
||||
constructor(interval = 1000, websocketUrl = null) {
|
||||
super();
|
||||
this.interval = interval;
|
||||
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
||||
|
||||
let websocketUrl;
|
||||
if (location.protocol === "https:") {
|
||||
websocketUrl = "wss://" + location.host;
|
||||
} else {
|
||||
websocketUrl = "ws://" + location.host;
|
||||
if (!websocketUrl) {
|
||||
if (location.protocol === "https:") {
|
||||
websocketUrl = "wss://" + location.host;
|
||||
} else {
|
||||
websocketUrl = "ws://" + location.host;
|
||||
}
|
||||
}
|
||||
|
||||
this.websocket = new WebSocket(websocketUrl);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import { MeetingRecorder } from '../../public/call/media/meeting-recorder.js';
|
||||
|
||||
class MediaStreamMock {
|
||||
@@ -107,7 +108,7 @@ describe('MeetingRecorder', () => {
|
||||
const result = await recorder.stop();
|
||||
|
||||
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(windowRef.URL.createObjectURL).not.toHaveBeenCalled();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,49 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import fs from 'fs';
|
||||
import * as Path from 'path';
|
||||
import process from 'process';
|
||||
import { setup, teardown } from 'jest-dev-server';
|
||||
import { Signaling, WebSocketSignaling } from "../../src/core/signaling.js";
|
||||
import { MockSignaling, reset } from "../mocks/mocksignaling.js";
|
||||
import { waitFor, sleep, serverExeName } from "../helpers/testutils.js";
|
||||
|
||||
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);
|
||||
|
||||
describe.each([
|
||||
{ mode: "mock" },
|
||||
{ mode: "http" },
|
||||
{ mode: "websocket" },
|
||||
])('signaling test in public mode', ({ mode }) => {
|
||||
function buildServerCommand(args = '') {
|
||||
const binaryPath = Path.resolve(`../bin~/${serverExeName()}`);
|
||||
const buildEntryPath = Path.resolve('../build/index.js');
|
||||
const serverCommand = fs.existsSync(binaryPath)
|
||||
? `"${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.each(signalingModes)('signaling test in public mode', ({ mode }) => {
|
||||
let signaling1;
|
||||
let signaling2;
|
||||
const connectionId1 = "12345";
|
||||
@@ -26,22 +57,22 @@ describe.each([
|
||||
signaling1 = new MockSignaling(1);
|
||||
signaling2 = new MockSignaling(1);
|
||||
} else {
|
||||
const path = Path.resolve(`../bin~/${serverExeName()}`);
|
||||
let cmd = `${path} -p ${portNumber}`;
|
||||
const serverPort = getPortForMode(mode, false);
|
||||
let cmd = buildServerCommand(`-p ${serverPort}`);
|
||||
if (mode == "http") {
|
||||
cmd += " -t http";
|
||||
}
|
||||
|
||||
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' });
|
||||
await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
|
||||
|
||||
if (mode == "http") {
|
||||
signaling1 = new Signaling(1);
|
||||
signaling2 = new Signaling(1);
|
||||
signaling1 = createHttpSignaling(serverPort);
|
||||
signaling2 = createHttpSignaling(serverPort);
|
||||
}
|
||||
|
||||
if (mode == "websocket") {
|
||||
signaling1 = new WebSocketSignaling(1);
|
||||
signaling2 = new WebSocketSignaling(1);
|
||||
signaling1 = createWebSocketSignaling(serverPort);
|
||||
signaling2 = createWebSocketSignaling(serverPort);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +81,8 @@ describe.each([
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await signaling1.stop();
|
||||
await signaling2.stop();
|
||||
await signaling1?.stop();
|
||||
await signaling2?.stop();
|
||||
signaling1 = null;
|
||||
signaling2 = null;
|
||||
|
||||
@@ -207,11 +238,7 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ mode: "mock" },
|
||||
{ mode: "http" },
|
||||
{ mode: "websocket" },
|
||||
])('signaling test in private mode', ({ mode }) => {
|
||||
describe.each(signalingModes)('signaling test in private mode', ({ mode }) => {
|
||||
let signaling1;
|
||||
let signaling2;
|
||||
const connectionId = "12345";
|
||||
@@ -226,22 +253,22 @@ describe.each([
|
||||
return;
|
||||
}
|
||||
|
||||
const path = Path.resolve(`../bin~/${serverExeName()}`);
|
||||
let cmd = `${path} -p ${portNumber} -m private`;
|
||||
const serverPort = getPortForMode(mode, true);
|
||||
let cmd = buildServerCommand(`-p ${serverPort} -m private`);
|
||||
if (mode == "http") {
|
||||
cmd += " -t http";
|
||||
}
|
||||
|
||||
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' });
|
||||
await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
|
||||
|
||||
if (mode == "http") {
|
||||
signaling1 = new Signaling(1);
|
||||
signaling2 = new Signaling(1);
|
||||
signaling1 = createHttpSignaling(serverPort);
|
||||
signaling2 = createHttpSignaling(serverPort);
|
||||
}
|
||||
|
||||
if (mode == "websocket") {
|
||||
signaling1 = new WebSocketSignaling(1);
|
||||
signaling2 = new WebSocketSignaling(1);
|
||||
signaling1 = createWebSocketSignaling(serverPort);
|
||||
signaling2 = createWebSocketSignaling(serverPort);
|
||||
}
|
||||
|
||||
await signaling1.start();
|
||||
@@ -249,8 +276,8 @@ describe.each([
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await signaling1.stop();
|
||||
await signaling2.stop();
|
||||
await signaling1?.stop();
|
||||
await signaling2?.stop();
|
||||
signaling1 = null;
|
||||
signaling2 = null;
|
||||
|
||||
|
||||
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-ui-express": "^4.5.0",
|
||||
"uuid": "^9.0.0",
|
||||
"werift": "^0.23.0",
|
||||
"ws": "^8.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -7,7 +7,7 @@ import Offer from './offer';
|
||||
import Answer from './answer';
|
||||
import Candidate from './candidate';
|
||||
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';
|
||||
/**
|
||||
* 断开连接记录类
|
||||
@@ -996,57 +996,6 @@ function postCandidate(req: Request, res: Response): void {
|
||||
arr.push(candidate);
|
||||
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
|
||||
@@ -1160,7 +1109,6 @@ export {
|
||||
postOffer, // 处理offer信令消息
|
||||
postAnswer, // 处理answer信令消息
|
||||
postCandidate, // 处理candidate信令消息
|
||||
onGetConnections, // 获取房间和用户信息
|
||||
getAllConnectionIds, // 获取所有连接ID
|
||||
getOnlineUsers // 获取在线WebSocket用户列表
|
||||
};
|
||||
|
||||
197
src/class/serveraudiorecorder.ts
Normal file
197
src/class/serveraudiorecorder.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { RTCPeerConnection } from 'werift';
|
||||
import { MediaRecorder } from 'werift/nonstandard';
|
||||
import { log, LogLevel } from '../log';
|
||||
|
||||
type ServerAudioRecordingSession = {
|
||||
recordingId: string;
|
||||
meetingId: string;
|
||||
peerConnection: RTCPeerConnection;
|
||||
audioPath: string;
|
||||
createdAt: number;
|
||||
recorder?: MediaRecorder;
|
||||
audioTrackCount: number;
|
||||
localCandidates: any[];
|
||||
};
|
||||
|
||||
type StartServerAudioRecordingOptions = {
|
||||
meetingId?: string;
|
||||
offerSdp: string;
|
||||
iceServers?: any[];
|
||||
};
|
||||
|
||||
type StartServerAudioRecordingResult = {
|
||||
recordingId: string;
|
||||
meetingId: string;
|
||||
answerSdp: string;
|
||||
candidates: any[];
|
||||
audioPath: string;
|
||||
};
|
||||
|
||||
type StoppedServerAudioRecording = {
|
||||
recordingId: string;
|
||||
meetingId: string;
|
||||
audioPath: string;
|
||||
hasAudio: boolean;
|
||||
audioTrackCount: number;
|
||||
createdAt: number;
|
||||
stoppedAt: number;
|
||||
};
|
||||
|
||||
function waitForIceGatheringComplete(peerConnection: RTCPeerConnection, timeoutMs: number): Promise<void> {
|
||||
if (peerConnection.iceGatheringState === 'complete') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const subscription = peerConnection.iceGatheringStateChange.subscribe((state) => {
|
||||
if (state === 'complete') {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
const timer = setTimeout(finish, timeoutMs);
|
||||
|
||||
function finish(): void {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
subscription.unSubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toJsonCandidate(candidate: any): any {
|
||||
if (!candidate) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return typeof candidate.toJSON === 'function' ? candidate.toJSON() : candidate;
|
||||
}
|
||||
|
||||
export class ServerAudioRecorderManager {
|
||||
private sessions: Map<string, ServerAudioRecordingSession> = new Map<string, ServerAudioRecordingSession>();
|
||||
|
||||
constructor(private tempDir: string) {}
|
||||
|
||||
async start(options: StartServerAudioRecordingOptions): Promise<StartServerAudioRecordingResult> {
|
||||
if (!options.offerSdp || typeof options.offerSdp !== 'string') {
|
||||
throw new Error('offerSdp is required');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
const recordingId = uuid();
|
||||
const meetingId = options.meetingId || 'unknown';
|
||||
const audioPath = path.join(this.tempDir, `${recordingId}.server-audio.webm`);
|
||||
const peerConnection = new RTCPeerConnection({
|
||||
iceServers: Array.isArray(options.iceServers) ? options.iceServers : []
|
||||
});
|
||||
const session: ServerAudioRecordingSession = {
|
||||
recordingId,
|
||||
meetingId,
|
||||
peerConnection,
|
||||
audioPath,
|
||||
createdAt: Date.now(),
|
||||
audioTrackCount: 0,
|
||||
localCandidates: []
|
||||
};
|
||||
|
||||
peerConnection.onIceCandidate.subscribe((candidate) => {
|
||||
if (candidate) {
|
||||
session.localCandidates.push(toJsonCandidate(candidate));
|
||||
}
|
||||
});
|
||||
|
||||
peerConnection.onTrack.subscribe((track) => {
|
||||
if (track.kind !== 'audio') {
|
||||
return;
|
||||
}
|
||||
|
||||
session.audioTrackCount += 1;
|
||||
if (session.recorder) {
|
||||
log(LogLevel.warn, `Ignoring extra server audio track for recording ${recordingId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
session.recorder = new MediaRecorder({
|
||||
path: audioPath,
|
||||
tracks: [track]
|
||||
});
|
||||
session.recorder.onError.subscribe((error) => {
|
||||
log(LogLevel.error, `Server audio recorder error for ${recordingId}:`, error);
|
||||
});
|
||||
log(LogLevel.log, `Server audio track received for recording ${recordingId}`);
|
||||
});
|
||||
|
||||
await peerConnection.setRemoteDescription({ type: 'offer', sdp: options.offerSdp });
|
||||
const answer = await peerConnection.createAnswer();
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(peerConnection, 3000);
|
||||
|
||||
this.sessions.set(recordingId, session);
|
||||
|
||||
return {
|
||||
recordingId,
|
||||
meetingId,
|
||||
answerSdp: (peerConnection.localDescription && peerConnection.localDescription.sdp) || answer.sdp,
|
||||
candidates: session.localCandidates,
|
||||
audioPath
|
||||
};
|
||||
}
|
||||
|
||||
async addCandidate(recordingId: string, candidate: any): Promise<boolean> {
|
||||
const session = this.sessions.get(recordingId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await session.peerConnection.addIceCandidate(candidate || {});
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(recordingId: string): Promise<StoppedServerAudioRecording | null> {
|
||||
const session = this.sessions.get(recordingId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.sessions.delete(recordingId);
|
||||
|
||||
if (session.recorder) {
|
||||
await session.recorder.stop();
|
||||
}
|
||||
await session.peerConnection.close();
|
||||
|
||||
const hasAudio = fs.existsSync(session.audioPath) && fs.statSync(session.audioPath).size > 0;
|
||||
return {
|
||||
recordingId: session.recordingId,
|
||||
meetingId: session.meetingId,
|
||||
audioPath: session.audioPath,
|
||||
hasAudio,
|
||||
audioTrackCount: session.audioTrackCount,
|
||||
createdAt: session.createdAt,
|
||||
stoppedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
async cancel(recordingId: string): Promise<boolean> {
|
||||
const stopped = await this.stop(recordingId);
|
||||
if (!stopped) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fs.existsSync(stopped.audioPath)) {
|
||||
fs.unlinkSync(stopped.audioPath);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
82
src/server-audio-recording-api.md
Normal file
82
src/server-audio-recording-api.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Server Audio Recording API
|
||||
|
||||
This API lets Unity keep local video recording while the server records app audio as an extra WebRTC peer. When Unity stops local recording, upload the local video to the stop endpoint and the server merges it with the recorded audio.
|
||||
|
||||
## 1. Start
|
||||
|
||||
`POST /api/server-audio-recordings/start`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"meetingId": "room-001",
|
||||
"offerSdp": "v=0...",
|
||||
"iceServers": []
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"recordingId": "uuid",
|
||||
"meetingId": "room-001",
|
||||
"answerSdp": "v=0...",
|
||||
"candidates": []
|
||||
}
|
||||
```
|
||||
|
||||
Unity should create a peer connection with an audio track only, send its offer SDP here, then set the returned answer SDP as the remote description.
|
||||
|
||||
## 2. Trickle ICE
|
||||
|
||||
`POST /api/server-audio-recordings/{recordingId}/candidate`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"candidate": "candidate:...",
|
||||
"sdpMid": "0",
|
||||
"sdpMLineIndex": 0
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Stop And Merge
|
||||
|
||||
`POST /api/server-audio-recordings/{recordingId}/stop`
|
||||
|
||||
Content type: `multipart/form-data`
|
||||
|
||||
Fields:
|
||||
|
||||
- `video`: the local video file recorded by Unity.
|
||||
- `meetingId`: optional, overrides the start meeting id.
|
||||
- `filename`: optional display filename.
|
||||
- `userId`: optional host user id.
|
||||
- `host`: optional JSON host metadata.
|
||||
- `participants`: optional JSON participant metadata array.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"recordingId": "uuid",
|
||||
"meetingId": "room-001",
|
||||
"filename": "2026-06-02T13-00-00-000Z-uuid.mp4",
|
||||
"merged": true,
|
||||
"url": "/api/recordings/room-001/2026-06-02T13-00-00-000Z-uuid.mp4/download"
|
||||
}
|
||||
```
|
||||
|
||||
The server keeps the local video track, replaces audio with the server-recorded app audio, and stores the merged file in the existing `recordings` directory.
|
||||
|
||||
## 4. Cancel
|
||||
|
||||
`DELETE /api/server-audio-recordings/{recordingId}`
|
||||
|
||||
Use this if local recording is aborted and no merged output should be saved.
|
||||
|
||||
270
src/server.ts
270
src/server.ts
@@ -2,12 +2,14 @@ import * as express from 'express';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as morgan from 'morgan';
|
||||
import { spawn } from 'child_process';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import signaling from './signaling';
|
||||
import { log, LogLevel } from './log';
|
||||
import Options from './class/options';
|
||||
import { reset as resetHandler } from './class/httphandler';
|
||||
import { initSwagger } from './swagger';
|
||||
import { ServerAudioRecorderManager } from './class/serveraudiorecorder';
|
||||
|
||||
const cors = require('cors');
|
||||
const multer = require('multer');
|
||||
@@ -139,7 +141,15 @@ function sanitizeMetadataString(value: any, maxLength = 200): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength);
|
||||
return String(value)
|
||||
.split('')
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code > 31 && code !== 127;
|
||||
})
|
||||
.join('')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function sanitizeRecordingPerson(value: any, fallbackRole: string): RecordingPerson | undefined {
|
||||
@@ -285,6 +295,75 @@ function removeEmptyDirectory(directory: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function removeFileIfExists(filePath: string | undefined): void {
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
log(LogLevel.warn, 'Failed to remove temporary file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getMergedRecordingExtension(videoExt: string): string {
|
||||
return videoExt.toLowerCase() === '.webm' ? '.webm' : '.mp4';
|
||||
}
|
||||
|
||||
function runFfmpeg(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ffmpegPath = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
const child = spawn(ffmpegPath, args, { windowsHide: true });
|
||||
let stderr = '';
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`ffmpeg exited with code ${code}: ${stderr.slice(-2000)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mergeVideoWithServerAudio(videoPath: string, audioPath: string, outputPath: string, outputExt: string): Promise<void> {
|
||||
const isWebmOutput = outputExt.toLowerCase() === '.webm';
|
||||
const args = isWebmOutput
|
||||
? [
|
||||
'-y',
|
||||
'-i', videoPath,
|
||||
'-i', audioPath,
|
||||
'-map', '0:v:0',
|
||||
'-map', '1:a:0',
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'libopus',
|
||||
'-shortest',
|
||||
outputPath
|
||||
]
|
||||
: [
|
||||
'-y',
|
||||
'-i', videoPath,
|
||||
'-i', audioPath,
|
||||
'-map', '0:v:0',
|
||||
'-map', '1:a:0',
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'aac',
|
||||
'-shortest',
|
||||
'-movflags', '+faststart',
|
||||
outputPath
|
||||
];
|
||||
|
||||
return runFfmpeg(args);
|
||||
}
|
||||
|
||||
export const createServer = (config: Options): express.Express => {
|
||||
const app: express.Express = express();
|
||||
resetHandler(config.mode);
|
||||
@@ -419,6 +498,7 @@ export const createServer = (config: Options): express.Express => {
|
||||
|
||||
const recordingRoot = getRecordingRoot();
|
||||
const recordingTempDir = path.join(recordingRoot, '.tmp');
|
||||
const serverAudioRecordings = new ServerAudioRecorderManager(recordingTempDir);
|
||||
const recordingStorage = multer.diskStorage({
|
||||
destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => {
|
||||
if (!fs.existsSync(recordingTempDir)) {
|
||||
@@ -452,6 +532,194 @@ export const createServer = (config: Options): express.Express => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/server-audio-recordings/start', async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const offerSdp = req.body.offerSdp || req.body.sdp;
|
||||
const meetingId = sanitizePathSegment(req.body.meetingId, 'unknown');
|
||||
const started = await serverAudioRecordings.start({
|
||||
meetingId,
|
||||
offerSdp,
|
||||
iceServers: Array.isArray(req.body.iceServers) ? req.body.iceServers : undefined
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
recordingId: started.recordingId,
|
||||
meetingId: started.meetingId,
|
||||
answerSdp: started.answerSdp,
|
||||
candidates: started.candidates
|
||||
});
|
||||
} catch (error) {
|
||||
log(LogLevel.error, 'Failed to start server audio recording:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to start server audio recording'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/server-audio-recordings/:recordingId/candidate', async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const recordingId = sanitizePathSegment(req.params.recordingId, '');
|
||||
const candidate = req.body.candidate && typeof req.body.candidate === 'object'
|
||||
? req.body.candidate
|
||||
: {
|
||||
candidate: req.body.candidate,
|
||||
sdpMid: req.body.sdpMid,
|
||||
sdpMLineIndex: req.body.sdpMLineIndex
|
||||
};
|
||||
const added = await serverAudioRecordings.addCandidate(recordingId, candidate);
|
||||
|
||||
if (!added) {
|
||||
res.status(404).json({ success: false, message: 'Server audio recording not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
log(LogLevel.error, 'Failed to add server audio ICE candidate:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to add server audio ICE candidate'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/server-audio-recordings/:recordingId', async (req: express.Request, res: express.Response) => {
|
||||
const recordingId = sanitizePathSegment(req.params.recordingId, '');
|
||||
const cancelled = await serverAudioRecordings.cancel(recordingId);
|
||||
if (!cancelled) {
|
||||
res.status(404).json({ success: false, message: 'Server audio recording not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/server-audio-recordings/:recordingId/stop', (req: express.Request, res: express.Response) => {
|
||||
const recordingId = sanitizePathSegment(req.params.recordingId, '');
|
||||
const stopPromise = serverAudioRecordings.stop(recordingId);
|
||||
stopPromise.catch(() => undefined);
|
||||
|
||||
recordingUpload.single('video')(req, res, async (error: Error) => {
|
||||
const request = req as any;
|
||||
let stopped = null;
|
||||
|
||||
try {
|
||||
stopped = await stopPromise;
|
||||
|
||||
if (!stopped) {
|
||||
removeFileIfExists(request.file && request.file.path);
|
||||
res.status(404).json({ success: false, message: 'Server audio recording not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
log(LogLevel.warn, 'Server audio merge upload rejected:', error.message);
|
||||
const isSizeLimit = error.name === 'MulterError' && (error as any).code === 'LIMIT_FILE_SIZE';
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: isSizeLimit ? 'Recording file is too large' : error.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stopped.hasAudio) {
|
||||
removeFileIfExists(request.file && request.file.path);
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
res.status(400).json({ success: false, message: 'No server audio was captured' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.file) {
|
||||
res.json({
|
||||
success: true,
|
||||
recordingId: stopped.recordingId,
|
||||
meetingId: stopped.meetingId,
|
||||
audioOnly: true,
|
||||
audioTrackCount: stopped.audioTrackCount
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const videoExt = safeRecordingExtension(request.file);
|
||||
if (!videoExt) {
|
||||
removeFileIfExists(request.file.path);
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
res.status(400).json({ success: false, message: 'Unsupported recording file type' });
|
||||
return;
|
||||
}
|
||||
|
||||
const finalExt = getMergedRecordingExtension(videoExt);
|
||||
const meetingId = sanitizePathSegment(request.body.meetingId || stopped.meetingId, 'unknown');
|
||||
const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${finalExt}`);
|
||||
const userId = sanitizeMetadataString(request.body.userId, 120);
|
||||
const host = sanitizeRecordingPerson(request.body.host, 'host') || buildFallbackRecordingHost(userId);
|
||||
const participants = sanitizeRecordingParticipants(request.body.participants);
|
||||
const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${stopped.recordingId}${finalExt}`;
|
||||
const meetingDir = path.join(recordingRoot, meetingId);
|
||||
const finalPath = path.join(meetingDir, finalFilename);
|
||||
|
||||
if (!isPathInside(recordingRoot, finalPath)) {
|
||||
removeFileIfExists(request.file.path);
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
res.status(400).json({ success: false, message: 'Invalid recording path' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(meetingDir)) {
|
||||
fs.mkdirSync(meetingDir, { recursive: true });
|
||||
}
|
||||
|
||||
await mergeVideoWithServerAudio(request.file.path, stopped.audioPath, finalPath, finalExt);
|
||||
const stat = fs.statSync(finalPath);
|
||||
const metadata = {
|
||||
id: stopped.recordingId,
|
||||
meetingId,
|
||||
filename: finalFilename,
|
||||
originalFilename,
|
||||
mimetype: getRecordingMimeTypeFromExtension(finalExt),
|
||||
size: stat.size,
|
||||
userId,
|
||||
host,
|
||||
participants,
|
||||
serverAudio: {
|
||||
audioTrackCount: stopped.audioTrackCount,
|
||||
startedAt: new Date(stopped.createdAt).toISOString(),
|
||||
stoppedAt: new Date(stopped.stoppedAt).toISOString()
|
||||
},
|
||||
uploadedAt: new Date().toISOString()
|
||||
};
|
||||
fs.writeFileSync(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2));
|
||||
|
||||
removeFileIfExists(request.file.path);
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
recordingId: stopped.recordingId,
|
||||
meetingId,
|
||||
filename: finalFilename,
|
||||
originalFilename,
|
||||
size: stat.size,
|
||||
merged: true,
|
||||
url: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(finalFilename)}/download`
|
||||
});
|
||||
} catch (mergeError) {
|
||||
removeFileIfExists(request.file && request.file.path);
|
||||
if (stopped) {
|
||||
removeFileIfExists(stopped.audioPath);
|
||||
}
|
||||
log(LogLevel.error, 'Failed to stop server audio recording:', mergeError);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: mergeError instanceof Error ? mergeError.message : 'Failed to stop server audio recording'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/recordings', (_req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const recordings = listRecordings(recordingRoot);
|
||||
|
||||
@@ -6,7 +6,7 @@ const router: express.Router = express.Router();
|
||||
// 不需要会话ID的路由
|
||||
router.get('/connection-ids', handler.getAllConnectionIds);
|
||||
router.get('/users', handler.getOnlineUsers);
|
||||
router.get('/rooms', handler.onGetConnections);
|
||||
// router.get('/rooms', handler.onGetConnections);
|
||||
|
||||
// 需要会话ID的路由
|
||||
router.use(handler.checkSessionId);
|
||||
|
||||
12
src/types/werift-nonstandard.d.ts
vendored
Normal file
12
src/types/werift-nonstandard.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
declare module 'werift/nonstandard' {
|
||||
export class MediaRecorder {
|
||||
onError: {
|
||||
subscribe: (execute: (error: Error) => void) => { unSubscribe: () => void };
|
||||
};
|
||||
|
||||
constructor(props: any);
|
||||
addTrack(track: any): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
"exclude": ["node_modules", "**/*.spec.ts"],
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "es5",
|
||||
"lib": ["dom","es5"],
|
||||
"sourceMap": true,
|
||||
"outDir":"build",
|
||||
"rootDir":"src"
|
||||
"rootDir":"src",
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user