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:
CSantosM 2026-01-28 18:14:29 +01:00
parent 2fe720c90b
commit 1add921ce0
9 changed files with 133 additions and 11 deletions

View File

@ -9,3 +9,22 @@ content:
type: string
description: The unique identifier of the room to record.
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.

View File

@ -8,6 +8,7 @@ content:
roomId: 'room-123'
roomName: 'room'
status: 'active'
layout: 'speaker'
filename: 'room-123--XX445.mp4'
startDate: 1600000000000
headers:

View File

@ -14,6 +14,7 @@ content:
roomId: 'room-123'
roomName: 'room'
status: 'ending'
layout: 'speaker'
filename: 'room-123--XX445.mp4'
startDate: 1600000000000
details: 'End reason: StopEgress API'

View File

@ -4,6 +4,14 @@
summary: Start a recording
description: >
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:
- OpenVidu Meet - Recordings
security:

View File

@ -18,11 +18,11 @@ import { getBaseUrl } from '../utils/url.utils.js';
export const startRecording = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const recordingService = container.get(RecordingService);
const { roomId } = req.body;
const { roomId, config } = req.body;
logger.info(`Starting recording in room '${roomId}'`);
try {
const recordingInfo = await recordingService.startRecording(roomId);
const recordingInfo = await recordingService.startRecording(roomId, config);
res.setHeader(
'Location',
`${getBaseUrl()}${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingInfo.recordingId}`

View File

@ -1,4 +1,4 @@
import { MeetRecordingFilters, MeetRecordingStatus } from '@openvidu-meet/typings';
import { MeetRecordingFilters, MeetRecordingLayout, MeetRecordingStatus } from '@openvidu-meet/typings';
import { z } from 'zod';
import { nonEmptySanitizedRoomId } from './room.schema.js';
@ -50,7 +50,10 @@ export const nonEmptySanitizedRecordingId = (fieldName: string) =>
);
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({

View File

@ -1,6 +1,7 @@
import {
MeetRecordingFilters,
MeetRecordingInfo,
MeetRecordingLayout,
MeetRecordingStatus,
MeetRoom,
MeetRoomConfig
@ -51,7 +52,10 @@ export class RecordingService {
@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 eventListener!: (info: Record<string, unknown>) => void;
let recordingId = '';
@ -106,7 +110,7 @@ export class RecordingService {
const startRecordingPromise = (async (): Promise<MeetRecordingInfo> => {
try {
const options = this.generateCompositeOptionsFromRequest(room.config);
const options = this.generateCompositeOptionsFromRequest(room.config, configOverride);
const output = this.generateFileOutputFromRequest(roomId);
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.
* If configOverride is provided, its values will take precedence over room configuration.
*
* @param roomConfig The room configuration
* @param configOverride Optional configuration override from the request
* @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 {
layout: recording.layout
layout
// customBaseUrl: customLayout,
// audioOnly: false,
// videoOnly: false

View File

@ -588,13 +588,19 @@ export const endMeeting = async (roomId: string, moderatorToken: string) => {
return response;
};
export const startRecording = async (roomId: string) => {
export const startRecording = async (roomId: string, config?: { layout?: string }) => {
checkAppIsRunning();
const body: { roomId: string; config?: { layout?: string } } = { roomId };
if (config) {
body.config = config;
}
return await request(app)
.post(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`)
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
.send({ roomId });
.send(body);
};
export const stopRecording = async (recordingId: string) => {

View File

@ -1,5 +1,5 @@
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 { setInternalConfig } from '../../../../src/config/internal-config.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);
});
});
});