服务器录屏1
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user