服务器录屏1
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,19 +229,90 @@ export function deleteServerTrackRecordingFiles(files: ServerTrackRecordingFile[
|
|||||||
return deletedFiles;
|
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 {
|
export function writeComposedRecordingMetadata(input: WriteComposedMetadataInput): void {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const participantsById: { [participantId: string]: any } = {};
|
const inputPeople = collectInputPeople(input.inputs);
|
||||||
input.inputs.forEach((file) => {
|
const host = normalizeRecordingPerson(input.host, 'host') || inputPeople.host;
|
||||||
if (!file.participantId || participantsById[file.participantId]) {
|
const participants = Array.isArray(input.participants) && input.participants.length > 0
|
||||||
return;
|
? input.participants
|
||||||
}
|
.map((participant) => normalizeRecordingPerson(participant, 'participant'))
|
||||||
participantsById[file.participantId] = {
|
.filter((participant) => Boolean(participant)) as RecordingPerson[]
|
||||||
participantId: file.participantId,
|
: inputPeople.participants;
|
||||||
id: file.participantId,
|
|
||||||
role: 'participant'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
id: `${input.recordingId}-composed`,
|
id: `${input.recordingId}-composed`,
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'
|
||||||
|
})
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user