From 59fc4be5cc9b40f2d1dda0211c103dd426233a5e Mon Sep 17 00:00:00 2001 From: stary <834207172@qq.com> Date: Tue, 2 Jun 2026 02:49:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=BD=95=E5=B1=8F1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/public/recordings/recordings-admin.js | 4 +- client/public/styles/style.css | 30 ++++- src/class/websockethandler.ts | 40 ++++++- src/recording/composer.ts | 13 ++- src/recording/storage.ts | 117 ++++++++++++++++--- src/server.ts | 39 ++++++- test/recording-storage.test.ts | 32 +++++ 7 files changed, 248 insertions(+), 27 deletions(-) diff --git a/client/public/recordings/recordings-admin.js b/client/public/recordings/recordings-admin.js index b0e507c..f2aeeb0 100644 --- a/client/public/recordings/recordings-admin.js +++ b/client/public/recordings/recordings-admin.js @@ -100,11 +100,11 @@ function formatDate(value) { } function getPersonId(person) { - return person?.userId || person?.id || ''; + return person?.userId || person?.id || person?.participantId || ''; } function getPersonName(person) { - return person?.name || getPersonId(person) || '-'; + return person?.name || person?.displayName || getPersonId(person) || '-'; } function getRecordingHost(recording) { diff --git a/client/public/styles/style.css b/client/public/styles/style.css index 0aa58d6..f745179 100644 --- a/client/public/styles/style.css +++ b/client/public/styles/style.css @@ -448,6 +448,7 @@ body { display: grid; grid-template-columns: 280px minmax(420px, 1fr) 360px; gap: 16px; + overflow: hidden; } .recordings-upload, @@ -612,7 +613,33 @@ body { .recordings-table-wrap { flex: 1; min-height: 0; - overflow: auto; + max-height: 100%; + overflow-x: auto; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.recordings-table-wrap::-webkit-scrollbar, +.recordings-preview-meta::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.recordings-table-wrap::-webkit-scrollbar-track, +.recordings-preview-meta::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.58); +} + +.recordings-table-wrap::-webkit-scrollbar-thumb, +.recordings-preview-meta::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.32); + border-radius: 999px; +} + +.recordings-table-wrap::-webkit-scrollbar-thumb:hover, +.recordings-preview-meta::-webkit-scrollbar-thumb:hover { + background: rgba(165, 180, 252, 0.52); } .recordings-table { @@ -900,6 +927,7 @@ body { .recordings-preview { display: flex; flex-direction: column; + overflow: visible; } .recordings-list, diff --git a/src/class/websockethandler.ts b/src/class/websockethandler.ts index 02a90ee..b21d5ab 100644 --- a/src/class/websockethandler.ts +++ b/src/class/websockethandler.ts @@ -10,6 +10,7 @@ import { RecordingSession, listRecordingSessions, stopRecordingSession } from '. 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'; /** * 是否为私有模式 @@ -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 { + const roomPeople = getRecordingRoomPeople(connectionId); getActiveRecordingSessions(connectionId).forEach((session) => { const stoppedSession = stopRecordingSession(session.id); if (stoppedSession) { @@ -376,7 +412,9 @@ function stopActiveRecordingSessions(connectionId: string): void { meetingId: session.connectionId, recordingId: session.id, layout: session.layout, - format: session.format + format: session.format, + host: roomPeople.host, + participants: roomPeople.participants }); }) .catch((error) => { diff --git a/src/recording/composer.ts b/src/recording/composer.ts index c28ddff..0c987ba 100644 --- a/src/recording/composer.ts +++ b/src/recording/composer.ts @@ -1,6 +1,7 @@ import { spawn } from 'child_process'; import { v4 as uuid } from 'uuid'; import { + RecordingPerson, ServerTrackRecordingFile, ServerTrackRecordingTarget, createComposedRecordingTarget, @@ -25,6 +26,8 @@ export type RecordingCompositionJob = { failedAt?: string; error?: string; inputFiles: string[]; + host?: RecordingPerson; + participants?: RecordingPerson[]; deletedInputFiles?: string[]; output?: { meetingId: string; @@ -41,6 +44,8 @@ type StartCompositionInput = { recordingId: string; layout?: string; format?: string; + host?: RecordingPerson; + participants?: RecordingPerson[]; }; type CompositionInputSets = { @@ -267,7 +272,9 @@ async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise recordingId: job.recordingId, inputs: compositionInputs, layout: job.layout, - format: job.format + format: job.format, + host: job.host, + participants: job.participants }); const deletedInputFiles = deleteServerTrackRecordingFiles(compositionInputs); @@ -301,7 +308,9 @@ export function startRecordingCompositionJob(input: StartCompositionInput): Reco format: normalizeFormat(input.format), createdAt: 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); runRecordingCompositionJob(job); diff --git a/src/recording/storage.ts b/src/recording/storage.ts index 8812673..0a18f1d 100644 --- a/src/recording/storage.ts +++ b/src/recording/storage.ts @@ -18,6 +18,17 @@ export type ServerTrackRecordingFile = ServerTrackRecordingTarget & { 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; @@ -43,6 +54,8 @@ type WriteComposedMetadataInput = { inputs: ServerTrackRecordingFile[]; layout: string; format: string; + host?: RecordingPerson; + participants?: RecordingPerson[]; }; export function getRecordingRoot(): string { @@ -216,19 +229,90 @@ export function deleteServerTrackRecordingFiles(files: ServerTrackRecordingFile[ 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 participantsById: { [participantId: string]: any } = {}; - input.inputs.forEach((file) => { - if (!file.participantId || participantsById[file.participantId]) { - return; - } - participantsById[file.participantId] = { - participantId: file.participantId, - id: file.participantId, - role: 'participant' - }; - }); + 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`, @@ -237,14 +321,9 @@ export function writeComposedRecordingMetadata(input: WriteComposedMetadataInput 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: 'server-recorder', - host: { - userId: 'server-recorder', - id: 'server-recorder', - name: 'Server Recorder', - role: 'recorder' - }, - participants: Object.keys(participantsById).map((participantId) => participantsById[participantId]), + userId: host?.userId || host?.id || '', + host, + participants, uploadedAt: now, updatedAt: now, recordingSource: 'server-composed', diff --git a/src/server.ts b/src/server.ts index b6df75c..b926033 100644 --- a/src/server.ts +++ b/src/server.ts @@ -326,6 +326,37 @@ function getActiveRecordingSession(connectionId: string) { 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 => { const app: express.Express = express(); resetHandler(config.mode); @@ -561,12 +592,15 @@ export const createServer = (config: Options): express.Express => { } 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 + format: session.format, + host: roomPeople.host, + participants: roomPeople.participants }) : null; res.json({ success: true, session, agent, notified, compositionJob }); @@ -602,7 +636,8 @@ export const createServer = (config: Options): express.Express => { meetingId, recordingId, layout: req.body.layout, - format: req.body.format + format: req.body.format, + ...getRecordingRoomPeople(meetingId) }); res.status(202).json({ success: true, job }); }); diff --git a/test/recording-storage.test.ts b/test/recording-storage.test.ts index a251b07..4508600 100644 --- a/test/recording-storage.test.ts +++ b/test/recording-storage.test.ts @@ -104,6 +104,24 @@ describe('recording storage', () => { 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, @@ -127,5 +145,19 @@ describe('recording storage', () => { 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' + }) + ]); }); });