backend: Allows overriding recording layout
Enables users to override the default recording layout for a room when starting a recording. This allows customization of the recording appearance on a per-recording basis, instead of being tied solely to the room's configuration.
This commit is contained in:
parent
2fe720c90b
commit
1add921ce0
@ -9,3 +9,22 @@ content:
|
|||||||
type: string
|
type: string
|
||||||
description: The unique identifier of the room to record.
|
description: The unique identifier of the room to record.
|
||||||
example: 'room-123'
|
example: 'room-123'
|
||||||
|
config:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Optional configuration to override the room's recording configuration for this specific recording.
|
||||||
|
If not provided, the recording will use the configuration defined in the room's config.
|
||||||
|
properties:
|
||||||
|
layout:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- grid
|
||||||
|
- speaker
|
||||||
|
- single-speaker
|
||||||
|
example: speaker
|
||||||
|
description: |
|
||||||
|
Defines the layout of the recording. This will override the room's default recording layout.
|
||||||
|
Options are:
|
||||||
|
- `grid`: All participants are shown in a grid layout.
|
||||||
|
- `speaker`: The active speaker is shown prominently, with other participants in smaller thumbnails.
|
||||||
|
- `single-speaker`: Only the active speaker is shown in the recording.
|
||||||
|
|||||||
@ -8,6 +8,7 @@ content:
|
|||||||
roomId: 'room-123'
|
roomId: 'room-123'
|
||||||
roomName: 'room'
|
roomName: 'room'
|
||||||
status: 'active'
|
status: 'active'
|
||||||
|
layout: 'speaker'
|
||||||
filename: 'room-123--XX445.mp4'
|
filename: 'room-123--XX445.mp4'
|
||||||
startDate: 1600000000000
|
startDate: 1600000000000
|
||||||
headers:
|
headers:
|
||||||
|
|||||||
@ -14,6 +14,7 @@ content:
|
|||||||
roomId: 'room-123'
|
roomId: 'room-123'
|
||||||
roomName: 'room'
|
roomName: 'room'
|
||||||
status: 'ending'
|
status: 'ending'
|
||||||
|
layout: 'speaker'
|
||||||
filename: 'room-123--XX445.mp4'
|
filename: 'room-123--XX445.mp4'
|
||||||
startDate: 1600000000000
|
startDate: 1600000000000
|
||||||
details: 'End reason: StopEgress API'
|
details: 'End reason: StopEgress API'
|
||||||
|
|||||||
@ -4,6 +4,14 @@
|
|||||||
summary: Start a recording
|
summary: Start a recording
|
||||||
description: >
|
description: >
|
||||||
Start a new recording for an OpenVidu Meet room with the specified room ID.
|
Start a new recording for an OpenVidu Meet room with the specified room ID.
|
||||||
|
|
||||||
|
|
||||||
|
By default, the recording will use the configuration defined in the room's settings.
|
||||||
|
However, you can optionally provide a configuration override in the request body to customize
|
||||||
|
the recording settings (e.g., layout) for this specific recording session.
|
||||||
|
|
||||||
|
|
||||||
|
If a configuration override is provided, those values will take precedence over the room's configuration.
|
||||||
tags:
|
tags:
|
||||||
- OpenVidu Meet - Recordings
|
- OpenVidu Meet - Recordings
|
||||||
security:
|
security:
|
||||||
|
|||||||
@ -18,11 +18,11 @@ import { getBaseUrl } from '../utils/url.utils.js';
|
|||||||
export const startRecording = async (req: Request, res: Response) => {
|
export const startRecording = async (req: Request, res: Response) => {
|
||||||
const logger = container.get(LoggerService);
|
const logger = container.get(LoggerService);
|
||||||
const recordingService = container.get(RecordingService);
|
const recordingService = container.get(RecordingService);
|
||||||
const { roomId } = req.body;
|
const { roomId, config } = req.body;
|
||||||
logger.info(`Starting recording in room '${roomId}'`);
|
logger.info(`Starting recording in room '${roomId}'`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const recordingInfo = await recordingService.startRecording(roomId);
|
const recordingInfo = await recordingService.startRecording(roomId, config);
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Location',
|
'Location',
|
||||||
`${getBaseUrl()}${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingInfo.recordingId}`
|
`${getBaseUrl()}${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingInfo.recordingId}`
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { MeetRecordingFilters, MeetRecordingStatus } from '@openvidu-meet/typings';
|
import { MeetRecordingFilters, MeetRecordingLayout, MeetRecordingStatus } from '@openvidu-meet/typings';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { nonEmptySanitizedRoomId } from './room.schema.js';
|
import { nonEmptySanitizedRoomId } from './room.schema.js';
|
||||||
|
|
||||||
@ -50,7 +50,10 @@ export const nonEmptySanitizedRecordingId = (fieldName: string) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const StartRecordingReqSchema = z.object({
|
export const StartRecordingReqSchema = z.object({
|
||||||
roomId: nonEmptySanitizedRoomId('roomId')
|
roomId: nonEmptySanitizedRoomId('roomId'),
|
||||||
|
config: z.object({
|
||||||
|
layout: z.nativeEnum(MeetRecordingLayout).optional()
|
||||||
|
}).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const RecordingFiltersSchema: z.ZodType<MeetRecordingFilters> = z.object({
|
export const RecordingFiltersSchema: z.ZodType<MeetRecordingFilters> = z.object({
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
MeetRecordingFilters,
|
MeetRecordingFilters,
|
||||||
MeetRecordingInfo,
|
MeetRecordingInfo,
|
||||||
|
MeetRecordingLayout,
|
||||||
MeetRecordingStatus,
|
MeetRecordingStatus,
|
||||||
MeetRoom,
|
MeetRoom,
|
||||||
MeetRoomConfig
|
MeetRoomConfig
|
||||||
@ -51,7 +52,10 @@ export class RecordingService {
|
|||||||
@inject(LoggerService) protected logger: LoggerService
|
@inject(LoggerService) protected logger: LoggerService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async startRecording(roomId: string): Promise<MeetRecordingInfo> {
|
async startRecording(
|
||||||
|
roomId: string,
|
||||||
|
configOverride?: { layout?: MeetRecordingLayout }
|
||||||
|
): Promise<MeetRecordingInfo> {
|
||||||
let acquiredLock: RedisLock | null = null;
|
let acquiredLock: RedisLock | null = null;
|
||||||
let eventListener!: (info: Record<string, unknown>) => void;
|
let eventListener!: (info: Record<string, unknown>) => void;
|
||||||
let recordingId = '';
|
let recordingId = '';
|
||||||
@ -106,7 +110,7 @@ export class RecordingService {
|
|||||||
|
|
||||||
const startRecordingPromise = (async (): Promise<MeetRecordingInfo> => {
|
const startRecordingPromise = (async (): Promise<MeetRecordingInfo> => {
|
||||||
try {
|
try {
|
||||||
const options = this.generateCompositeOptionsFromRequest(room.config);
|
const options = this.generateCompositeOptionsFromRequest(room.config, configOverride);
|
||||||
const output = this.generateFileOutputFromRequest(roomId);
|
const output = this.generateFileOutputFromRequest(roomId);
|
||||||
const egressInfo = await this.livekitService.startRoomComposite(roomId, output, options);
|
const egressInfo = await this.livekitService.startRoomComposite(roomId, output, options);
|
||||||
|
|
||||||
@ -700,13 +704,20 @@ export class RecordingService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates composite options for recording based on the provided room configuration.
|
* Generates composite options for recording based on the provided room configuration.
|
||||||
|
* If configOverride is provided, its values will take precedence over room configuration.
|
||||||
*
|
*
|
||||||
* @param roomConfig The room configuration
|
* @param roomConfig The room configuration
|
||||||
|
* @param configOverride Optional configuration override from the request
|
||||||
* @returns The generated RoomCompositeOptions object.
|
* @returns The generated RoomCompositeOptions object.
|
||||||
*/
|
*/
|
||||||
protected generateCompositeOptionsFromRequest({ recording }: MeetRoomConfig): RoomCompositeOptions {
|
protected generateCompositeOptionsFromRequest(
|
||||||
|
roomConfig: MeetRoomConfig,
|
||||||
|
configOverride?: { layout?: MeetRecordingLayout }
|
||||||
|
): RoomCompositeOptions {
|
||||||
|
const roomRecordingConfig = roomConfig.recording;
|
||||||
|
const layout = configOverride?.layout ?? roomRecordingConfig.layout;
|
||||||
return {
|
return {
|
||||||
layout: recording.layout
|
layout
|
||||||
// customBaseUrl: customLayout,
|
// customBaseUrl: customLayout,
|
||||||
// audioOnly: false,
|
// audioOnly: false,
|
||||||
// videoOnly: false
|
// videoOnly: false
|
||||||
|
|||||||
@ -588,13 +588,19 @@ export const endMeeting = async (roomId: string, moderatorToken: string) => {
|
|||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const startRecording = async (roomId: string) => {
|
export const startRecording = async (roomId: string, config?: { layout?: string }) => {
|
||||||
checkAppIsRunning();
|
checkAppIsRunning();
|
||||||
|
|
||||||
|
const body: { roomId: string; config?: { layout?: string } } = { roomId };
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
body.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
return await request(app)
|
return await request(app)
|
||||||
.post(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`)
|
.post(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`)
|
||||||
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
|
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
|
||||||
.send({ roomId });
|
.send(body);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stopRecording = async (recordingId: string) => {
|
export const stopRecording = async (recordingId: string) => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from '@jest/globals';
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from '@jest/globals';
|
||||||
import { MeetRoom } from '@openvidu-meet/typings';
|
import { MeetRecordingLayout, MeetRoom } from '@openvidu-meet/typings';
|
||||||
import { container } from '../../../../src/config/dependency-injector.config.js';
|
import { container } from '../../../../src/config/dependency-injector.config.js';
|
||||||
import { setInternalConfig } from '../../../../src/config/internal-config.js';
|
import { setInternalConfig } from '../../../../src/config/internal-config.js';
|
||||||
import { errorRoomNotFound } from '../../../../src/models/error.model.js';
|
import { errorRoomNotFound } from '../../../../src/models/error.model.js';
|
||||||
@ -207,4 +207,77 @@ describe('Recording API Tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Start Recording with Config Override', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create a room and join a participant
|
||||||
|
context = await setupMultiRoomTestContext(1, true);
|
||||||
|
({ room } = context.getRoomByIndex(0)!);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await disconnectFakeParticipants();
|
||||||
|
await stopAllRecordings();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await disconnectFakeParticipants();
|
||||||
|
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
|
||||||
|
context = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start recording with default room layout when no config override is provided', async () => {
|
||||||
|
const response = await startRecording(room.roomId);
|
||||||
|
const recordingId = response.body.recordingId;
|
||||||
|
|
||||||
|
expectValidStartRecordingResponse(response, room.roomId, room.roomName);
|
||||||
|
// Verify the recording uses the room's default layout (grid)
|
||||||
|
expect(response.body.layout).toBe(MeetRecordingLayout.GRID);
|
||||||
|
|
||||||
|
const stopResponse = await stopRecording(recordingId);
|
||||||
|
expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId, room.roomName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should override room layout when config with layout is provided', async () => {
|
||||||
|
const response = await startRecording(room.roomId, { layout: MeetRecordingLayout.SPEAKER });
|
||||||
|
const recordingId = response.body.recordingId;
|
||||||
|
|
||||||
|
expectValidStartRecordingResponse(response, room.roomId, room.roomName);
|
||||||
|
// Verify the recording uses the overridden layout
|
||||||
|
expect(response.body.layout).toBe(MeetRecordingLayout.SPEAKER);
|
||||||
|
|
||||||
|
const stopResponse = await stopRecording(recordingId);
|
||||||
|
expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId, room.roomName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should override room layout with single-speaker layout', async () => {
|
||||||
|
const response = await startRecording(room.roomId, { layout: MeetRecordingLayout.SINGLE_SPEAKER });
|
||||||
|
const recordingId = response.body.recordingId;
|
||||||
|
|
||||||
|
expectValidStartRecordingResponse(response, room.roomId, room.roomName);
|
||||||
|
// Verify the recording uses the overridden layout
|
||||||
|
expect(response.body.layout).toBe(MeetRecordingLayout.SINGLE_SPEAKER);
|
||||||
|
|
||||||
|
const stopResponse = await stopRecording(recordingId);
|
||||||
|
expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId, room.roomName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid layout in config override', async () => {
|
||||||
|
const response = await startRecording(room.roomId, { layout: 'invalid-layout' });
|
||||||
|
|
||||||
|
expectValidationError(response, 'config.layout', 'Invalid enum value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept empty config object and use room defaults', async () => {
|
||||||
|
const response = await startRecording(room.roomId, {});
|
||||||
|
const recordingId = response.body.recordingId;
|
||||||
|
|
||||||
|
expectValidStartRecordingResponse(response, room.roomId, room.roomName);
|
||||||
|
// Verify the recording uses the room's default layout
|
||||||
|
expect(response.body.layout).toBe(MeetRecordingLayout.GRID);
|
||||||
|
|
||||||
|
const stopResponse = await stopRecording(recordingId);
|
||||||
|
expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId, room.roomName);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user