服务器录屏1

This commit is contained in:
2026-06-02 02:49:47 +08:00
parent 66d6f92d1e
commit 59fc4be5cc
7 changed files with 248 additions and 27 deletions

View File

@@ -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) {

View File

@@ -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,

View File

@@ -10,6 +10,7 @@ import { RecordingSession, listRecordingSessions, stopRecordingSession } from '.
import { registerRecordingPeerCandidate, registerRecordingPeerOffer, stopRecordingAgent } from '../recording/agent'; import { registerRecordingPeerCandidate, registerRecordingPeerOffer, stopRecordingAgent } from '../recording/agent';
import { startRecordingCompositionJob } from '../recording/composer'; import { startRecordingCompositionJob } from '../recording/composer';
import { acceptRecordingOffer, addRecordingIceCandidate, stopRecordingPeer } from '../recording/werift-adapter'; import { acceptRecordingOffer, addRecordingIceCandidate, stopRecordingPeer } from '../recording/werift-adapter';
import { RecordingPerson } from '../recording/storage';
/** /**
* 是否为私有模式 * 是否为私有模式
@@ -363,7 +364,42 @@ function stopRecordingPeersForSocket(ws: WebSocket, connectionId: string): void
}); });
} }
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 { function stopActiveRecordingSessions(connectionId: string): void {
const roomPeople = getRecordingRoomPeople(connectionId);
getActiveRecordingSessions(connectionId).forEach((session) => { getActiveRecordingSessions(connectionId).forEach((session) => {
const stoppedSession = stopRecordingSession(session.id); const stoppedSession = stopRecordingSession(session.id);
if (stoppedSession) { if (stoppedSession) {
@@ -376,7 +412,9 @@ function stopActiveRecordingSessions(connectionId: string): void {
meetingId: session.connectionId, meetingId: session.connectionId,
recordingId: session.id, recordingId: session.id,
layout: session.layout, layout: session.layout,
format: session.format format: session.format,
host: roomPeople.host,
participants: roomPeople.participants
}); });
}) })
.catch((error) => { .catch((error) => {

View File

@@ -1,6 +1,7 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { import {
RecordingPerson,
ServerTrackRecordingFile, ServerTrackRecordingFile,
ServerTrackRecordingTarget, ServerTrackRecordingTarget,
createComposedRecordingTarget, createComposedRecordingTarget,
@@ -25,6 +26,8 @@ export type RecordingCompositionJob = {
failedAt?: string; failedAt?: string;
error?: string; error?: string;
inputFiles: string[]; inputFiles: string[];
host?: RecordingPerson;
participants?: RecordingPerson[];
deletedInputFiles?: string[]; deletedInputFiles?: string[];
output?: { output?: {
meetingId: string; meetingId: string;
@@ -41,6 +44,8 @@ type StartCompositionInput = {
recordingId: string; recordingId: string;
layout?: string; layout?: string;
format?: string; format?: string;
host?: RecordingPerson;
participants?: RecordingPerson[];
}; };
type CompositionInputSets = { type CompositionInputSets = {
@@ -267,7 +272,9 @@ async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise
recordingId: job.recordingId, recordingId: job.recordingId,
inputs: compositionInputs, inputs: compositionInputs,
layout: job.layout, layout: job.layout,
format: job.format format: job.format,
host: job.host,
participants: job.participants
}); });
const deletedInputFiles = deleteServerTrackRecordingFiles(compositionInputs); const deletedInputFiles = deleteServerTrackRecordingFiles(compositionInputs);
@@ -301,7 +308,9 @@ export function startRecordingCompositionJob(input: StartCompositionInput): Reco
format: normalizeFormat(input.format), format: normalizeFormat(input.format),
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp, updatedAt: timestamp,
inputFiles: inputSets.videoInputs.concat(inputSets.audioInputs).map((file) => file.filename) inputFiles: inputSets.videoInputs.concat(inputSets.audioInputs).map((file) => file.filename),
host: input.host,
participants: input.participants
}; };
jobs.set(job.id, job); jobs.set(job.id, job);
runRecordingCompositionJob(job); runRecordingCompositionJob(job);

View File

@@ -18,6 +18,17 @@ export type ServerTrackRecordingFile = ServerTrackRecordingTarget & {
metadata: any; metadata: any;
}; };
export type RecordingPerson = {
participantId?: string;
userId?: string;
id?: string;
name?: string;
avatar?: string;
role?: string;
status?: string;
mediaState?: any;
};
type CreateTargetInput = { type CreateTargetInput = {
recordingId: string; recordingId: string;
connectionId: string; connectionId: string;
@@ -43,6 +54,8 @@ type WriteComposedMetadataInput = {
inputs: ServerTrackRecordingFile[]; inputs: ServerTrackRecordingFile[];
layout: string; layout: string;
format: string; format: string;
host?: RecordingPerson;
participants?: RecordingPerson[];
}; };
export function getRecordingRoot(): string { export function getRecordingRoot(): string {
@@ -216,20 +229,91 @@ export function deleteServerTrackRecordingFiles(files: ServerTrackRecordingFile[
return deletedFiles; return deletedFiles;
} }
export function writeComposedRecordingMetadata(input: WriteComposedMetadataInput): void { function getPersonKey(person: RecordingPerson | undefined): string {
const now = new Date().toISOString(); return person?.participantId || person?.userId || person?.id || '';
const participantsById: { [participantId: string]: any } = {}; }
input.inputs.forEach((file) => {
if (!file.participantId || participantsById[file.participantId]) { 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; return;
} }
participantsById[file.participantId] = { 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, participantId: file.participantId,
id: file.participantId, id: file.participantId,
role: 'participant' 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 = { const metadata = {
id: `${input.recordingId}-composed`, id: `${input.recordingId}-composed`,
meetingId: input.target.meetingId, meetingId: input.target.meetingId,
@@ -237,14 +321,9 @@ export function writeComposedRecordingMetadata(input: WriteComposedMetadataInput
originalFilename: `server-recording-${input.recordingId}-composed.${input.format}`, originalFilename: `server-recording-${input.recordingId}-composed.${input.format}`,
mimetype: input.format === 'mp4' ? 'video/mp4' : 'video/webm', mimetype: input.format === 'mp4' ? 'video/mp4' : 'video/webm',
size: fs.existsSync(input.target.filePath) ? fs.statSync(input.target.filePath).size : 0, size: fs.existsSync(input.target.filePath) ? fs.statSync(input.target.filePath).size : 0,
userId: 'server-recorder', userId: host?.userId || host?.id || '',
host: { host,
userId: 'server-recorder', participants,
id: 'server-recorder',
name: 'Server Recorder',
role: 'recorder'
},
participants: Object.keys(participantsById).map((participantId) => participantsById[participantId]),
uploadedAt: now, uploadedAt: now,
updatedAt: now, updatedAt: now,
recordingSource: 'server-composed', recordingSource: 'server-composed',

View File

@@ -326,6 +326,37 @@ function getActiveRecordingSession(connectionId: string) {
return null; 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);
@@ -561,12 +592,15 @@ export const createServer = (config: Options): express.Express => {
} }
const shouldCompose = req.query.compose !== 'false'; const shouldCompose = req.query.compose !== 'false';
const roomPeople = getRecordingRoomPeople(session.connectionId);
const compositionJob = shouldCompose const compositionJob = shouldCompose
? startRecordingCompositionJob({ ? startRecordingCompositionJob({
meetingId: session.connectionId, meetingId: session.connectionId,
recordingId: session.id, recordingId: session.id,
layout: session.layout, layout: session.layout,
format: session.format format: session.format,
host: roomPeople.host,
participants: roomPeople.participants
}) })
: null; : null;
res.json({ success: true, session, agent, notified, compositionJob }); res.json({ success: true, session, agent, notified, compositionJob });
@@ -602,7 +636,8 @@ export const createServer = (config: Options): express.Express => {
meetingId, meetingId,
recordingId, recordingId,
layout: req.body.layout, layout: req.body.layout,
format: req.body.format format: req.body.format,
...getRecordingRoomPeople(meetingId)
}); });
res.status(202).json({ success: true, job }); res.status(202).json({ success: true, job });
}); });

View File

@@ -104,6 +104,24 @@ describe('recording storage', () => {
recordingId: 'recording-1', recordingId: 'recording-1',
layout: 'grid', layout: 'grid',
format: 'webm', 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: [ inputs: [
{ {
...target, ...target,
@@ -127,5 +145,19 @@ describe('recording storage', () => {
layout: 'grid', layout: 'grid',
inputFiles: ['p1-video.webm'] 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'
})
]);
}); });
}); });