diff --git a/meet-ce/backend/openapi/components/requestBodies/start-recording-request.yaml b/meet-ce/backend/openapi/components/requestBodies/start-recording-request.yaml
index 123bfd8c..3e23287b 100644
--- a/meet-ce/backend/openapi/components/requestBodies/start-recording-request.yaml
+++ b/meet-ce/backend/openapi/components/requestBodies/start-recording-request.yaml
@@ -28,3 +28,8 @@ content:
- `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.
+ encoding:
+ description: Defines the encoding settings for the recording. This will override the room's default recording encoding.
+ oneOf:
+ - $ref: '../schemas/meet-room-config.yaml#/MeetRecordingEncodingPreset'
+ - $ref: '../schemas/meet-room-config.yaml#/MeetRecordingEncodingOptions'
diff --git a/meet-ce/backend/openapi/components/requestBodies/update-room-config-request.yaml b/meet-ce/backend/openapi/components/requestBodies/update-room-config-request.yaml
index bd7576c3..20120bc4 100644
--- a/meet-ce/backend/openapi/components/requestBodies/update-room-config-request.yaml
+++ b/meet-ce/backend/openapi/components/requestBodies/update-room-config-request.yaml
@@ -9,7 +9,8 @@ content:
example:
config:
recording:
- enabled: false
+ enabled: true
+ encoding: H264_720P_30
chat:
enabled: true
virtualBackground:
diff --git a/meet-ce/backend/openapi/components/responses/success-get-room.yaml b/meet-ce/backend/openapi/components/responses/success-get-room.yaml
index 5d3cdf4b..c6350cb6 100644
--- a/meet-ce/backend/openapi/components/responses/success-get-room.yaml
+++ b/meet-ce/backend/openapi/components/responses/success-get-room.yaml
@@ -19,6 +19,7 @@ content:
recording:
enabled: false
layout: grid
+ encoding: H264_720P_30
allowAccessTo: admin_moderator_speaker
chat:
enabled: true
@@ -83,6 +84,15 @@ content:
recording:
enabled: false
layout: grid
+ encoding:
+ video:
+ width: 1920
+ height: 1080
+ framerate: 30
+ codec: H264_MAIN
+ audio:
+ codec: OPUS
+ bitrate: 128
allowAccessTo: admin_moderator_speaker
chat:
enabled: true
diff --git a/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml b/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml
index 66463add..6166494b 100644
--- a/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml
+++ b/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml
@@ -28,6 +28,7 @@ content:
recording:
enabled: false
layout: grid
+ encoding: H264_720P_30
allowAccessTo: admin_moderator_speaker
chat:
enabled: true
@@ -92,6 +93,15 @@ content:
recording:
enabled: true
layout: grid
+ encoding:
+ video:
+ width: 1280
+ height: 720
+ framerate: 60
+ codec: H264_HIGH
+ audio:
+ codec: AAC
+ bitrate: 192
allowAccessTo: admin_moderator_speaker
chat:
enabled: false
diff --git a/meet-ce/backend/openapi/components/schemas/meet-recording.yaml b/meet-ce/backend/openapi/components/schemas/meet-recording.yaml
index bf265e75..29dd6b8c 100644
--- a/meet-ce/backend/openapi/components/schemas/meet-recording.yaml
+++ b/meet-ce/backend/openapi/components/schemas/meet-recording.yaml
@@ -26,6 +26,54 @@ properties:
type: string
example: 'grid'
description: The layout of the recording.
+ encoding:
+ oneOf:
+ - type: string
+ enum: ['H264_720P_30', 'H264_720P_60', 'H264_1080P_30', 'H264_1080P_60', 'PORTRAIT_H264_720P_30', 'PORTRAIT_H264_720P_60', 'PORTRAIT_H264_1080P_30', 'PORTRAIT_H264_1080P_60']
+ description: Encoding preset
+ - type: object
+ properties:
+ video:
+ type: object
+ properties:
+ width:
+ type: integer
+ example: 1920
+ height:
+ type: integer
+ example: 1080
+ framerate:
+ type: integer
+ example: 30
+ codec:
+ type: string
+ enum: ['DEFAULT_VC', 'H264_BASELINE', 'H264_MAIN', 'H264_HIGH', 'VP8']
+ bitrate:
+ type: integer
+ example: 4500
+ keyFrameInterval:
+ type: number
+ example: 2
+ depth:
+ type: integer
+ example: 24
+ audio:
+ type: object
+ properties:
+ codec:
+ type: string
+ enum: ['DEFAULT_AC', 'OPUS', 'AAC', 'AC_MP3']
+ bitrate:
+ type: integer
+ example: 128
+ frequency:
+ type: integer
+ example: 48000
+ description: Advanced encoding options
+ description: |
+ The encoding configuration used for this recording.
+ Can be either a preset string or advanced encoding options.
+ example: 'H264_720P_30'
filename:
type: string
example: 'room-123--XX445.mp4'
diff --git a/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml b/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml
index d0e03fac..ee3a446a 100644
--- a/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml
+++ b/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml
@@ -51,6 +51,10 @@ MeetRecordingConfig:
# - `grid-light`: Similar to `grid` but with a light-themed background.
# - `speaker-light`: Similar to `speaker` but with a light-themed background.
# - `single-speaker-light`: Similar to `single-speaker` but with a light-themed background.
+ encoding:
+ oneOf:
+ - $ref: '#/MeetRecordingEncodingPreset'
+ - $ref: '#/MeetRecordingEncodingOptions'
allowAccessTo:
type: string
enum:
@@ -93,3 +97,163 @@ MeetCaptionsConfig:
description: >
If true, the room will have live captions enabled.
This allows participants to see real-time captions of the all participants' speech during the meeting.
+MeetRecordingEncodingPreset:
+ type: string
+ enum:
+ - H264_720P_30
+ - H264_720P_60
+ - H264_1080P_30
+ - H264_1080P_60
+ - PORTRAIT_H264_720P_30
+ - PORTRAIT_H264_720P_60
+ - PORTRAIT_H264_1080P_30
+ - PORTRAIT_H264_1080P_60
+ description: |
+ Predefined encoding presets for recordings. Each preset defines a combination of resolution, frame rate, and codec:
+ - `H264_720P_30`: 1280x720, 30fps, 3000kbps, H.264_MAIN / OPUS **(default)**
+ - `H264_720P_60`: 1280x720, 60fps, 4500kbps, H.264_MAIN / OPUS
+ - `H264_1080P_30`: 1920x1080, 30fps, 4500kbps, H.264_MAIN / OPUS
+ - `H264_1080P_60`: 1920x1080, 60fps, 6000kbps, H.264_MAIN / OPUS
+ - `PORTRAIT_H264_720P_30`: 720x1280, 30fps, 3000kbps, H.264_MAIN / OPUS
+ - `PORTRAIT_H264_720P_60`: 720x1280, 60fps, 4500kbps, H.264_MAIN / OPUS
+ - `PORTRAIT_H264_1080P_30`: 1080x1920, 30fps, 4500kbps, H.264_MAIN / OPUS
+ - `PORTRAIT_H264_1080P_60`: 1080x1920, 60fps, 6000kbps, H.264_MAIN / OPUS
+ example: H264_720P_30
+MeetRecordingVideoCodec:
+ type: string
+ enum:
+ - DEFAULT_VC
+ - H264_BASELINE
+ - H264_MAIN
+ - H264_HIGH
+ - VP8
+ description: |
+ Video codec options for recording encoding:
+ - `DEFAULT_VC`: Use the default video codec (H.264_MAIN)
+ - `H264_BASELINE`: H.264 Baseline profile
+ - `H264_MAIN`: H.264 Main profile
+ - `H264_HIGH`: H.264 High profile
+ - `VP8`: VP8 codec
+ example: H264_MAIN
+MeetRecordingAudioCodec:
+ type: string
+ enum:
+ - DEFAULT_AC
+ - OPUS
+ - AAC
+ - AC_MP3
+ description: |
+ Audio codec options for recording encoding:
+ - `DEFAULT_AC`: Use the default audio codec (OPUS)
+ - `OPUS`: Opus codec
+ - `AAC`: AAC codec
+ - `AC_MP3`: MP3 codec
+ example: OPUS
+MeetRecordingVideoEncodingOptions:
+ type: object
+ required:
+ - width
+ - height
+ - framerate
+ - codec
+ - bitrate
+ - keyFrameInterval
+ - depth
+ properties:
+ width:
+ type: integer
+ minimum: 1
+ example: 1280
+ description: |
+ Video width in pixels
+ height:
+ type: integer
+ minimum: 1
+ example: 720
+ description: |
+ Video height in pixels
+ framerate:
+ type: integer
+ minimum: 1
+ example: 30
+ description: |
+ Frame rate in fps
+ codec:
+ $ref: '#/MeetRecordingVideoCodec'
+ description: |
+ Video codec
+ bitrate:
+ type: integer
+ minimum: 1
+ example: 4500
+ description: |
+ Video bitrate in kbps
+ keyframeInterval:
+ type: number
+ minimum: 0
+ example: 4
+ description: |
+ Keyframe interval in seconds
+ depth:
+ type: integer
+ minimum: 1
+ example: 24
+ description: |
+ Video depth (pixel format) in bits
+ description: |
+ Advanced video encoding options for recordings.
+MeetRecordingAudioEncodingOptions:
+ type: object
+ required:
+ - codec
+ - bitrate
+ - frequency
+ properties:
+ codec:
+ $ref: '#/MeetRecordingAudioCodec'
+ description: |
+ Audio codec (required when audio is provided)
+ bitrate:
+ type: integer
+ minimum: 1
+ example: 128
+ description: |
+ Audio bitrate in kbps (required when audio is provided)
+ frequency:
+ type: integer
+ minimum: 1
+ example: 44100
+ description: |
+ Audio sample rate in Hz (required when audio is provided)
+ description: |
+ Advanced audio encoding options for recordings.
+ When audio encoding is provided, all fields are required.
+MeetRecordingEncodingOptions:
+ type: object
+ required:
+ - video
+ - audio
+ properties:
+ video:
+ $ref: '#/MeetRecordingVideoEncodingOptions'
+ description: Video encoding configuration
+ audio:
+ $ref: '#/MeetRecordingAudioEncodingOptions'
+ description: Audio encoding configuration
+ description: |
+ Advanced encoding options for recordings.
+ Use this for fine-grained control over video and audio encoding parameters.
+ Both video and audio configurations are required when using advanced options.
+ For common scenarios, consider using encoding presets instead.
+ example:
+ video:
+ width: 1280
+ height: 720
+ framerate: 30
+ codec: H264_MAIN
+ bitrate: 3000
+ keyFrameInterval: 4
+ audio:
+ codec: OPUS
+ bitrate: 128
+ frequency: 44100
diff --git a/meet-ce/backend/src/helpers/encoding-converter.helper.ts b/meet-ce/backend/src/helpers/encoding-converter.helper.ts
new file mode 100644
index 00000000..de40b15d
--- /dev/null
+++ b/meet-ce/backend/src/helpers/encoding-converter.helper.ts
@@ -0,0 +1,192 @@
+import {
+ MeetRecordingAudioCodec,
+ MeetRecordingEncodingOptions,
+ MeetRecordingEncodingPreset,
+ MeetRecordingVideoCodec
+} from '@openvidu-meet/typings';
+import { AudioCodec, EncodingOptions, EncodingOptionsPreset, VideoCodec } from 'livekit-server-sdk';
+
+/**
+ * Helper class for converting encoding configurations between OpenVidu Meet and LiveKit formats.
+ * Provides bidirectional conversion for presets, codecs, and advanced encoding options.
+ */
+export class EncodingConverter {
+ private constructor() {
+ // Prevent instantiation of this utility class
+ }
+
+ // Bidirectional mappings for encoding conversions
+ private static readonly PRESET_MAP = new Map([
+ [MeetRecordingEncodingPreset.H264_720P_30, EncodingOptionsPreset.H264_720P_30],
+ [MeetRecordingEncodingPreset.H264_720P_60, EncodingOptionsPreset.H264_720P_60],
+ [MeetRecordingEncodingPreset.H264_1080P_30, EncodingOptionsPreset.H264_1080P_30],
+ [MeetRecordingEncodingPreset.H264_1080P_60, EncodingOptionsPreset.H264_1080P_60],
+ [MeetRecordingEncodingPreset.PORTRAIT_H264_720P_30, EncodingOptionsPreset.PORTRAIT_H264_720P_30],
+ [MeetRecordingEncodingPreset.PORTRAIT_H264_720P_60, EncodingOptionsPreset.PORTRAIT_H264_720P_60],
+ [MeetRecordingEncodingPreset.PORTRAIT_H264_1080P_30, EncodingOptionsPreset.PORTRAIT_H264_1080P_30],
+ [MeetRecordingEncodingPreset.PORTRAIT_H264_1080P_60, EncodingOptionsPreset.PORTRAIT_H264_1080P_60]
+ ]);
+
+ private static readonly VIDEO_CODEC_MAP = new Map([
+ [MeetRecordingVideoCodec.H264_BASELINE, VideoCodec.H264_BASELINE],
+ [MeetRecordingVideoCodec.H264_MAIN, VideoCodec.H264_MAIN],
+ [MeetRecordingVideoCodec.H264_HIGH, VideoCodec.H264_HIGH],
+ [MeetRecordingVideoCodec.VP8, VideoCodec.VP8]
+ ]);
+
+ private static readonly AUDIO_CODEC_MAP = new Map([
+ [MeetRecordingAudioCodec.OPUS, AudioCodec.OPUS],
+ [MeetRecordingAudioCodec.AAC, AudioCodec.AAC]
+ ]);
+
+ /**
+ * Converts OpenVidu Meet encoding options to LiveKit encoding options.
+ * Used when starting a recording to translate from Meet format to LiveKit SDK format.
+ *
+ * @param encoding - The encoding configuration in OpenVidu Meet format
+ * @returns The encoding options in LiveKit format (preset or advanced)
+ */
+ static toLivekit(
+ encoding: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions | undefined
+ ): EncodingOptions | EncodingOptionsPreset | undefined {
+ if (!encoding) return undefined;
+
+ // If it's a preset string
+ if (typeof encoding === 'string') {
+ return this.convertPresetToLivekit(encoding);
+ }
+
+ // It's advanced encoding options
+ return this.convertAdvancedOptionsToLivekit(encoding);
+ }
+
+ /**
+ * Converts LiveKit encoding options back to OpenVidu Meet format.
+ * Used when receiving webhook information about a recording.
+ *
+ * @param encodingOptions - The encoding options from LiveKit
+ * @returns The encoding configuration in OpenVidu Meet format
+ */
+ static fromLivekit(
+ encodingOptions: EncodingOptions | EncodingOptionsPreset | undefined
+ ): MeetRecordingEncodingPreset | MeetRecordingEncodingOptions | undefined {
+ // When undefined, recording is using default preset but EgressInfo does not specify it.
+ // Return default preset.
+ if (encodingOptions === undefined) return MeetRecordingEncodingPreset.H264_720P_30;
+
+ // If it's a preset (number enum from LiveKit)
+ if (typeof encodingOptions === 'number') {
+ return this.convertPresetFromLivekit(encodingOptions);
+ }
+
+ // It's an EncodingOptions object
+ return this.convertAdvancedOptionsFromLivekit(encodingOptions);
+ }
+
+ /**
+ * Converts OpenVidu Meet encoding preset to LiveKit preset.
+ */
+ private static convertPresetToLivekit(preset: MeetRecordingEncodingPreset): EncodingOptionsPreset {
+ return this.PRESET_MAP.get(preset) ?? EncodingOptionsPreset.H264_720P_30;
+ }
+
+ /**
+ * Converts LiveKit encoding preset to OpenVidu Meet preset.
+ */
+ private static convertPresetFromLivekit(preset: EncodingOptionsPreset): MeetRecordingEncodingPreset {
+ for (const [meetPreset, lkPreset] of this.PRESET_MAP) {
+ if (lkPreset === preset) return meetPreset;
+ }
+
+ return MeetRecordingEncodingPreset.H264_720P_30;
+ }
+
+ /**
+ * Converts OpenVidu Meet advanced encoding options to LiveKit EncodingOptions.
+ */
+ private static convertAdvancedOptionsToLivekit(options: MeetRecordingEncodingOptions): EncodingOptions {
+ const encodingOptions = new EncodingOptions();
+ const { video, audio } = options;
+
+ if (video) {
+ Object.assign(encodingOptions, {
+ width: video.width,
+ height: video.height,
+ framerate: video.framerate,
+ videoBitrate: video.bitrate,
+ videoCodec: this.convertVideoCodecToLivekit(video.codec),
+ keyFrameInterval: video.keyFrameInterval,
+ depth: video.depth
+ });
+ }
+
+ if (audio) {
+ Object.assign(encodingOptions, {
+ audioBitrate: audio.bitrate,
+ audioFrequency: audio.frequency,
+ audioCodec: this.convertAudioCodecToLivekit(audio.codec)
+ });
+ }
+
+ return encodingOptions;
+ }
+
+ /**
+ * Converts LiveKit EncodingOptions to OpenVidu Meet advanced encoding options.
+ */
+ private static convertAdvancedOptionsFromLivekit(options: EncodingOptions): MeetRecordingEncodingOptions {
+ // In Meet, both video and audio are required with all their properties
+ return {
+ video: {
+ width: options.width || 1920,
+ height: options.height || 1080,
+ framerate: options.framerate || 30,
+ codec: this.convertVideoCodecFromLivekit(options.videoCodec),
+ bitrate: options.videoBitrate || 128,
+ keyFrameInterval: options.keyFrameInterval || 4,
+ depth: options.depth || 24 // Use 24 as default when LiveKit returns 0 or undefined
+ },
+ audio: {
+ codec: this.convertAudioCodecFromLivekit(options.audioCodec),
+ bitrate: options.audioBitrate || 128,
+ frequency: options.audioFrequency || 44100
+ }
+ };
+ }
+
+ /**
+ * Converts OpenVidu Meet video codec to LiveKit video codec.
+ */
+ private static convertVideoCodecToLivekit(codec: MeetRecordingVideoCodec): VideoCodec {
+ return this.VIDEO_CODEC_MAP.get(codec) ?? VideoCodec.H264_MAIN;
+ }
+
+ /**
+ * Converts LiveKit video codec to OpenVidu Meet video codec.
+ */
+ private static convertVideoCodecFromLivekit(codec: VideoCodec): MeetRecordingVideoCodec {
+ for (const [meetCodec, lkCodec] of this.VIDEO_CODEC_MAP) {
+ if (lkCodec === codec) return meetCodec;
+ }
+
+ return MeetRecordingVideoCodec.H264_MAIN;
+ }
+
+ /**
+ * Converts OpenVidu Meet audio codec to LiveKit audio codec.
+ */
+ private static convertAudioCodecToLivekit(codec: MeetRecordingAudioCodec): AudioCodec {
+ return this.AUDIO_CODEC_MAP.get(codec) ?? AudioCodec.OPUS;
+ }
+
+ /**
+ * Converts LiveKit audio codec to OpenVidu Meet audio codec.
+ */
+ private static convertAudioCodecFromLivekit(codec: AudioCodec): MeetRecordingAudioCodec {
+ for (const [meetCodec, lkCodec] of this.AUDIO_CODEC_MAP) {
+ if (lkCodec === codec) return meetCodec;
+ }
+
+ return MeetRecordingAudioCodec.OPUS;
+ }
+}
diff --git a/meet-ce/backend/src/helpers/recording.helper.ts b/meet-ce/backend/src/helpers/recording.helper.ts
index 44369fff..5509ebbf 100644
--- a/meet-ce/backend/src/helpers/recording.helper.ts
+++ b/meet-ce/backend/src/helpers/recording.helper.ts
@@ -1,8 +1,15 @@
import { EgressStatus } from '@livekit/protocol';
-import { MeetRecordingInfo, MeetRecordingLayout, MeetRecordingStatus } from '@openvidu-meet/typings';
+import {
+ MeetRecordingEncodingOptions,
+ MeetRecordingEncodingPreset,
+ MeetRecordingInfo,
+ MeetRecordingLayout,
+ MeetRecordingStatus
+} from '@openvidu-meet/typings';
import { EgressInfo } from 'livekit-server-sdk';
import { container } from '../config/dependency-injector.config.js';
import { RoomService } from '../services/room.service.js';
+import { EncodingConverter } from './encoding-converter.helper.js';
export class RecordingHelper {
private constructor() {
@@ -20,6 +27,7 @@ export class RecordingHelper {
const recordingId = RecordingHelper.extractRecordingIdFromEgress(egressInfo);
const { roomName: roomId, errorCode, error, details } = egressInfo;
const layout = RecordingHelper.extractRecordingLayout(egressInfo);
+ const encoding = RecordingHelper.extractRecordingEncoding(egressInfo);
const roomService = container.get(RoomService);
const { roomName } = await roomService.getMeetRoom(roomId);
@@ -29,6 +37,7 @@ export class RecordingHelper {
roomName,
// outputMode,
layout,
+ encoding,
status,
filename,
startDate: startDateMs,
@@ -156,6 +165,27 @@ export class RecordingHelper {
}
}
+ /**
+ * Extracts the encoding configuration from EgressInfo.
+ * Converts LiveKit encoding options back to OpenVidu Meet format.
+ *
+ * @param egressInfo - The egress information from LiveKit
+ * @returns The encoding configuration in OpenVidu Meet format (preset or advanced options)
+ */
+ static extractRecordingEncoding(
+ egressInfo: EgressInfo
+ ): MeetRecordingEncodingPreset | MeetRecordingEncodingOptions | undefined {
+ if (egressInfo.request.case !== 'roomComposite') return undefined;
+
+ const { options } = egressInfo.request.value;
+
+ // Extract encoding based on type (preset or advanced)
+ const encodingOptions =
+ options.case === 'preset' ? options.value : options.case === 'advanced' ? options.value : undefined;
+
+ return EncodingConverter.fromLivekit(encodingOptions);
+ }
+
/**
* Extracts the room name, egressId, and UID from the given recordingId.
* @param recordingId ${roomId}--${egressId}--${uid}
diff --git a/meet-ce/backend/src/models/mongoose-schemas/recording.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/recording.schema.ts
index d1034d7e..6beb6a48 100644
--- a/meet-ce/backend/src/models/mongoose-schemas/recording.schema.ts
+++ b/meet-ce/backend/src/models/mongoose-schemas/recording.schema.ts
@@ -46,7 +46,11 @@ const MeetRecordingSchema = new Schema(
layout: {
type: String,
enum: Object.values(MeetRecordingLayout),
- required: false
+ required: true
+ },
+ encoding: {
+ type: Schema.Types.Mixed,
+ required: true
},
filename: {
type: String,
diff --git a/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts
index 93b6db92..60b3af81 100644
--- a/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts
+++ b/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts
@@ -1,5 +1,6 @@
import {
MeetRecordingAccess,
+ MeetRecordingEncodingPreset,
MeetRecordingLayout,
MeetRoom,
MeetRoomDeletionPolicyWithMeeting,
@@ -56,6 +57,27 @@ const MeetRecordingConfigSchema = new Schema(
required: true,
default: MeetRecordingLayout.GRID
},
+ encoding: {
+ type: Schema.Types.Mixed,
+ required: false,
+ encoding: {
+ type: Schema.Types.Mixed,
+ required: true,
+ default: MeetRecordingEncodingPreset.H264_720P_30,
+ validate: {
+ validator: (value: any) => {
+ if (!value) return true;
+
+ if (typeof value === 'string') return true;
+
+ if (typeof value === 'object') return value.video || value.audio;
+
+ return false;
+ },
+ message: 'Encoding must be a preset string or options object'
+ }
+ }
+ },
allowAccessTo: {
type: String,
enum: Object.values(MeetRecordingAccess),
diff --git a/meet-ce/backend/src/models/zod-schemas/recording.schema.ts b/meet-ce/backend/src/models/zod-schemas/recording.schema.ts
index 1db9e1f4..d633e72e 100644
--- a/meet-ce/backend/src/models/zod-schemas/recording.schema.ts
+++ b/meet-ce/backend/src/models/zod-schemas/recording.schema.ts
@@ -1,6 +1,6 @@
import { MeetRecordingFilters, MeetRecordingLayout, MeetRecordingStatus } from '@openvidu-meet/typings';
import { z } from 'zod';
-import { nonEmptySanitizedRoomId } from './room.schema.js';
+import { encodingValidator, nonEmptySanitizedRoomId } from './room.schema.js';
export const nonEmptySanitizedRecordingId = (fieldName: string) =>
z
@@ -51,9 +51,12 @@ export const nonEmptySanitizedRecordingId = (fieldName: string) =>
export const StartRecordingReqSchema = z.object({
roomId: nonEmptySanitizedRoomId('roomId'),
- config: z.object({
- layout: z.nativeEnum(MeetRecordingLayout).optional()
- }).optional()
+ config: z
+ .object({
+ layout: z.nativeEnum(MeetRecordingLayout).optional(),
+ encoding: encodingValidator.optional()
+ })
+ .optional()
});
export const RecordingFiltersSchema: z.ZodType = z.object({
diff --git a/meet-ce/backend/src/models/zod-schemas/room.schema.ts b/meet-ce/backend/src/models/zod-schemas/room.schema.ts
index 15c2e624..e6b21d78 100644
--- a/meet-ce/backend/src/models/zod-schemas/room.schema.ts
+++ b/meet-ce/backend/src/models/zod-schemas/room.schema.ts
@@ -4,8 +4,12 @@ import {
MeetE2EEConfig,
MeetPermissions,
MeetRecordingAccess,
+ MeetRecordingAudioCodec,
MeetRecordingConfig,
+ MeetRecordingEncodingOptions,
+ MeetRecordingEncodingPreset,
MeetRecordingLayout,
+ MeetRecordingVideoCodec,
MeetRoomAutoDeletionPolicy,
MeetRoomCaptionsConfig,
MeetRoomConfig,
@@ -36,11 +40,122 @@ export const nonEmptySanitizedRoomId = (fieldName: string) =>
message: `${fieldName} cannot be empty after sanitization`
});
+// Encoding options validation - both video and audio are required with all their fields
+export const EncodingOptionsSchema: z.ZodType = z.object({
+ video: z.object({
+ width: z.number().positive('Video width must be a positive number'),
+ height: z.number().positive('Video height must be a positive number'),
+ framerate: z.number().positive('Video framerate must be a positive number'),
+ codec: z.nativeEnum(MeetRecordingVideoCodec),
+ bitrate: z.number().positive('Video bitrate must be a positive number'),
+ keyFrameInterval: z.number().positive('Video keyFrameInterval must be a positive number'),
+ depth: z.number().positive('Video depth must be a positive number')
+ }),
+ audio: z.object({
+ codec: z.nativeEnum(MeetRecordingAudioCodec),
+ bitrate: z.number().positive('Audio bitrate must be a positive number'),
+ frequency: z.number().positive('Audio frequency must be a positive number')
+ })
+});
+
+/**
+ * Custom encoding validator to handle both preset strings and encoding objects.
+ * Used in RecordingConfigSchema
+ */
+export const encodingValidator = z.any().superRefine((value, ctx) => {
+ // If undefined, skip validation (it's optional)
+ if (value === undefined) {
+ return;
+ }
+
+ // Check if it's a string preset
+ if (typeof value === 'string') {
+ const presetValues = Object.values(MeetRecordingEncodingPreset);
+
+ if (!presetValues.includes(value as MeetRecordingEncodingPreset)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Invalid encoding preset. Must be one of: ${presetValues.join(', ')}`
+ });
+ }
+
+ return;
+ }
+
+ // If it's not a string, it must be an encoding object
+ if (typeof value !== 'object' || value === null) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Encoding must be either a preset string or an encoding configuration object'
+ });
+ return;
+ }
+
+ // Both video and audio must be provided
+ if (!value.video || !value.audio) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Both video and audio configuration must be provided when using encoding options'
+ });
+ return;
+ }
+
+ if (value.video === null || typeof value.video !== 'object') {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Video encoding must be a valid object'
+ });
+ return;
+ }
+
+ if (value.audio === null || typeof value.audio !== 'object') {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Audio encoding must be a valid object'
+ });
+ return;
+ }
+
+ // Check video fields
+ const requiredVideoFields = ['width', 'height', 'framerate', 'codec', 'bitrate', 'keyFrameInterval', 'depth'];
+ const missingVideoFields = requiredVideoFields.filter((field) => !(field in value.video));
+
+ if (missingVideoFields.length > 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `When video encoding is provided, required fields are missing: ${missingVideoFields.join(', ')}`,
+ path: ['video']
+ });
+ }
+
+ // Check audio fields
+ const requiredAudioFields = ['codec', 'bitrate', 'frequency'];
+ const missingAudioFields = requiredAudioFields.filter((field) => !(field in value.audio));
+
+ if (missingAudioFields.length > 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `When audio encoding is provided, required fields are missing: ${missingAudioFields.join(', ')}`,
+ path: ['audio']
+ });
+ }
+
+ // Validate the actual types and values using the schema
+ const result = EncodingOptionsSchema.safeParse(value);
+
+ if (!result.success) {
+ result.error.issues.forEach((issue) => {
+ ctx.addIssue(issue);
+ });
+ }
+});
+
const RecordingAccessSchema: z.ZodType = z.nativeEnum(MeetRecordingAccess);
const RecordingConfigSchema: z.ZodType = z.object({
enabled: z.boolean(),
layout: z.nativeEnum(MeetRecordingLayout).optional(),
+ encoding: encodingValidator.optional(),
allowAccessTo: RecordingAccessSchema.optional()
});
@@ -125,6 +240,7 @@ const CreateRoomConfigSchema = z
recording: RecordingConfigSchema.optional().default(() => ({
enabled: true,
layout: MeetRecordingLayout.GRID,
+ encoding: MeetRecordingEncodingPreset.H264_720P_30,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
})),
chat: ChatConfigSchema.optional().default(() => ({ enabled: true })),
@@ -139,6 +255,11 @@ const CreateRoomConfigSchema = z
data.recording.layout = MeetRecordingLayout.GRID;
}
+ // Apply default encoding if not provided
+ if (data.recording.encoding === undefined) {
+ data.recording.encoding = MeetRecordingEncodingPreset.H264_720P_30;
+ }
+
// Apply default allowAccessTo if not provided
if (data.recording.allowAccessTo === undefined) {
data.recording.allowAccessTo = MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER;
@@ -210,6 +331,7 @@ export const RoomOptionsSchema: z.ZodType = z.object({
recording: {
enabled: true,
layout: MeetRecordingLayout.GRID,
+ encoding: MeetRecordingEncodingPreset.H264_720P_30,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
diff --git a/meet-ce/backend/src/services/recording.service.ts b/meet-ce/backend/src/services/recording.service.ts
index 5b799efe..1dd03ff9 100644
--- a/meet-ce/backend/src/services/recording.service.ts
+++ b/meet-ce/backend/src/services/recording.service.ts
@@ -1,4 +1,6 @@
import {
+ MeetRecordingEncodingOptions,
+ MeetRecordingEncodingPreset,
MeetRecordingFilters,
MeetRecordingInfo,
MeetRecordingLayout,
@@ -13,6 +15,7 @@ import { Readable } from 'stream';
import { uid } from 'uid';
import { INTERNAL_CONFIG } from '../config/internal-config.js';
import { MEET_ENV } from '../environment.js';
+import { EncodingConverter } from '../helpers/encoding-converter.helper.js';
import { RecordingHelper } from '../helpers/recording.helper.js';
import { MeetLock } from '../helpers/redis.helper.js';
import { DistributedEventType } from '../models/distributed-event.model.js';
@@ -54,7 +57,10 @@ export class RecordingService {
async startRecording(
roomId: string,
- configOverride?: { layout?: MeetRecordingLayout }
+ configOverride?: {
+ layout?: MeetRecordingLayout;
+ encoding?: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions;
+ }
): Promise {
let acquiredLock: RedisLock | null = null;
let eventListener!: (info: Record) => void;
@@ -712,16 +718,22 @@ export class RecordingService {
*/
protected generateCompositeOptionsFromRequest(
roomConfig: MeetRoomConfig,
- configOverride?: { layout?: MeetRecordingLayout }
+ configOverride?: {
+ layout?: MeetRecordingLayout;
+ encoding?: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions;
+ }
): RoomCompositeOptions {
const roomRecordingConfig = roomConfig.recording;
const layout = configOverride?.layout ?? roomRecordingConfig.layout;
+ const encoding = configOverride?.encoding ?? roomRecordingConfig.encoding;
+ const encodingOptions = EncodingConverter.toLivekit(encoding);
+
return {
- layout
+ layout,
+ encodingOptions
// customBaseUrl: customLayout,
// audioOnly: false,
// videoOnly: false
- // encodingOptions
};
}
diff --git a/meet-ce/backend/tests/helpers/assertion-helpers.ts b/meet-ce/backend/tests/helpers/assertion-helpers.ts
index 64583e53..a505c155 100644
--- a/meet-ce/backend/tests/helpers/assertion-helpers.ts
+++ b/meet-ce/backend/tests/helpers/assertion-helpers.ts
@@ -2,6 +2,8 @@ import { expect } from '@jest/globals';
import {
MeetingEndAction,
MeetRecordingAccess,
+ MeetRecordingEncodingOptions,
+ MeetRecordingEncodingPreset,
MeetRecordingInfo,
MeetRecordingLayout,
MeetRecordingStatus,
@@ -19,6 +21,9 @@ import { container } from '../../src/config/dependency-injector.config';
import { INTERNAL_CONFIG } from '../../src/config/internal-config';
import { TokenService } from '../../src/services/token.service';
+export const DEFAULT_RECORDING_ENCODING_PRESET = MeetRecordingEncodingPreset.H264_720P_30;
+export const DEFAULT_RECORDING_LAYOUT = MeetRecordingLayout.GRID;
+
export const expectErrorResponse = (
response: Response,
status = 422,
@@ -151,12 +156,14 @@ export const expectValidRoom = (
expect(room.config).toBeDefined();
if (config !== undefined) {
- expect(room.config).toEqual(config);
+ // Use toMatchObject to allow encoding defaults to be added without breaking tests
+ expect(room.config).toMatchObject(config as any);
} else {
expect(room.config).toEqual({
recording: {
enabled: true,
- layout: MeetRecordingLayout.GRID,
+ layout: DEFAULT_RECORDING_LAYOUT,
+ encoding: DEFAULT_RECORDING_ENCODING_PRESET,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
@@ -202,6 +209,28 @@ export const expectValidRecording = (
if (recording.layout !== undefined) {
expect(Object.values(MeetRecordingLayout)).toContain(recording.layout);
}
+
+ // Validate encoding is present and has a valid value
+ expect(recording.encoding).toBeDefined();
+
+ if (recording.encoding !== undefined) {
+ if (typeof recording.encoding === 'string') {
+ // Encoding preset: should match the default H264_720P_30
+ expect(recording.encoding).toBe('H264_720P_30');
+ } else {
+ // Advanced encoding options: should have valid codec values
+ expect(typeof recording.encoding).toBe('object');
+ const encodingObj = recording.encoding as MeetRecordingEncodingOptions;
+
+ if (encodingObj.video?.codec) {
+ expect(['H264_BASELINE', 'H264_MAIN', 'H264_HIGH', 'VP8']).toContain(encodingObj.video.codec);
+ }
+
+ if (encodingObj.audio?.codec) {
+ expect(['OPUS', 'AAC']).toContain(encodingObj.audio.codec);
+ }
+ }
+ }
};
export const expectValidRoomWithFields = (room: MeetRoom, fields: string[] = []) => {
@@ -365,7 +394,13 @@ export const expectSuccessRecordingMediaResponse = (
}
};
-export const expectValidStartRecordingResponse = (response: Response, roomId: string, roomName: string) => {
+export const expectValidStartRecordingResponse = (
+ response: Response,
+ roomId: string,
+ roomName: string,
+ expectedLayout?: MeetRecordingLayout,
+ expectedEncoding?: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions
+) => {
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('recordingId');
@@ -385,9 +420,28 @@ export const expectValidStartRecordingResponse = (response: Response, roomId: st
expect(response.body).not.toHaveProperty('endDate');
expect(response.body).not.toHaveProperty('size');
- // Validate layout is a valid value
- if (response.body.layout !== undefined) {
- expect(Object.values(MeetRecordingLayout)).toContain(response.body.layout);
+ expect(response.body.layout).toBeDefined();
+ expect(response.body.encoding).toBeDefined();
+
+ // Validate expected layout if provided
+ if (expectedLayout) {
+ expect(response.body.layout).toEqual(expectedLayout);
+ } else {
+ // Default layout
+ expect(response.body.layout).toEqual(DEFAULT_RECORDING_LAYOUT);
+ }
+
+ if (expectedEncoding !== undefined) {
+ if (typeof expectedEncoding === 'string') {
+ // Encoding preset
+ expect(response.body.encoding).toEqual(expectedEncoding);
+ } else {
+ // Advanced encoding options
+ expect(response.body.encoding).toMatchObject(expectedEncoding as any);
+ }
+ } else {
+ // Default encoding preset
+ expect(response.body.encoding).toEqual(DEFAULT_RECORDING_ENCODING_PRESET);
}
};
@@ -395,10 +449,13 @@ export const expectValidStopRecordingResponse = (
response: Response,
recordingId: string,
roomId: string,
- roomName: string
+ roomName: string,
+ expectedLayout?: MeetRecordingLayout,
+ expectedEncoding?: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions
) => {
expect(response.status).toBe(202);
expect(response.body).toBeDefined();
+ expectValidRecordingLocationHeader(response);
expect(response.body).toHaveProperty('recordingId', recordingId);
expect([MeetRecordingStatus.COMPLETE, MeetRecordingStatus.ENDING]).toContain(response.body.status);
expect(response.body).toHaveProperty('roomId', roomId);
@@ -407,35 +464,85 @@ export const expectValidStopRecordingResponse = (
expect(response.body).toHaveProperty('startDate');
expect(response.body).toHaveProperty('duration', expect.any(Number));
expect(response.body).toHaveProperty('layout');
+ expect(response.body).toHaveProperty('encoding');
// Validate layout is a valid value
- if (response.body.layout !== undefined) {
- expect(Object.values(MeetRecordingLayout)).toContain(response.body.layout);
+ if (expectedLayout) {
+ expect(response.body.layout).toEqual(expectedLayout);
+ } else {
+ // Default layout
+ expect(response.body.layout).toEqual(DEFAULT_RECORDING_LAYOUT);
}
- expectValidRecordingLocationHeader(response);
+ // Validate encoding property
+ if (expectedEncoding) {
+ expect(response.body.encoding).toEqual(expectedEncoding);
+ } else {
+ // Default encoding preset
+ expect(response.body.encoding).toEqual(DEFAULT_RECORDING_ENCODING_PRESET);
+ }
};
export const expectValidGetRecordingResponse = (
response: Response,
- recordingId: string,
- roomId: string,
- roomName: string,
- status?: MeetRecordingStatus,
- maxSecDuration?: number
+ expectedConfig: {
+ recordingId: string;
+ roomId: string;
+ roomName: string;
+ recordingStatus?: MeetRecordingStatus;
+ recordingDuration?: number;
+ recordingLayout?: MeetRecordingLayout;
+ recordingEncoding?: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions;
+ }
) => {
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
const body = response.body;
+ const { recordingId, roomId, roomName, recordingStatus, recordingDuration, recordingLayout, recordingEncoding } =
+ expectedConfig;
+
expect(body).toMatchObject({ recordingId, roomId, roomName });
+ // Validate layout property
+ expect(body).toHaveProperty('layout');
+ expect(body.layout).toBeDefined();
+
+ if (recordingLayout !== undefined) {
+ expect(body.layout).toBe(recordingLayout);
+ } else {
+ // Default layout
+ expect(body.layout).toBe(DEFAULT_RECORDING_LAYOUT);
+ }
+
+ // Validate encoding property
+ expect(body).toHaveProperty('encoding');
+ expect(body.encoding).toBeDefined();
+
+ // Validate encoding property is present and coherent
+ if (recordingEncoding !== undefined) {
+ if (typeof recordingEncoding === 'string') {
+ expect(body.layout).toBe(recordingLayout);
+ } else {
+ expect(body.encoding).toMatchObject(recordingEncoding as any);
+ }
+ } else {
+ // Default encoding preset
+ expect(body.encoding).toBe(DEFAULT_RECORDING_ENCODING_PRESET);
+ }
+
+ expect(body.status).toBeDefined();
+
+ if (recordingStatus !== undefined) {
+ expect(body.status).toBe(recordingStatus);
+ }
+
const isRecFinished =
- status &&
- (status === MeetRecordingStatus.COMPLETE ||
- status === MeetRecordingStatus.ABORTED ||
- status === MeetRecordingStatus.FAILED ||
- status === MeetRecordingStatus.LIMIT_REACHED);
+ recordingStatus &&
+ (recordingStatus === MeetRecordingStatus.COMPLETE ||
+ recordingStatus === MeetRecordingStatus.ABORTED ||
+ recordingStatus === MeetRecordingStatus.FAILED ||
+ recordingStatus === MeetRecordingStatus.LIMIT_REACHED);
expect(body).toEqual(
expect.objectContaining({
recordingId: expect.stringMatching(new RegExp(`^${recordingId}$`)),
@@ -451,32 +558,16 @@ export const expectValidGetRecordingResponse = (
})
);
- // Validate layout property
- expect(body).toHaveProperty('layout');
-
- if (body.layout !== undefined) {
- expect(body.layout).toBeDefined();
- expect(typeof body.layout).toBe('string');
- // Validate it's a valid MeetRecordingLayout value
- expect(Object.values(MeetRecordingLayout)).toContain(body.layout);
- }
-
- expect(body.status).toBeDefined();
-
- if (status !== undefined) {
- expect(body.status).toBe(status);
- }
-
if (isRecFinished) {
expect(body.endDate).toBeGreaterThanOrEqual(body.startDate);
expect(body.duration).toBeGreaterThanOrEqual(0);
}
- if (isRecFinished && maxSecDuration) {
- expect(body.duration).toBeLessThanOrEqual(maxSecDuration);
+ if (isRecFinished && recordingDuration) {
+ expect(body.duration).toBeLessThanOrEqual(recordingDuration);
const computedSec = (body.endDate - body.startDate) / 1000;
- const diffSec = Math.abs(maxSecDuration - computedSec);
+ const diffSec = Math.abs(recordingDuration - computedSec);
// Estimate 5 seconds of tolerace because of time to start/stop recording
expect(diffSec).toBeLessThanOrEqual(5);
}
diff --git a/meet-ce/backend/tests/helpers/request-helpers.ts b/meet-ce/backend/tests/helpers/request-helpers.ts
index acf5b31d..16b5b7c9 100644
--- a/meet-ce/backend/tests/helpers/request-helpers.ts
+++ b/meet-ce/backend/tests/helpers/request-helpers.ts
@@ -3,6 +3,8 @@ import {
AuthMode,
MeetAppearanceConfig,
MeetRecordingAccess,
+ MeetRecordingEncodingOptions,
+ MeetRecordingEncodingPreset,
MeetRecordingInfo,
MeetRecordingStatus,
MeetRoom,
@@ -588,10 +590,22 @@ export const endMeeting = async (roomId: string, moderatorToken: string) => {
return response;
};
-export const startRecording = async (roomId: string, config?: { layout?: string }) => {
+export const startRecording = async (
+ roomId: string,
+ config?: {
+ layout?: string;
+ encoding?: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions;
+ }
+) => {
checkAppIsRunning();
- const body: { roomId: string; config?: { layout?: string } } = { roomId };
+ const body: {
+ roomId: string;
+ config?: {
+ layout?: string;
+ encoding?: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions;
+ };
+ } = { roomId };
if (config) {
body.config = config;
diff --git a/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts
index 7a823b81..3dcea2e1 100644
--- a/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts
+++ b/meet-ce/backend/tests/integration/api/recordings/get-recording.test.ts
@@ -36,14 +36,13 @@ describe('Recording API Tests', () => {
it('should return 200 when recording exists', async () => {
const response = await getRecording(recordingId);
- expectValidGetRecordingResponse(
- response,
+ expectValidGetRecordingResponse(response, {
recordingId,
- room.roomId,
- room.roomName,
- MeetRecordingStatus.COMPLETE,
- 1
- );
+ roomId: room.roomId,
+ roomName: room.roomName,
+ recordingStatus: MeetRecordingStatus.COMPLETE,
+ recordingDuration: 1
+ });
});
it('should get an ACTIVE recording status', async () => {
@@ -51,13 +50,12 @@ describe('Recording API Tests', () => {
const { room: roomAux, recordingId: recordingIdAux = '' } = contextAux.getRoomByIndex(0)!;
const response = await getRecording(recordingIdAux);
- expectValidGetRecordingResponse(
- response,
- recordingIdAux,
- roomAux.roomId,
- roomAux.roomName,
- MeetRecordingStatus.ACTIVE
- );
+ expectValidGetRecordingResponse(response, {
+ recordingId: recordingIdAux,
+ roomId: roomAux.roomId,
+ roomName: roomAux.roomName,
+ recordingStatus: MeetRecordingStatus.ACTIVE
+ });
await stopAllRecordings();
});
diff --git a/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts b/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts
index 2566cbd9..1bbaa6b6 100644
--- a/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts
+++ b/meet-ce/backend/tests/integration/api/recordings/get-recordings.test.ts
@@ -13,6 +13,7 @@ import {
generateRoomMemberToken,
getAllRecordings,
getAllRecordingsFromRoom,
+ sleep,
startTestServer,
stopRecording
} from '../../../helpers/request-helpers.js';
@@ -116,6 +117,7 @@ describe('Recordings API Tests', () => {
it('should return recordings with pagination', async () => {
context = await setupMultiRecordingsTestContext(6, 6, 6);
const rooms = context.rooms;
+ await sleep('2s');
const response = await getAllRecordings({ maxItems: 3 });
expectSuccessListRecordingResponse(response, 3, true, true, 3);
diff --git a/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts
index 09c7ccef..8a2f4353 100644
--- a/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts
+++ b/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts
@@ -1,5 +1,12 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from '@jest/globals';
-import { MeetRecordingLayout, MeetRoom } from '@openvidu-meet/typings';
+import {
+ MeetRecordingAudioCodec,
+ MeetRecordingEncodingOptions,
+ MeetRecordingEncodingPreset,
+ MeetRecordingLayout,
+ MeetRecordingVideoCodec,
+ 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';
@@ -206,17 +213,300 @@ describe('Recording API Tests', () => {
RECORDING_STARTED_TIMEOUT: '30s'
});
});
+
+ 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 reject partial audio encoding with missing fields', async () => {
+ const partialEncoding = {
+ video: {
+ width: 1280,
+ height: 720,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 3000,
+ keyFrameInterval: 4,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS
+ // Missing bitrate and frequency
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ encoding: partialEncoding as MeetRecordingEncodingOptions
+ });
+
+ expectValidationError(
+ response,
+ 'config.encoding.audio',
+ 'When audio encoding is provided, required fields are missing: bitrate, frequency'
+ );
+ });
+
+ it('should reject encoding with neither video nor audio', async () => {
+ const emptyEncoding = {};
+ const response = await startRecording(room.roomId, {
+ encoding: emptyEncoding as MeetRecordingEncodingOptions
+ });
+
+ expectValidationError(response, 'config.encoding', 'Both video and audio configuration must be provided');
+ });
+
+ it('should reject partial video encoding with missing fields', async () => {
+ const partialEncoding = {
+ video: {
+ width: 1920,
+ height: 1080
+ // Missing framerate, codec, bitrate
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.AAC,
+ bitrate: 192,
+ frequency: 48000
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ encoding: partialEncoding as MeetRecordingEncodingOptions
+ });
+
+ expectValidationError(
+ response,
+ 'config.encoding.video',
+ 'When video encoding is provided, required fields are missing'
+ );
+ });
+
+ it('should reject invalid encoding preset string', async () => {
+ const response = await startRecording(room.roomId, {
+ encoding: 'invalid-preset' as MeetRecordingEncodingPreset
+ });
+
+ expectValidationError(response, 'config.encoding', 'Invalid encoding preset');
+ });
+
+ it('should reject invalid encoding options with wrong video codec', async () => {
+ const invalidEncoding = {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: 'INVALID_CODEC',
+ bitrate: 4500,
+ keyFrameInterval: 4,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: 128,
+ frequency: 44100
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ encoding: invalidEncoding as MeetRecordingEncodingOptions
+ });
+
+ expectValidationError(response, 'config.encoding.video.codec', 'Invalid enum value');
+ });
+
+ it('should reject invalid encoding options with wrong audio codec', async () => {
+ const invalidEncoding = {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500,
+ keyFrameInterval: 4,
+ depth: 24
+ },
+ audio: {
+ codec: 'INVALID_AUDIO_CODEC',
+ bitrate: 128,
+ frequency: 44100
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ encoding: invalidEncoding as MeetRecordingEncodingOptions
+ });
+
+ expectValidationError(response, 'config.encoding.audio.codec', 'Invalid enum value');
+ });
+
+ it('should reject encoding options with negative video bitrate', async () => {
+ const invalidEncoding = {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: -1000,
+ keyFrameInterval: 4,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: 128,
+ frequency: 44100
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ encoding: invalidEncoding as MeetRecordingEncodingOptions
+ });
+
+ expectValidationError(response, 'config.encoding.video.bitrate', 'positive');
+ });
+
+ it('should reject encoding options with negative audio bitrate', async () => {
+ const invalidEncoding = {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500,
+ keyFrameInterval: 4,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: -128,
+ frequency: 44100
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ encoding: invalidEncoding as MeetRecordingEncodingOptions
+ });
+
+ expectValidationError(response, 'config.encoding.audio.bitrate', 'positive');
+ });
+
+ it('should reject encoding options with missing keyFrameInterval', async () => {
+ const invalidEncoding = {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500
+ // Missing keyFrameInterval
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: 128,
+ frequency: 44100
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ encoding: invalidEncoding as MeetRecordingEncodingOptions
+ });
+
+ expectValidationError(
+ response,
+ 'config.encoding.video',
+ 'When video encoding is provided, required fields are missing: keyFrameInterval'
+ );
+ });
+
+ it('should reject encoding options with missing video width', async () => {
+ const invalidEncoding = {
+ video: {
+ // Missing width
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500,
+ keyFrameInterval: 4,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: 128,
+ frequency: 44100
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ encoding: invalidEncoding as MeetRecordingEncodingOptions
+ });
+
+ expectValidationError(
+ response,
+ 'config.encoding.video',
+ 'When video encoding is provided, required fields are missing: width'
+ );
+ });
+
+ it('should reject encoding options with missing audio frequency', async () => {
+ const invalidEncoding = {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500,
+ keyFrameInterval: 4,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: 128
+ // Missing frequency
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ encoding: invalidEncoding as MeetRecordingEncodingOptions
+ });
+
+ expectValidationError(
+ response,
+ 'config.encoding.audio',
+ 'When audio encoding is provided, required fields are missing: frequency'
+ );
+ });
+
+ it('should reject audio-only encoding without video', async () => {
+ const audioOnlyEncoding = {
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: 256,
+ frequency: 48000
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ encoding: audioOnlyEncoding as MeetRecordingEncodingOptions
+ });
+ expectValidationError(response, 'config.encoding', 'Both video and audio configuration must be provided');
+ });
+
+ it('should reject video-only encoding without audio', async () => {
+ const videoOnlyEncoding = {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_HIGH,
+ bitrate: 5000
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ encoding: videoOnlyEncoding as MeetRecordingEncodingOptions
+ });
+ expectValidationError(response, 'config.encoding', 'Both video and audio configuration must be provided');
+ });
});
describe('Start Recording with Config Override', () => {
beforeAll(async () => {
- // Create a room and join a participant
+ // Create a room (without participant initially)
context = await setupMultiRoomTestContext(1, true);
({ room } = context.getRoomByIndex(0)!);
});
afterEach(async () => {
- await disconnectFakeParticipants();
+ // await disconnectFakeParticipants();
await stopAllRecordings();
});
@@ -226,58 +516,154 @@ describe('Recording API Tests', () => {
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 () => {
+ it('should override room layout when recording 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);
+ expectValidStartRecordingResponse(response, room.roomId, room.roomName, 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');
+ expectValidStopRecordingResponse(
+ stopResponse,
+ recordingId,
+ room.roomId,
+ room.roomName,
+ MeetRecordingLayout.SPEAKER
+ );
});
it('should accept empty config object and use room defaults', async () => {
const response = await startRecording(room.roomId, {});
const recordingId = response.body.recordingId;
+ console.log('Response for empty config override:', response);
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);
});
+
+ it('should override room encoding with a preset when config with encoding is provided', async () => {
+ const response = await startRecording(room.roomId, { encoding: MeetRecordingEncodingPreset.H264_1080P_60 });
+ const recordingId = response.body.recordingId;
+
+ expectValidStartRecordingResponse(
+ response,
+ room.roomId,
+ room.roomName,
+ undefined,
+ MeetRecordingEncodingPreset.H264_1080P_60
+ );
+
+ const stopResponse = await stopRecording(recordingId);
+ expectValidStopRecordingResponse(
+ stopResponse,
+ recordingId,
+ room.roomId,
+ room.roomName,
+ undefined,
+ MeetRecordingEncodingPreset.H264_1080P_60
+ );
+ });
+
+ it('should override room encoding with portrait preset', async () => {
+ const response = await startRecording(room.roomId, {
+ encoding: MeetRecordingEncodingPreset.PORTRAIT_H264_1080P_30
+ });
+ const recordingId = response.body.recordingId;
+
+ expectValidStartRecordingResponse(
+ response,
+ room.roomId,
+ room.roomName,
+ undefined,
+ MeetRecordingEncodingPreset.PORTRAIT_H264_1080P_30
+ );
+
+ const stopResponse = await stopRecording(recordingId);
+ expectValidStopRecordingResponse(
+ stopResponse,
+ recordingId,
+ room.roomId,
+ room.roomName,
+ undefined,
+ MeetRecordingEncodingPreset.PORTRAIT_H264_1080P_30
+ );
+ });
+
+ it('should override room encoding with custom encoding options', async () => {
+ const customEncoding: MeetRecordingEncodingOptions = {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 60,
+ codec: MeetRecordingVideoCodec.H264_HIGH,
+ bitrate: 6000,
+ keyFrameInterval: 2,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.AAC,
+ bitrate: 192,
+ frequency: 48000
+ }
+ };
+ const response = await startRecording(room.roomId, { encoding: customEncoding });
+ const recordingId = response.body.recordingId;
+
+ expectValidStartRecordingResponse(response, room.roomId, room.roomName, undefined, customEncoding);
+
+ const stopResponse = await stopRecording(recordingId);
+ expectValidStopRecordingResponse(
+ stopResponse,
+ recordingId,
+ room.roomId,
+ room.roomName,
+ undefined,
+ customEncoding
+ );
+ });
+
+ it('should override both layout and encoding simultaneously', async () => {
+ const customEncoding: MeetRecordingEncodingOptions = {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500,
+ keyFrameInterval: 4,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: 128,
+ frequency: 44100
+ }
+ };
+ const response = await startRecording(room.roomId, {
+ layout: MeetRecordingLayout.SPEAKER,
+ encoding: customEncoding
+ });
+ const recordingId = response.body.recordingId;
+
+ expectValidStartRecordingResponse(
+ response,
+ room.roomId,
+ room.roomName,
+ MeetRecordingLayout.SPEAKER,
+ customEncoding
+ );
+
+ const stopResponse = await stopRecording(recordingId);
+ expectValidStopRecordingResponse(
+ stopResponse,
+ recordingId,
+ room.roomId,
+ room.roomName,
+ MeetRecordingLayout.SPEAKER,
+ customEncoding
+ );
+ });
});
});
diff --git a/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts b/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts
index c4b83aac..da203198 100644
--- a/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts
+++ b/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts
@@ -1,7 +1,11 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
import {
MeetRecordingAccess,
+ MeetRecordingAudioCodec,
+ MeetRecordingEncodingOptions,
+ MeetRecordingEncodingPreset,
MeetRecordingLayout,
+ MeetRecordingVideoCodec,
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings
} from '@openvidu-meet/typings';
@@ -10,7 +14,12 @@ import ms from 'ms';
import request from 'supertest';
import { INTERNAL_CONFIG } from '../../../../src/config/internal-config.js';
import { MEET_ENV } from '../../../../src/environment.js';
-import { expectValidRoom } from '../../../helpers/assertion-helpers.js';
+import {
+ DEFAULT_RECORDING_ENCODING_PRESET,
+ DEFAULT_RECORDING_LAYOUT,
+ expectValidRoom,
+ expectValidationError
+} from '../../../helpers/assertion-helpers.js';
import { createRoom, deleteAllRooms, startTestServer } from '../../../helpers/request-helpers.js';
const ROOMS_PATH = `${INTERNAL_CONFIG.API_BASE_PATH_V1}/rooms`;
@@ -62,6 +71,7 @@ describe('Room API Tests', () => {
recording: {
enabled: false,
layout: MeetRecordingLayout.GRID,
+ encoding: MeetRecordingEncodingPreset.H264_720P_30,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: false },
@@ -103,6 +113,7 @@ describe('Room API Tests', () => {
recording: {
enabled: false,
layout: MeetRecordingLayout.GRID, // Default value
+ encoding: MeetRecordingEncodingPreset.H264_720P_30, // Default value
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER // Default value
},
chat: { enabled: true }, // Default value
@@ -133,6 +144,7 @@ describe('Room API Tests', () => {
recording: {
enabled: true, // Default value
layout: MeetRecordingLayout.GRID, // Default value
+ encoding: MeetRecordingEncodingPreset.H264_720P_30, // Default value
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER // Default value
},
chat: { enabled: false },
@@ -286,6 +298,150 @@ describe('Room API Tests', () => {
});
});
+ describe('Recording Encoding Configuration Tests', () => {
+ it('Should create a room without encoding and return default value', async () => {
+ const payload = {
+ roomName: 'Room without encoding',
+ config: {
+ recording: {
+ enabled: true
+ // No encoding specified
+ }
+ }
+ };
+
+ const room = await createRoom(payload);
+
+ const expectedConfig = {
+ recording: {
+ enabled: true,
+ layout: DEFAULT_RECORDING_LAYOUT,
+ encoding: DEFAULT_RECORDING_ENCODING_PRESET, // Default value
+ allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
+ },
+ chat: { enabled: true },
+ virtualBackground: { enabled: true },
+ e2ee: { enabled: false },
+ captions: { enabled: true }
+ };
+ expectValidRoom(room, 'Room without encoding', 'room_without_encoding', expectedConfig);
+ });
+
+ it('Should create a room with H264_1080P_30 encoding preset', async () => {
+ const payload = {
+ roomName: '1080p Preset Room',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: MeetRecordingEncodingPreset.H264_1080P_30
+ }
+ }
+ };
+
+ const room = await createRoom(payload);
+
+ const expectedConfig = {
+ recording: {
+ enabled: true,
+ layout: DEFAULT_RECORDING_LAYOUT,
+ encoding: MeetRecordingEncodingPreset.H264_1080P_30,
+ allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
+ },
+ chat: { enabled: true },
+ virtualBackground: { enabled: true },
+ e2ee: { enabled: false },
+ captions: { enabled: true }
+ };
+ expectValidRoom(room, '1080p Preset Room', '1080p_preset_room', expectedConfig);
+ });
+
+ it('Should create a room with PORTRAIT_H264_720P_30 encoding preset', async () => {
+ const payload = {
+ roomName: 'Portrait 720p Room',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: MeetRecordingEncodingPreset.PORTRAIT_H264_720P_30
+ }
+ }
+ };
+
+ const room = await createRoom(payload);
+
+ const expectedConfig = {
+ recording: {
+ enabled: true,
+ layout: DEFAULT_RECORDING_LAYOUT,
+ encoding: MeetRecordingEncodingPreset.PORTRAIT_H264_720P_30,
+ allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
+ },
+ chat: { enabled: true },
+ virtualBackground: { enabled: true },
+ e2ee: { enabled: false },
+ captions: { enabled: true }
+ };
+ expectValidRoom(room, 'Portrait 720p Room', 'portrait_720p_room', expectedConfig);
+ });
+
+ it('Should create a room with advanced encoding options - both video and audio', async () => {
+ const payload = {
+ roomName: 'Full Advanced Encoding Room',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: {
+ video: {
+ width: 1280,
+ height: 720,
+ framerate: 60,
+ codec: MeetRecordingVideoCodec.H264_HIGH,
+ bitrate: 3000,
+ keyFrameInterval: 2,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.AAC,
+ bitrate: 192,
+ frequency: 44100
+ }
+ } as MeetRecordingEncodingOptions
+ }
+ }
+ };
+
+ const room = await createRoom(payload);
+
+ const expectedConfig = {
+ recording: {
+ enabled: true,
+ layout: MeetRecordingLayout.GRID,
+ encoding: {
+ video: {
+ width: 1280,
+ height: 720,
+ framerate: 60,
+ codec: MeetRecordingVideoCodec.H264_HIGH,
+ bitrate: 3000,
+ keyFrameInterval: 2,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.AAC,
+ bitrate: 192,
+ frequency: 44100
+ }
+ },
+ allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
+ },
+ chat: { enabled: true },
+ virtualBackground: { enabled: true },
+ e2ee: { enabled: false },
+ captions: { enabled: true }
+ };
+ expectValidRoom(room, 'Full Advanced Encoding Room', 'full_advanced_encoding_room', expectedConfig);
+ });
+ });
+
describe('Room Creation Validation failures', () => {
it('should fail when autoDeletionDate is negative', async () => {
const payload = {
@@ -543,5 +699,216 @@ describe('Room API Tests', () => {
expect(JSON.stringify(response.body.details)).toContain('roomName cannot exceed 50 characters');
});
+
+ describe('Encoding Validation Failures', () => {
+ it('Should reject room with video-only encoding (audio required)', async () => {
+ const payload = {
+ roomName: 'Video Only Encoding Room',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500,
+ keyFrameInterval: 4,
+ depth: 24
+ }
+ // No audio encoding
+ } as MeetRecordingEncodingOptions
+ }
+ }
+ };
+
+ const response = await request(app)
+ .post(ROOMS_PATH)
+ .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
+ .send(payload)
+ .expect(422);
+
+ expectValidationError(
+ response,
+ 'config.recording.encoding',
+ 'Both video and audio configuration must be provided'
+ );
+ });
+
+ it('Should reject room with audio-only encoding (video required)', async () => {
+ const payload = {
+ roomName: 'Audio Only Encoding Room',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: {
+ // No video encoding
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: 128,
+ frequency: 48000
+ }
+ } as MeetRecordingEncodingOptions
+ }
+ }
+ };
+
+ const response = await request(app)
+ .post(ROOMS_PATH)
+ .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
+ .send(payload)
+ .expect(422);
+ expectValidationError(
+ response,
+ 'config.recording.encoding',
+ 'Both video and audio configuration must be provided'
+ );
+ });
+ it('Should fail when encoding preset is invalid', async () => {
+ const payload = {
+ roomName: 'Invalid Preset',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: 'INVALID_PRESET'
+ }
+ }
+ };
+
+ const response = await request(app)
+ .post(ROOMS_PATH)
+ .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
+ .send(payload)
+ .expect(422);
+
+ expect(JSON.stringify(response.body.details)).toContain('Invalid encoding preset');
+ });
+
+ it('Should fail when video encoding has missing required fields', async () => {
+ const payload = {
+ roomName: 'Missing Video Fields',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: {
+ video: {
+ width: 1920
+ // Missing height, framerate, codec, bitrate
+ },
+ audio: {
+ codec: 'OPUS',
+ bitrate: 128,
+ frequency: 48000
+ }
+ }
+ }
+ }
+ };
+
+ const response = await request(app)
+ .post(ROOMS_PATH)
+ .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
+ .send(payload)
+ .expect(422);
+
+ // Partial video config - missing required fields
+ expect(JSON.stringify(response.body.details)).toContain(
+ 'When video encoding is provided, required fields are missing'
+ );
+ });
+
+ it('Should fail when audio encoding has missing required fields', async () => {
+ const payload = {
+ roomName: 'Missing Audio Fields',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500,
+ keyFrameInterval: 4,
+ depth: 24
+ },
+ audio: {
+ codec: 'OPUS'
+ // Missing bitrate and frequency
+ }
+ }
+ }
+ }
+ };
+
+ const response = await request(app)
+ .post(ROOMS_PATH)
+ .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
+ .send(payload)
+ .expect(422);
+
+ // Partial audio config - missing required fields
+ expect(JSON.stringify(response.body.details)).toContain(
+ 'When audio encoding is provided, required fields are missing'
+ );
+ });
+
+ it('Should fail when encoding has neither video nor audio', async () => {
+ const payload = {
+ roomName: 'Empty Encoding',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: {}
+ }
+ }
+ };
+
+ const response = await request(app)
+ .post(ROOMS_PATH)
+ .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
+ .send(payload)
+ .expect(422);
+
+ expect(JSON.stringify(response.body.details)).toContain(
+ 'Both video and audio configuration must be provided when using encoding options'
+ );
+ });
+
+ it('Should fail when encoding has invalid types', async () => {
+ const payload = {
+ roomName: 'Invalid Types',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: {
+ video: {
+ width: '1920', // String instead of number
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: 128,
+ frequency: 48000
+ }
+ }
+ }
+ }
+ };
+
+ const response = await request(app)
+ .post(ROOMS_PATH)
+ .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY)
+ .send(payload)
+ .expect(422);
+
+ expect(JSON.stringify(response.body.details)).toContain('Expected number');
+ });
+ });
});
});
diff --git a/meet-ce/backend/tests/integration/api/rooms/e2ee-room-config.test.ts b/meet-ce/backend/tests/integration/api/rooms/e2ee-room-config.test.ts
index db97320e..2c497374 100644
--- a/meet-ce/backend/tests/integration/api/rooms/e2ee-room-config.test.ts
+++ b/meet-ce/backend/tests/integration/api/rooms/e2ee-room-config.test.ts
@@ -72,13 +72,13 @@ describe('E2EE Room Configuration Tests', () => {
e2ee: { enabled: true }
});
- const { room, moderatorToken } = context.getRoomByIndex(0)!;
+ const { room } = context.getRoomByIndex(0)!;
// Try to start recording (should fail because recording is not enabled in room config)
- const response = await startRecording(room.roomId, moderatorToken);
+ const response = await startRecording(room.roomId);
- // The endpoint returns 404 when the recording endpoint doesn't exist for disabled recording rooms
- expect(403).toBe(response.status);
+ // The endpoint returns 403 when the recording endpoint doesn't exist for disabled recording rooms
+ expect(response.status).toBe(403);
expect(response.body.message).toBe(`Recording is disabled for room '${room.roomId}'`);
});
diff --git a/meet-ce/backend/tests/integration/api/rooms/expired-rooms-gc.test.ts b/meet-ce/backend/tests/integration/api/rooms/expired-rooms-gc.test.ts
index 557d4ae7..ba4cdcc5 100644
--- a/meet-ce/backend/tests/integration/api/rooms/expired-rooms-gc.test.ts
+++ b/meet-ce/backend/tests/integration/api/rooms/expired-rooms-gc.test.ts
@@ -1,8 +1,8 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
+import { MeetRoomDeletionPolicyWithMeeting, MeetRoomDeletionPolicyWithRecordings } from '@openvidu-meet/typings';
import ms from 'ms';
import { setInternalConfig } from '../../../../src/config/internal-config.js';
import { MeetRoomHelper } from '../../../../src/helpers/room.helper.js';
-import { MeetRoomDeletionPolicyWithMeeting, MeetRoomDeletionPolicyWithRecordings } from '@openvidu-meet/typings';
import {
createRoom,
deleteAllRecordings,
@@ -176,9 +176,7 @@ describe('Expired Rooms GC Tests', () => {
await joinFakeParticipant(room2.roomId, 'participant2');
// Start recording
- const { moderatorSecret } = MeetRoomHelper.extractSecretsFromRoom(room1);
- const moderatorToken = await generateRoomMemberToken(room1.roomId, { secret: moderatorSecret });
- await startRecording(room1.roomId, moderatorToken);
+ await startRecording(room1.roomId);
await runExpiredRoomsGC();
diff --git a/meet-ce/backend/tests/integration/api/rooms/get-room-config.test.ts b/meet-ce/backend/tests/integration/api/rooms/get-room-config.test.ts
index 7f54f46b..bf453840 100644
--- a/meet-ce/backend/tests/integration/api/rooms/get-room-config.test.ts
+++ b/meet-ce/backend/tests/integration/api/rooms/get-room-config.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeAll, describe, it } from '@jest/globals';
-import { MeetRecordingAccess, MeetRecordingLayout } from '@openvidu-meet/typings';
+import { MeetRecordingAccess, MeetRecordingEncodingPreset, MeetRecordingLayout } from '@openvidu-meet/typings';
import { Response } from 'supertest';
import { expectSuccessRoomConfigResponse } from '../../../helpers/assertion-helpers.js';
import { deleteAllRooms, getRoomConfig, startTestServer } from '../../../helpers/request-helpers.js';
@@ -10,6 +10,7 @@ describe('Room API Tests', () => {
recording: {
enabled: true,
layout: MeetRecordingLayout.GRID,
+ encoding: MeetRecordingEncodingPreset.H264_720P_30,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
@@ -43,6 +44,7 @@ describe('Room API Tests', () => {
recording: {
enabled: true,
layout: MeetRecordingLayout.SPEAKER,
+ encoding: MeetRecordingEncodingPreset.H264_1080P_30,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
diff --git a/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts b/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts
index e624ff4a..937838cb 100644
--- a/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts
+++ b/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts
@@ -1,5 +1,11 @@
import { afterEach, beforeAll, describe, expect, it } from '@jest/globals';
-import { MeetRecordingAccess, MeetRecordingLayout } from '@openvidu-meet/typings';
+import {
+ MeetRecordingAccess,
+ MeetRecordingAudioCodec,
+ MeetRecordingEncodingPreset,
+ MeetRecordingLayout,
+ MeetRecordingVideoCodec
+} from '@openvidu-meet/typings';
import ms from 'ms';
import {
expectSuccessRoomResponse,
@@ -39,6 +45,7 @@ describe('Room API Tests', () => {
recording: {
enabled: true,
layout: MeetRecordingLayout.SPEAKER,
+ encoding: MeetRecordingEncodingPreset.H264_1080P_30,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
@@ -107,6 +114,55 @@ describe('Room API Tests', () => {
expect(response.body.moderatorUrl).toBeUndefined();
});
+ it('should retrieve a room with encoding preset', async () => {
+ const createdRoom = await createRoom({
+ roomName: 'encoding-preset-test',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: MeetRecordingEncodingPreset.H264_1080P_60
+ }
+ }
+ });
+
+ const response = await getRoom(createdRoom.roomId);
+ expect(response.status).toBe(200);
+ expect(response.body.config.recording.encoding).toBe(MeetRecordingEncodingPreset.H264_1080P_60);
+ });
+
+ it('should retrieve a room with advanced encoding options', async () => {
+ const advancedEncoding = {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 60,
+ codec: MeetRecordingVideoCodec.H264_HIGH,
+ bitrate: 5000,
+ keyFrameInterval: 2,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.AAC,
+ bitrate: 192,
+ frequency: 48000
+ }
+ };
+
+ const createdRoom = await createRoom({
+ roomName: 'advanced-encoding-test',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: advancedEncoding
+ }
+ }
+ });
+
+ const response = await getRoom(createdRoom.roomId);
+ expect(response.status).toBe(200);
+ expect(response.body.config.recording.encoding).toMatchObject(advancedEncoding);
+ });
+
it('should return 404 for a non-existent room', async () => {
const fakeRoomId = 'non-existent-room-id';
const response = await getRoom(fakeRoomId);
diff --git a/meet-ce/backend/tests/integration/api/rooms/recording-layout-room-config.test.ts b/meet-ce/backend/tests/integration/api/rooms/recording-layout-room-config.test.ts
index f302b707..4dc878d0 100644
--- a/meet-ce/backend/tests/integration/api/rooms/recording-layout-room-config.test.ts
+++ b/meet-ce/backend/tests/integration/api/rooms/recording-layout-room-config.test.ts
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, it } from '@jest/globals';
import { MeetRecordingAccess, MeetRecordingLayout } from '@openvidu-meet/typings';
-import { expectValidRoom } from '../../../helpers/assertion-helpers.js';
+import { DEFAULT_RECORDING_ENCODING_PRESET, DEFAULT_RECORDING_LAYOUT, expectValidRoom } from '../../../helpers/assertion-helpers.js';
import { createRoom, deleteAllRooms, startTestServer } from '../../../helpers/request-helpers.js';
describe('Room API Tests', () => {
@@ -27,7 +27,8 @@ describe('Room API Tests', () => {
const expectedConfig = {
recording: {
enabled: true,
- layout: MeetRecordingLayout.GRID, // Default value
+ layout: DEFAULT_RECORDING_LAYOUT,
+ encoding: DEFAULT_RECORDING_ENCODING_PRESET,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
@@ -56,6 +57,7 @@ describe('Room API Tests', () => {
recording: {
enabled: true,
layout: MeetRecordingLayout.SPEAKER,
+ encoding: DEFAULT_RECORDING_ENCODING_PRESET,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
@@ -84,6 +86,7 @@ describe('Room API Tests', () => {
recording: {
enabled: true,
layout: MeetRecordingLayout.SINGLE_SPEAKER,
+ encoding: DEFAULT_RECORDING_ENCODING_PRESET,
allowAccessTo: MeetRecordingAccess.ADMIN
},
chat: { enabled: true },
diff --git a/meet-ce/backend/tests/integration/api/rooms/update-room-config.test.ts b/meet-ce/backend/tests/integration/api/rooms/update-room-config.test.ts
index d8a1a51c..10d95e1a 100644
--- a/meet-ce/backend/tests/integration/api/rooms/update-room-config.test.ts
+++ b/meet-ce/backend/tests/integration/api/rooms/update-room-config.test.ts
@@ -1,7 +1,16 @@
import { afterEach, beforeAll, describe, expect, it, jest } from '@jest/globals';
-import { MeetRecordingAccess, MeetRecordingLayout, MeetRoomConfig, MeetSignalType } from '@openvidu-meet/typings';
+import {
+ MeetRecordingAccess,
+ MeetRecordingAudioCodec,
+ MeetRecordingEncodingPreset,
+ MeetRecordingLayout,
+ MeetRecordingVideoCodec,
+ MeetRoomConfig,
+ MeetSignalType
+} from '@openvidu-meet/typings';
import { container } from '../../../../src/config/dependency-injector.config.js';
import { FrontendEventService } from '../../../../src/services/frontend-event.service.js';
+import { expectValidationError } from '../../../helpers/assertion-helpers.js';
import {
createRoom,
deleteAllRooms,
@@ -36,6 +45,7 @@ describe('Room API Tests', () => {
config: {
recording: {
enabled: true,
+ encoding: MeetRecordingEncodingPreset.H264_720P_30,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
@@ -49,6 +59,7 @@ describe('Room API Tests', () => {
const updatedConfig = {
recording: {
enabled: false,
+ encoding: MeetRecordingEncodingPreset.H264_1080P_60,
allowAccessTo: MeetRecordingAccess.ADMIN
},
chat: { enabled: false },
@@ -124,6 +135,7 @@ describe('Room API Tests', () => {
recording: {
enabled: false,
layout: MeetRecordingLayout.SPEAKER,
+ encoding: MeetRecordingEncodingPreset.H264_720P_30, // Default value
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
@@ -176,6 +188,167 @@ describe('Room API Tests', () => {
expect(response.status).toBe(404);
expect(response.body.message).toContain(`'${nonExistentRoomId}' does not exist`);
});
+
+ it('should update room encoding preset from H264_720P_30 to H264_1080P_60', async () => {
+ const createdRoom = await createRoom({
+ roomName: 'encoding-update-test',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: MeetRecordingEncodingPreset.H264_720P_30
+ }
+ }
+ });
+
+ // Update encoding preset
+ const updatedConfig = {
+ recording: {
+ enabled: true,
+ encoding: MeetRecordingEncodingPreset.H264_1080P_60
+ }
+ };
+ const updateResponse = await updateRoomConfig(createdRoom.roomId, updatedConfig);
+
+ expect(updateResponse.status).toBe(200);
+
+ // Verify with a get request
+ const getResponse = await getRoom(createdRoom.roomId);
+ expect(getResponse.status).toBe(200);
+ expect(getResponse.body.config.recording.encoding).toBe(MeetRecordingEncodingPreset.H264_1080P_60);
+ });
+
+ it('should update room encoding from preset to advanced options', async () => {
+ const createdRoom = await createRoom({
+ roomName: 'preset-to-advanced',
+ config: {
+ recording: {
+ enabled: true
+ }
+ }
+ });
+
+ // Update to advanced encoding with both video and audio
+ const updatedConfig = {
+ recording: {
+ enabled: true,
+ encoding: {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 60,
+ codec: MeetRecordingVideoCodec.H264_HIGH,
+ bitrate: 5000,
+ keyFrameInterval: 2,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.AAC,
+ bitrate: 192,
+ frequency: 48000
+ }
+ }
+ }
+ };
+ const updateResponse = await updateRoomConfig(createdRoom.roomId, updatedConfig);
+
+ expect(updateResponse.status).toBe(200);
+
+ // Verify with a get request
+ const getResponse = await getRoom(createdRoom.roomId);
+ expect(getResponse.status).toBe(200);
+ expect(getResponse.body.config.recording.encoding).toMatchObject(updatedConfig.recording.encoding);
+ });
+
+ it('should update room encoding from advanced options to preset', async () => {
+ const recordingEncoding = {
+ video: {
+ width: 1280,
+ height: 720,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 3000,
+ keyFrameInterval: 4,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: 128,
+ frequency: 44100
+ }
+ };
+ const createdRoom = await createRoom({
+ roomName: 'advanced-to-preset',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: recordingEncoding
+ }
+ }
+ });
+
+ expect(createdRoom.config.recording.encoding).toMatchObject(recordingEncoding);
+ // Update to preset encoding
+ const updatedConfig = {
+ recording: {
+ enabled: true,
+ encoding: MeetRecordingEncodingPreset.PORTRAIT_H264_1080P_60
+ }
+ };
+ const updateResponse = await updateRoomConfig(createdRoom.roomId, updatedConfig);
+
+ expect(updateResponse.status).toBe(200);
+
+ // Verify with a get request
+ const getResponse = await getRoom(createdRoom.roomId);
+ expect(getResponse.status).toBe(200);
+ expect(getResponse.body.config.recording.encoding).toBe(MeetRecordingEncodingPreset.PORTRAIT_H264_1080P_60);
+ });
+
+ it('should update only encoding while keeping other recording config', async () => {
+ const createdRoom = await createRoom({
+ roomName: 'partial-encoding-update',
+ config: {
+ recording: {
+ enabled: true,
+ layout: MeetRecordingLayout.SPEAKER,
+ encoding: MeetRecordingEncodingPreset.H264_720P_30,
+ allowAccessTo: MeetRecordingAccess.ADMIN
+ }
+ }
+ });
+
+ expect(createdRoom.config.recording.layout).toBe(MeetRecordingLayout.SPEAKER);
+ expect(createdRoom.config.recording.encoding).toBe(MeetRecordingEncodingPreset.H264_720P_30);
+
+ // Update only encoding
+ const partialConfig: Partial = {
+ recording: {
+ enabled: true,
+ encoding: MeetRecordingEncodingPreset.H264_1080P_30
+ }
+ };
+ const updateResponse = await updateRoomConfig(createdRoom.roomId, partialConfig);
+
+ expect(updateResponse.status).toBe(200);
+
+ // Verify with a get request
+ const getResponse = await getRoom(createdRoom.roomId);
+ expect(getResponse.status).toBe(200);
+
+ const expectedConfig = {
+ recording: {
+ enabled: true,
+ layout: MeetRecordingLayout.SPEAKER,
+ encoding: MeetRecordingEncodingPreset.H264_1080P_30,
+ allowAccessTo: MeetRecordingAccess.ADMIN
+ },
+ chat: { enabled: true },
+ virtualBackground: { enabled: true },
+ e2ee: { enabled: false },
+ captions: { enabled: true }
+ };
+ expect(getResponse.body.config).toEqual(expectedConfig);
+ });
});
describe('Update Room Config Validation failures', () => {
@@ -199,5 +372,185 @@ describe('Room API Tests', () => {
expect(response.body.error).toContain('Unprocessable Entity');
expect(JSON.stringify(response.body.details)).toContain('recording.enabled');
});
+
+ it('should reject update with video-only encoding (audio required)', async () => {
+ const createdRoom = await createRoom({
+ roomName: 'video-only-update',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: MeetRecordingEncodingPreset.H264_720P_30
+ }
+ }
+ });
+
+ const updatedConfig = {
+ recording: {
+ enabled: true,
+ encoding: {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 60,
+ codec: MeetRecordingVideoCodec.H264_HIGH,
+ bitrate: 5000
+ }
+ // No audio encoding
+ }
+ }
+ } as any;
+ const updateResponse = await updateRoomConfig(createdRoom.roomId, updatedConfig);
+
+ expectValidationError(
+ updateResponse,
+ 'config.recording.encoding',
+ 'Both video and audio configuration must be provided when using encoding options'
+ );
+ });
+
+ it('should reject update with audio-only encoding (video required)', async () => {
+ const createdRoom = await createRoom({
+ roomName: 'audio-only-update',
+ config: {
+ recording: {
+ enabled: true,
+ encoding: MeetRecordingEncodingPreset.H264_720P_30
+ }
+ }
+ });
+
+ const updatedConfig = {
+ recording: {
+ enabled: true,
+ encoding: {
+ audio: {
+ codec: MeetRecordingAudioCodec.AAC,
+ bitrate: 256,
+ frequency: 48000
+ }
+ // No video encoding
+ }
+ }
+ } as any;
+ const updateResponse = await updateRoomConfig(createdRoom.roomId, updatedConfig);
+
+ expectValidationError(
+ updateResponse,
+ 'config.recording.encoding',
+ 'Both video and audio configuration must be provided when using encoding options'
+ );
+ });
+
+ it('should fail when updating with partial video encoding missing required fields', async () => {
+ const createdRoom = await createRoom({
+ roomName: 'partial-video-update-test'
+ });
+
+ const invalidConfig = {
+ recording: {
+ enabled: true,
+ encoding: {
+ video: {
+ width: 1920,
+ height: 1080
+ // Missing framerate, codec, bitrate
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.AAC,
+ bitrate: 192,
+ frequency: 48000
+ }
+ }
+ }
+ };
+ const response = await updateRoomConfig(createdRoom.roomId, invalidConfig as unknown as MeetRoomConfig);
+
+ expect(response.status).toBe(422);
+ expect(JSON.stringify(response.body.details)).toContain(
+ 'When video encoding is provided, required fields are missing'
+ );
+ });
+
+ it('should fail when updating with partial audio encoding missing required fields', async () => {
+ const createdRoom = await createRoom({
+ roomName: 'partial-audio-update-test'
+ });
+
+ const invalidConfig = {
+ recording: {
+ enabled: true,
+ encoding: {
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500,
+ keyFrameInterval: 4,
+ depth: 24
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.AAC
+ // Missing bitrate and frequency
+ }
+ }
+ }
+ };
+ const response = await updateRoomConfig(createdRoom.roomId, invalidConfig as unknown as MeetRoomConfig);
+
+ expect(response.status).toBe(422);
+ expect(JSON.stringify(response.body.details)).toContain(
+ 'When audio encoding is provided, required fields are missing'
+ );
+ });
+
+ it('should fail when updating with empty encoding object', async () => {
+ const createdRoom = await createRoom({
+ roomName: 'empty-encoding-update-test'
+ });
+
+ const invalidConfig = {
+ recording: {
+ enabled: true,
+ encoding: {}
+ }
+ };
+ const response = await updateRoomConfig(createdRoom.roomId, invalidConfig as unknown as MeetRoomConfig);
+
+ expect(response.status).toBe(422);
+ expect(JSON.stringify(response.body.details)).toContain(
+ 'Both video and audio configuration must be provided when using encoding options'
+ );
+ });
+
+ it('should fail when updating encoding with invalid types', async () => {
+ const createdRoom = await createRoom({
+ roomName: 'invalid-types-update-test'
+ });
+
+ const invalidConfig = {
+ recording: {
+ enabled: true,
+ encoding: {
+ video: {
+ width: '1920', // String instead of number
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.AAC,
+ bitrate: 192,
+ frequency: 48000
+ }
+ }
+ }
+ };
+ const response = await updateRoomConfig(createdRoom.roomId, invalidConfig as unknown as MeetRoomConfig);
+
+ expect(response.status).toBe(422);
+ expect(JSON.stringify(response.body.details)).toContain('Expected number');
+ });
});
});
diff --git a/meet-ce/backend/tests/integration/webhooks/webhook.test.ts b/meet-ce/backend/tests/integration/webhooks/webhook.test.ts
index 3e8f966b..e81938e6 100644
--- a/meet-ce/backend/tests/integration/webhooks/webhook.test.ts
+++ b/meet-ce/backend/tests/integration/webhooks/webhook.test.ts
@@ -1,5 +1,15 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
-import { MeetRecordingInfo, MeetRecordingLayout, MeetRecordingStatus, MeetWebhookEvent, MeetWebhookEventType } from '@openvidu-meet/typings';
+import {
+ MeetRecordingAccess,
+ MeetRecordingEncodingPreset,
+ MeetRecordingInfo,
+ MeetRecordingLayout,
+ MeetRecordingStatus,
+ MeetRoom,
+ MeetRoomConfig,
+ MeetWebhookEvent,
+ MeetWebhookEventType
+} from '@openvidu-meet/typings';
import { Request } from 'express';
import http from 'http';
import {
@@ -23,6 +33,19 @@ import {
describe('Webhook Integration Tests', () => {
let receivedWebhooks: { headers: http.IncomingHttpHeaders; body: MeetWebhookEvent }[] = [];
+ const defaultRoomConfig: MeetRoomConfig = {
+ recording: {
+ enabled: true,
+ layout: MeetRecordingLayout.GRID,
+ encoding: MeetRecordingEncodingPreset.H264_720P_30,
+ allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
+ },
+ chat: { enabled: true },
+ virtualBackground: { enabled: true },
+ e2ee: { enabled: false },
+ captions: { enabled: true }
+ };
+
beforeAll(async () => {
await startTestServer();
@@ -52,6 +75,11 @@ describe('Webhook Integration Tests', () => {
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
});
+ const expectValidSignature = (webhook: { headers: http.IncomingHttpHeaders; body: MeetWebhookEvent }) => {
+ expect(webhook.headers['x-signature']).toBeDefined();
+ expect(webhook.headers['x-timestamp']).toBeDefined();
+ };
+
it('should not send webhooks when disabled', async () => {
await updateWebbhookConfig({
enabled: false
@@ -80,8 +108,12 @@ describe('Webhook Integration Tests', () => {
expect(meetingStartedWebhook?.body.data.roomId).toBe(roomData.roomId);
expect(meetingStartedWebhook?.body.creationDate).toBeLessThanOrEqual(Date.now());
expect(meetingStartedWebhook?.body.creationDate).toBeGreaterThanOrEqual(Date.now() - 3000);
- expect(meetingStartedWebhook?.headers['x-signature']).toBeDefined();
- expect(meetingStartedWebhook?.headers['x-timestamp']).toBeDefined();
+
+ const room: MeetRoom = meetingStartedWebhook!.body.data as MeetRoom;
+ expect(room.roomId).toBe(roomData.roomId);
+ expect(room.config).toEqual(defaultRoomConfig);
+
+ expectValidSignature(meetingStartedWebhook!);
});
it('should send meeting_ended webhook when meeting is closed', async () => {
@@ -99,11 +131,14 @@ describe('Webhook Integration Tests', () => {
expect(receivedWebhooks.length).toBeGreaterThanOrEqual(1);
const meetingEndedWebhook = receivedWebhooks.find((w) => w.body.event === MeetWebhookEventType.MEETING_ENDED);
expect(meetingEndedWebhook).toBeDefined();
- expect(meetingEndedWebhook?.body.data.roomId).toBe(roomData.roomId);
expect(meetingEndedWebhook?.body.creationDate).toBeLessThanOrEqual(Date.now());
expect(meetingEndedWebhook?.body.creationDate).toBeGreaterThanOrEqual(Date.now() - 3000);
- expect(meetingEndedWebhook?.headers['x-signature']).toBeDefined();
- expect(meetingEndedWebhook?.headers['x-timestamp']).toBeDefined();
+
+ const room: MeetRoom = meetingEndedWebhook!.body.data as MeetRoom;
+ expect(room.roomId).toBe(roomData.roomId);
+ expect(room.config).toEqual(defaultRoomConfig);
+
+ expectValidSignature(meetingEndedWebhook!);
});
it('should send meeting_ended when room is forcefully deleted', async () => {
@@ -119,8 +154,12 @@ describe('Webhook Integration Tests', () => {
expect(meetingEndedWebhook?.body.data.roomId).toBe(roomData.roomId);
expect(meetingEndedWebhook?.body.creationDate).toBeLessThanOrEqual(Date.now());
expect(meetingEndedWebhook?.body.creationDate).toBeGreaterThanOrEqual(Date.now() - 3000);
- expect(meetingEndedWebhook?.headers['x-signature']).toBeDefined();
- expect(meetingEndedWebhook?.headers['x-timestamp']).toBeDefined();
+
+ const room: MeetRoom = meetingEndedWebhook!.body.data as MeetRoom;
+ expect(room.roomId).toBe(roomData.roomId);
+ expect(room.config).toEqual(defaultRoomConfig);
+
+ expectValidSignature(meetingEndedWebhook!);
});
it('should send recordingStarted, recordingUpdated and recordingEnded webhooks when recording is started and stopped', async () => {
@@ -144,8 +183,8 @@ describe('Webhook Integration Tests', () => {
expect(data.recordingId).toBe(recordingId);
expect(recordingStartedWebhook?.body.creationDate).toBeLessThan(Date.now());
expect(recordingStartedWebhook?.body.creationDate).toBeGreaterThan(startDate);
- expect(recordingStartedWebhook?.headers['x-signature']).toBeDefined();
- expect(recordingStartedWebhook?.headers['x-timestamp']).toBeDefined();
+
+ expectValidSignature(recordingStartedWebhook!);
expect(recordingStartedWebhook?.body.event).toBe(MeetWebhookEventType.RECORDING_STARTED);
expect(data.status).toBe(MeetRecordingStatus.STARTING);
expect(data.layout).toBeDefined();
@@ -154,6 +193,9 @@ describe('Webhook Integration Tests', () => {
expect(Object.values(MeetRecordingLayout)).toContain(data.layout);
}
+ // Validate encoding is present and coherent with default value
+ expect(data.encoding).toBeDefined();
+ expect(data.encoding).toBe(MeetRecordingEncodingPreset.H264_720P_30);
// Check recording_updated webhook
const recordingUpdatedWebhook = receivedWebhooks.find(
(w) => w.body.event === MeetWebhookEventType.RECORDING_UPDATED
@@ -164,8 +206,7 @@ describe('Webhook Integration Tests', () => {
expect(data.recordingId).toBe(recordingId);
expect(recordingUpdatedWebhook?.body.creationDate).toBeLessThan(Date.now());
expect(recordingUpdatedWebhook?.body.creationDate).toBeGreaterThan(startDate);
- expect(recordingUpdatedWebhook?.headers['x-signature']).toBeDefined();
- expect(recordingUpdatedWebhook?.headers['x-timestamp']).toBeDefined();
+ expectValidSignature(recordingUpdatedWebhook!);
expect(recordingUpdatedWebhook?.body.event).toBe(MeetWebhookEventType.RECORDING_UPDATED);
expect(data.status).toBe(MeetRecordingStatus.ACTIVE);
expect(data.layout).toBeDefined();
@@ -174,6 +215,10 @@ describe('Webhook Integration Tests', () => {
expect(Object.values(MeetRecordingLayout)).toContain(data.layout);
}
+ // Validate encoding is present and coherent with default value
+ expect(data.encoding).toBeDefined();
+ expect(data.encoding).toBe('H264_720P_30');
+
// Check recording_ended webhook
const recordingEndedWebhook = receivedWebhooks.find(
(w) => w.body.event === MeetWebhookEventType.RECORDING_ENDED
@@ -184,8 +229,7 @@ describe('Webhook Integration Tests', () => {
expect(data.recordingId).toBe(recordingId);
expect(recordingEndedWebhook?.body.creationDate).toBeLessThan(Date.now());
expect(recordingEndedWebhook?.body.creationDate).toBeGreaterThan(startDate);
- expect(recordingEndedWebhook?.headers['x-signature']).toBeDefined();
- expect(recordingEndedWebhook?.headers['x-timestamp']).toBeDefined();
+ expectValidSignature(recordingEndedWebhook!);
expect(recordingEndedWebhook?.body.event).toBe(MeetWebhookEventType.RECORDING_ENDED);
expect(data.status).not.toBe(MeetRecordingStatus.ENDING);
expect(data.status).toBe(MeetRecordingStatus.COMPLETE);
@@ -194,5 +238,9 @@ describe('Webhook Integration Tests', () => {
if (data.layout) {
expect(Object.values(MeetRecordingLayout)).toContain(data.layout);
}
+
+ // Validate encoding is present and coherent with default value
+ expect(data.encoding).toBeDefined();
+ expect(data.encoding).toBe('H264_720P_30');
});
});
diff --git a/meet-ce/backend/tests/unit/helpers/recording-encoding-conversion.helper.test.ts b/meet-ce/backend/tests/unit/helpers/recording-encoding-conversion.helper.test.ts
new file mode 100644
index 00000000..c6eb3647
--- /dev/null
+++ b/meet-ce/backend/tests/unit/helpers/recording-encoding-conversion.helper.test.ts
@@ -0,0 +1,548 @@
+import { describe, expect, it } from '@jest/globals';
+import {
+ MeetRecordingAudioCodec,
+ MeetRecordingEncodingOptions,
+ MeetRecordingEncodingPreset,
+ MeetRecordingVideoCodec
+} from '@openvidu-meet/typings';
+import { AudioCodec, EncodingOptions, EncodingOptionsPreset, VideoCodec } from 'livekit-server-sdk';
+import { EncodingConverter } from '../../../src/helpers/encoding-converter.helper';
+
+// Helper to create complete encoding options with all required fields
+const createCompleteEncodingOptions = (
+ overrides?: Partial<{
+ video: Partial;
+ audio: Partial;
+ }>
+): MeetRecordingEncodingOptions => ({
+ video: {
+ width: 1920,
+ height: 1080,
+ framerate: 30,
+ codec: MeetRecordingVideoCodec.H264_MAIN,
+ bitrate: 4500,
+ keyFrameInterval: 2,
+ depth: 24,
+ ...overrides?.video
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.OPUS,
+ bitrate: 128,
+ frequency: 48000,
+ ...overrides?.audio
+ }
+});
+
+describe('EncodingConverter - Encoding Options Conversion', () => {
+ describe('toLivekit', () => {
+ describe('Preset Conversion', () => {
+ it('Should convert H264_720P_30 preset correctly', () => {
+ const result = EncodingConverter.toLivekit(MeetRecordingEncodingPreset.H264_720P_30);
+
+ expect(result).toBe(EncodingOptionsPreset.H264_720P_30);
+ });
+
+ it('Should convert H264_720P_60 preset correctly', () => {
+ const result = EncodingConverter.toLivekit(MeetRecordingEncodingPreset.H264_720P_60);
+
+ expect(result).toBe(EncodingOptionsPreset.H264_720P_60);
+ });
+
+ it('Should convert H264_1080P_30 preset correctly', () => {
+ const result = EncodingConverter.toLivekit(MeetRecordingEncodingPreset.H264_1080P_30);
+
+ expect(result).toBe(EncodingOptionsPreset.H264_1080P_30);
+ });
+
+ it('Should convert H264_1080P_60 preset correctly', () => {
+ const result = EncodingConverter.toLivekit(MeetRecordingEncodingPreset.H264_1080P_60);
+
+ expect(result).toBe(EncodingOptionsPreset.H264_1080P_60);
+ });
+
+ it('Should convert PORTRAIT_H264_720P_30 preset correctly', () => {
+ const result = EncodingConverter.toLivekit(MeetRecordingEncodingPreset.PORTRAIT_H264_720P_30);
+
+ expect(result).toBe(EncodingOptionsPreset.PORTRAIT_H264_720P_30);
+ });
+
+ it('Should convert PORTRAIT_H264_720P_60 preset correctly', () => {
+ const result = EncodingConverter.toLivekit(MeetRecordingEncodingPreset.PORTRAIT_H264_720P_60);
+
+ expect(result).toBe(EncodingOptionsPreset.PORTRAIT_H264_720P_60);
+ });
+
+ it('Should convert PORTRAIT_H264_1080P_30 preset correctly', () => {
+ const result = EncodingConverter.toLivekit(MeetRecordingEncodingPreset.PORTRAIT_H264_1080P_30);
+
+ expect(result).toBe(EncodingOptionsPreset.PORTRAIT_H264_1080P_30);
+ });
+
+ it('Should convert PORTRAIT_H264_1080P_60 preset correctly', () => {
+ const result = EncodingConverter.toLivekit(MeetRecordingEncodingPreset.PORTRAIT_H264_1080P_60);
+
+ expect(result).toBe(EncodingOptionsPreset.PORTRAIT_H264_1080P_60);
+ });
+ });
+
+ describe('Advanced Options Conversion', () => {
+ it('Should convert complete encoding options correctly', () => {
+ const meetOptions = createCompleteEncodingOptions();
+
+ const result = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+
+ expect(result).toBeInstanceOf(EncodingOptions);
+ expect(result.width).toBe(1920);
+ expect(result.height).toBe(1080);
+ expect(result.framerate).toBe(30);
+ expect(result.videoCodec).toBe(VideoCodec.H264_MAIN);
+ expect(result.videoBitrate).toBe(4500);
+ expect(result.keyFrameInterval).toBe(2);
+ expect(result.audioCodec).toBe(AudioCodec.OPUS);
+ expect(result.audioBitrate).toBe(128);
+ expect(result.audioFrequency).toBe(48000);
+ });
+
+ it('Should convert options with different video codec correctly', () => {
+ const meetOptions = createCompleteEncodingOptions({
+ video: { codec: MeetRecordingVideoCodec.VP8 }
+ });
+
+ const result = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+
+ expect(result.videoCodec).toBe(VideoCodec.VP8);
+ });
+
+ it('Should convert options with different audio codec correctly', () => {
+ const meetOptions = createCompleteEncodingOptions({
+ audio: { codec: MeetRecordingAudioCodec.AAC }
+ });
+
+ const result = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+
+ expect(result.audioCodec).toBe(AudioCodec.AAC);
+ });
+
+ it('Should convert options with different video dimensions correctly', () => {
+ const meetOptions = createCompleteEncodingOptions({
+ video: { width: 1280, height: 720, framerate: 60, bitrate: 3000 }
+ });
+
+ const result = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+
+ expect(result.width).toBe(1280);
+ expect(result.height).toBe(720);
+ expect(result.framerate).toBe(60);
+ expect(result.videoBitrate).toBe(3000);
+ });
+
+ it('Should convert options with different audio settings correctly', () => {
+ const meetOptions = createCompleteEncodingOptions({
+ audio: { bitrate: 96, frequency: 44100 }
+ });
+
+ const result = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+
+ expect(result.audioBitrate).toBe(96);
+ expect(result.audioFrequency).toBe(44100);
+ });
+
+ it('Should convert options with different keyFrameInterval correctly', () => {
+ const meetOptions = createCompleteEncodingOptions({
+ video: { keyFrameInterval: 4.5 }
+ });
+
+ const result = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+
+ expect(result.keyFrameInterval).toBe(4.5);
+ });
+
+ it('Should convert options with keyFrameInterval = 0 correctly', () => {
+ const meetOptions = createCompleteEncodingOptions({
+ video: { keyFrameInterval: 0 }
+ });
+
+ const result = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+
+ expect(result.keyFrameInterval).toBe(0);
+ });
+
+ it('Should convert all video codecs correctly', () => {
+ const codecs: MeetRecordingVideoCodec[] = [
+ MeetRecordingVideoCodec.H264_BASELINE,
+ MeetRecordingVideoCodec.H264_MAIN,
+ MeetRecordingVideoCodec.H264_HIGH,
+ MeetRecordingVideoCodec.VP8
+ ];
+
+ const expectedLivekitCodecs: VideoCodec[] = [
+ VideoCodec.H264_BASELINE,
+ VideoCodec.H264_MAIN,
+ VideoCodec.H264_HIGH,
+ VideoCodec.VP8
+ ];
+
+ codecs.forEach((codec, index) => {
+ const meetOptions = createCompleteEncodingOptions({
+ video: { codec }
+ });
+
+ const result = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+
+ expect(result.videoCodec).toBe(expectedLivekitCodecs[index]);
+ });
+ });
+
+ it('Should convert all audio codecs correctly', () => {
+ const codecs: MeetRecordingAudioCodec[] = [MeetRecordingAudioCodec.OPUS, MeetRecordingAudioCodec.AAC];
+
+ const expectedLivekitCodecs: AudioCodec[] = [AudioCodec.OPUS, AudioCodec.AAC];
+
+ codecs.forEach((codec, index) => {
+ const meetOptions = createCompleteEncodingOptions({
+ audio: { codec }
+ });
+
+ const result = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+
+ expect(result.audioCodec).toBe(expectedLivekitCodecs[index]);
+ });
+ });
+
+ it('Should bidirectionally convert keyFrameInterval correctly', () => {
+ // Meet -> LiveKit
+ const meetOptions = createCompleteEncodingOptions({
+ video: { keyFrameInterval: 2.5 }
+ });
+
+ const livekitOptions = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+ expect(livekitOptions.keyFrameInterval).toBe(2.5);
+
+ // LiveKit -> Meet (round trip)
+ const convertedBack = EncodingConverter.fromLivekit(livekitOptions) as MeetRecordingEncodingOptions;
+ expect(convertedBack.video.keyFrameInterval).toBe(2.5);
+ });
+
+ it('Should convert depth correctly', () => {
+ const meetOptions = createCompleteEncodingOptions({
+ video: { depth: 32 }
+ });
+
+ const result = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+
+ expect(result.depth).toBe(32);
+ });
+
+ it('Should bidirectionally convert depth correctly', () => {
+ // Meet -> LiveKit
+ const meetOptions = createCompleteEncodingOptions({
+ video: { depth: 16 }
+ });
+
+ const livekitOptions = EncodingConverter.toLivekit(meetOptions) as EncodingOptions;
+ expect(livekitOptions.depth).toBe(16);
+
+ // LiveKit -> Meet (round trip)
+ const convertedBack = EncodingConverter.fromLivekit(livekitOptions) as MeetRecordingEncodingOptions;
+ expect(convertedBack.video.depth).toBe(16);
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('Should return undefined when encoding options is undefined', () => {
+ const result = EncodingConverter.toLivekit(undefined);
+
+ expect(result).toBeUndefined();
+ });
+ });
+ });
+
+ describe('fromLivekit', () => {
+ describe('Preset Conversion from LiveKit', () => {
+ it('Should convert H264_720P_30 preset from LiveKit correctly', () => {
+ const result = EncodingConverter.fromLivekit(EncodingOptionsPreset.H264_720P_30);
+
+ expect(result).toBe(MeetRecordingEncodingPreset.H264_720P_30);
+ });
+
+ it('Should convert H264_720P_60 preset from LiveKit correctly', () => {
+ const result = EncodingConverter.fromLivekit(EncodingOptionsPreset.H264_720P_60);
+
+ expect(result).toBe(MeetRecordingEncodingPreset.H264_720P_60);
+ });
+
+ it('Should convert H264_1080P_30 preset from LiveKit correctly', () => {
+ const result = EncodingConverter.fromLivekit(EncodingOptionsPreset.H264_1080P_30);
+
+ expect(result).toBe(MeetRecordingEncodingPreset.H264_1080P_30);
+ });
+
+ it('Should convert H264_1080P_60 preset from LiveKit correctly', () => {
+ const result = EncodingConverter.fromLivekit(EncodingOptionsPreset.H264_1080P_60);
+
+ expect(result).toBe(MeetRecordingEncodingPreset.H264_1080P_60);
+ });
+
+ it('Should convert PORTRAIT_H264_720P_30 preset from LiveKit correctly', () => {
+ const result = EncodingConverter.fromLivekit(EncodingOptionsPreset.PORTRAIT_H264_720P_30);
+
+ expect(result).toBe(MeetRecordingEncodingPreset.PORTRAIT_H264_720P_30);
+ });
+
+ it('Should convert PORTRAIT_H264_720P_60 preset from LiveKit correctly', () => {
+ const result = EncodingConverter.fromLivekit(EncodingOptionsPreset.PORTRAIT_H264_720P_60);
+
+ expect(result).toBe(MeetRecordingEncodingPreset.PORTRAIT_H264_720P_60);
+ });
+
+ it('Should convert PORTRAIT_H264_1080P_30 preset from LiveKit correctly', () => {
+ const result = EncodingConverter.fromLivekit(EncodingOptionsPreset.PORTRAIT_H264_1080P_30);
+
+ expect(result).toBe(MeetRecordingEncodingPreset.PORTRAIT_H264_1080P_30);
+ });
+
+ it('Should convert PORTRAIT_H264_1080P_60 preset from LiveKit correctly', () => {
+ const result = EncodingConverter.fromLivekit(EncodingOptionsPreset.PORTRAIT_H264_1080P_60);
+
+ expect(result).toBe(MeetRecordingEncodingPreset.PORTRAIT_H264_1080P_60);
+ });
+
+ it('Should return default preset for unknown LiveKit preset', () => {
+ const unknownPreset = 999 as EncodingOptionsPreset;
+ const result = EncodingConverter.fromLivekit(unknownPreset);
+
+ expect(result).toBe(MeetRecordingEncodingPreset.H264_720P_30);
+ });
+ });
+
+ describe('Advanced Options Conversion from LiveKit', () => {
+ it('Should convert complete LiveKit options correctly', () => {
+ const lkOptions = new EncodingOptions();
+ lkOptions.width = 1920;
+ lkOptions.height = 1080;
+ lkOptions.framerate = 30;
+ lkOptions.videoCodec = VideoCodec.H264_MAIN;
+ lkOptions.videoBitrate = 4500;
+ lkOptions.keyFrameInterval = 2;
+ lkOptions.audioCodec = AudioCodec.OPUS;
+ lkOptions.audioBitrate = 128;
+ lkOptions.audioFrequency = 48000;
+
+ const result = EncodingConverter.fromLivekit(lkOptions) as MeetRecordingEncodingOptions;
+
+ // Both video and audio should be defined with all properties
+ expect(result.video).toBeDefined();
+ expect(result.video.width).toBe(1920);
+ expect(result.video.height).toBe(1080);
+ expect(result.video.framerate).toBe(30);
+ expect(result.video.codec).toBe(MeetRecordingVideoCodec.H264_MAIN);
+ expect(result.video.bitrate).toBe(4500);
+ expect(result.video.keyFrameInterval).toBe(2);
+
+ expect(result.audio).toBeDefined();
+ expect(result.audio.codec).toBe(MeetRecordingAudioCodec.OPUS);
+ expect(result.audio.bitrate).toBe(128);
+ expect(result.audio.frequency).toBe(48000);
+ });
+
+ it('Should always include keyFrameInterval even when 0 (LiveKit default)', () => {
+ const lkOptions = new EncodingOptions();
+ // LiveKit initializes keyFrameInterval to 0 by default
+ lkOptions.width = 1920;
+ lkOptions.height = 1080;
+ lkOptions.framerate = 30;
+ lkOptions.videoCodec = VideoCodec.H264_MAIN;
+ lkOptions.videoBitrate = 4500;
+ lkOptions.audioCodec = AudioCodec.OPUS;
+ lkOptions.audioBitrate = 128;
+ lkOptions.audioFrequency = 48000;
+
+ const result = EncodingConverter.fromLivekit(lkOptions) as MeetRecordingEncodingOptions;
+
+ expect(result.video).toBeDefined();
+ expect(result.video.keyFrameInterval).toBe(4);
+ });
+
+ it('Should use default depth of 24 when LiveKit does not provide depth', () => {
+ const lkOptions = new EncodingOptions();
+ lkOptions.width = 1920;
+ lkOptions.height = 1080;
+ lkOptions.framerate = 30;
+ lkOptions.videoCodec = VideoCodec.H264_MAIN;
+ lkOptions.videoBitrate = 4500;
+ lkOptions.audioCodec = AudioCodec.OPUS;
+ lkOptions.audioBitrate = 128;
+ lkOptions.audioFrequency = 48000;
+ // depth is not set in LiveKit
+
+ const result = EncodingConverter.fromLivekit(lkOptions) as MeetRecordingEncodingOptions;
+
+ expect(result.video).toBeDefined();
+ expect(result.video.depth).toBe(24); // Default value
+ });
+
+ it('Should preserve depth value from LiveKit when provided', () => {
+ const lkOptions = new EncodingOptions();
+ lkOptions.width = 1920;
+ lkOptions.height = 1080;
+ lkOptions.framerate = 30;
+ lkOptions.videoCodec = VideoCodec.H264_MAIN;
+ lkOptions.videoBitrate = 4500;
+ lkOptions.depth = 32; // Custom depth
+ lkOptions.audioCodec = AudioCodec.OPUS;
+ lkOptions.audioBitrate = 128;
+ lkOptions.audioFrequency = 48000;
+
+ const result = EncodingConverter.fromLivekit(lkOptions) as MeetRecordingEncodingOptions;
+
+ expect(result.video).toBeDefined();
+ expect(result.video.depth).toBe(32); // Preserved from LiveKit
+ });
+
+ it('Should convert LiveKit options with VP8 codec correctly', () => {
+ const lkOptions = new EncodingOptions();
+ lkOptions.width = 1280;
+ lkOptions.height = 720;
+ lkOptions.framerate = 60;
+ lkOptions.videoCodec = VideoCodec.VP8;
+ lkOptions.videoBitrate = 3000;
+ lkOptions.keyFrameInterval = 4;
+ lkOptions.audioCodec = AudioCodec.AAC;
+ lkOptions.audioBitrate = 96;
+ lkOptions.audioFrequency = 44100;
+
+ const result = EncodingConverter.fromLivekit(lkOptions) as MeetRecordingEncodingOptions;
+
+ expect(result.video.codec).toBe(MeetRecordingVideoCodec.VP8);
+ expect(result.audio.codec).toBe(MeetRecordingAudioCodec.AAC);
+ });
+
+ it('Should handle LiveKit EncodingOptions with default initialization', () => {
+ // LiveKit's EncodingOptions constructor sets default codecs
+ const lkOptions = new EncodingOptions();
+
+ const result = EncodingConverter.fromLivekit(lkOptions) as MeetRecordingEncodingOptions;
+
+ // EncodingOptions has default codecs (H264_MAIN and OPUS)
+ expect(result.video).toBeDefined();
+ expect(result.video.codec).toBe(MeetRecordingVideoCodec.H264_MAIN);
+ expect(result.video.width).toBe(1920);
+ expect(result.video.height).toBe(1080);
+ expect(result.video.framerate).toBe(30);
+ expect(result.video.bitrate).toBe(128);
+ expect(result.video.keyFrameInterval).toBe(4);
+ expect(result.video.depth).toBe(24); // Default value
+
+ expect(result.audio).toBeDefined();
+ expect(result.audio.codec).toBe(MeetRecordingAudioCodec.OPUS);
+ expect(result.audio.bitrate).toBe(128);
+ expect(result.audio.frequency).toBe(44100);
+ });
+
+ it('Should convert all video codecs from LiveKit correctly', () => {
+ const codecs: VideoCodec[] = [
+ VideoCodec.H264_BASELINE,
+ VideoCodec.H264_MAIN,
+ VideoCodec.H264_HIGH,
+ VideoCodec.VP8
+ ];
+
+ const expectedMeetCodecs: MeetRecordingVideoCodec[] = [
+ MeetRecordingVideoCodec.H264_BASELINE,
+ MeetRecordingVideoCodec.H264_MAIN,
+ MeetRecordingVideoCodec.H264_HIGH,
+ MeetRecordingVideoCodec.VP8
+ ];
+
+ codecs.forEach((codec, index) => {
+ const lkOptions = new EncodingOptions();
+ lkOptions.videoCodec = codec;
+
+ const result = EncodingConverter.fromLivekit(lkOptions) as MeetRecordingEncodingOptions;
+
+ expect(result.video.codec).toBe(expectedMeetCodecs[index]);
+ });
+ });
+
+ it('Should convert all audio codecs from LiveKit correctly', () => {
+ const codecs: AudioCodec[] = [AudioCodec.OPUS, AudioCodec.AAC];
+
+ const expectedMeetCodecs: MeetRecordingAudioCodec[] = [
+ MeetRecordingAudioCodec.OPUS,
+ MeetRecordingAudioCodec.AAC
+ ];
+
+ codecs.forEach((codec, index) => {
+ const lkOptions = new EncodingOptions();
+ lkOptions.audioCodec = codec;
+
+ const result = EncodingConverter.fromLivekit(lkOptions) as MeetRecordingEncodingOptions;
+
+ expect(result.audio.codec).toBe(expectedMeetCodecs[index]);
+ });
+ });
+
+ it('Should return default codec for unknown LiveKit video codec', () => {
+ const lkOptions = new EncodingOptions();
+ lkOptions.videoCodec = 999 as VideoCodec;
+
+ const result = EncodingConverter.fromLivekit(lkOptions) as MeetRecordingEncodingOptions;
+
+ expect(result.video.codec).toBe(MeetRecordingVideoCodec.H264_MAIN);
+ });
+
+ it('Should return default codec for unknown LiveKit audio codec', () => {
+ const lkOptions = new EncodingOptions();
+ lkOptions.audioCodec = 999 as AudioCodec;
+
+ const result = EncodingConverter.fromLivekit(lkOptions) as MeetRecordingEncodingOptions;
+
+ expect(result.audio.codec).toBe(MeetRecordingAudioCodec.OPUS);
+ });
+
+ it('Should preserve all values in round-trip conversion', () => {
+ const originalMeetOptions = createCompleteEncodingOptions({
+ video: {
+ width: 1280,
+ height: 720,
+ framerate: 60,
+ codec: MeetRecordingVideoCodec.VP8,
+ bitrate: 3000,
+ keyFrameInterval: 4.5
+ },
+ audio: {
+ codec: MeetRecordingAudioCodec.AAC,
+ bitrate: 96,
+ frequency: 44100
+ }
+ });
+
+ // Meet -> LiveKit
+ const livekitOptions = EncodingConverter.toLivekit(originalMeetOptions) as EncodingOptions;
+
+ // LiveKit -> Meet
+ const convertedBack = EncodingConverter.fromLivekit(livekitOptions) as MeetRecordingEncodingOptions;
+
+ // Verify all values are preserved
+ expect(convertedBack.video.width).toBe(1280);
+ expect(convertedBack.video.height).toBe(720);
+ expect(convertedBack.video.framerate).toBe(60);
+ expect(convertedBack.video.codec).toBe(MeetRecordingVideoCodec.VP8);
+ expect(convertedBack.video.bitrate).toBe(3000);
+ expect(convertedBack.video.keyFrameInterval).toBe(4.5);
+ expect(convertedBack.video.depth).toBe(24); // Default value from helper
+ expect(convertedBack.audio.codec).toBe(MeetRecordingAudioCodec.AAC);
+ expect(convertedBack.audio.bitrate).toBe(96);
+ expect(convertedBack.audio.frequency).toBe(44100);
+ });
+ });
+
+ describe('Edge Cases from LiveKit', () => {
+ it('Should return default preset when undefined', () => {
+ const result = EncodingConverter.fromLivekit(undefined);
+
+ expect(result).toBe(MeetRecordingEncodingPreset.H264_720P_30);
+ });
+ });
+ });
+});
diff --git a/meet-ce/typings/src/recording.model.ts b/meet-ce/typings/src/recording.model.ts
index f0e1f24f..77cc9148 100644
--- a/meet-ce/typings/src/recording.model.ts
+++ b/meet-ce/typings/src/recording.model.ts
@@ -1,21 +1,130 @@
import { SortAndPagination } from './sort-pagination.js';
+/**
+ * Recording status enumeration.
+ */
export enum MeetRecordingStatus {
- STARTING = 'starting',
- ACTIVE = 'active',
- ENDING = 'ending',
- COMPLETE = 'complete',
- FAILED = 'failed',
- ABORTED = 'aborted',
- LIMIT_REACHED = 'limit_reached'
+ STARTING = 'starting',
+ ACTIVE = 'active',
+ ENDING = 'ending',
+ COMPLETE = 'complete',
+ FAILED = 'failed',
+ ABORTED = 'aborted',
+ LIMIT_REACHED = 'limit_reached',
}
+
+/**
+ * Layout options for recordings.
+ */
export enum MeetRecordingLayout {
- GRID = 'grid',
- SPEAKER = 'speaker',
- SINGLE_SPEAKER = 'single-speaker',
- // GRID_LIGHT = 'grid-light',
- // SPEAKER_LIGHT = 'speaker-light',
- // SINGLE_SPEAKER_LIGHT = 'single-speaker-light'
+ GRID = 'grid',
+ SPEAKER = 'speaker',
+ SINGLE_SPEAKER = 'single-speaker',
+ // GRID_LIGHT = 'grid-light',
+ // SPEAKER_LIGHT = 'speaker-light',
+ // SINGLE_SPEAKER_LIGHT = 'single-speaker-light'
+}
+
+/**
+ * Encoding presets for recordings.
+ */
+export enum MeetRecordingEncodingPreset {
+ /**
+ * 1280x720, 30fps, 3000kbps. Recommended for most cases.
+ */
+ H264_720P_30 = 'H264_720P_30',
+ /**
+ * 1280x720, 60fps, ~4500 kbps. Smooth motion for fast action.
+ */
+ H264_720P_60 = 'H264_720P_60',
+
+ /**
+ * 1920x1080, 30fps, ~4500 kbps. High visual quality for detailed content.
+ */
+ H264_1080P_30 = 'H264_1080P_30',
+
+ /**
+ * 1920x1080, 60fps, ~6000 kbps. Premium quality with very smooth motion.
+ */
+ H264_1080P_60 = 'H264_1080P_60',
+
+ /**
+ * Portrait 720x1280, 30fps. Vertical video optimized for mobile/portrait use.
+ */
+ PORTRAIT_H264_720P_30 = 'PORTRAIT_H264_720P_30',
+
+ /**
+ * Portrait 720x1280, 60fps. Vertical video with smoother motion.
+ */
+ PORTRAIT_H264_720P_60 = 'PORTRAIT_H264_720P_60',
+
+ /**
+ * Portrait 1080x1920, 30fps. High-quality vertical recording.
+ */
+ PORTRAIT_H264_1080P_30 = 'PORTRAIT_H264_1080P_30',
+
+ /**
+ * Portrait 1080x1920, 60fps. Premium vertical recording with smooth motion.
+ */
+ PORTRAIT_H264_1080P_60 = 'PORTRAIT_H264_1080P_60',
+}
+
+/**
+ * Advanced encoding options for recordings.
+ * Use presets for common scenarios; use this for fine-grained control.
+ * Both video and audio configurations are required when using advanced options.
+ */
+export interface MeetRecordingEncodingOptions {
+ /** Video encoding configuration */
+ video: {
+ /** Video width in pixels */
+ width: number;
+ /** Video height in pixels */
+ height: number;
+ /** Frame rate in fps */
+ framerate: number;
+ /** Video codec */
+ codec: MeetRecordingVideoCodec;
+ /** Video bitrate in kbps */
+ bitrate: number;
+ /** Keyframe interval in seconds */
+ keyFrameInterval: number;
+ /** Video depth (pixel format) in bits */
+ depth: number;
+ };
+
+ /**
+ * Audio encoding configuration
+ */
+ audio: {
+ /** Audio codec */
+ codec: MeetRecordingAudioCodec;
+ /** Audio bitrate in kbps */
+ bitrate: number;
+ /** Audio sample rate in Hz */
+ frequency: number;
+ };
+}
+
+/**
+ * Video encoding configuration
+ */
+export enum MeetRecordingVideoCodec {
+ DEFAULT_VC = 'DEFAULT_VC',
+ H264_BASELINE = 'H264_BASELINE',
+ H264_MAIN = 'H264_MAIN',
+ H264_HIGH = 'H264_HIGH',
+ VP8 = 'VP8',
+}
+
+/**
+ * Audio encoding configuration
+ */
+export enum MeetRecordingAudioCodec {
+ DEFAULT_AC = 'DEFAULT_AC',
+ OPUS = 'OPUS',
+ AAC = 'AAC',
+ AC_MP3 = 'AC_MP3',
}
// export enum MeetRecordingOutputMode {
@@ -26,25 +135,26 @@ export enum MeetRecordingLayout {
* Interface representing a recording
*/
export interface MeetRecordingInfo {
- recordingId: string;
- roomId: string;
- roomName: string;
- // outputMode: MeetRecordingOutputMode;
- status: MeetRecordingStatus;
- layout?: MeetRecordingLayout;
- filename?: string;
- startDate?: number;
- endDate?: number;
- duration?: number;
- size?: number;
- errorCode?: number;
- error?: string;
- details?: string;
+ recordingId: string;
+ roomId: string;
+ roomName: string;
+ // outputMode: MeetRecordingOutputMode;
+ status: MeetRecordingStatus;
+ layout?: MeetRecordingLayout;
+ encoding?: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions;
+ filename?: string;
+ startDate?: number;
+ endDate?: number;
+ duration?: number;
+ size?: number;
+ errorCode?: number;
+ error?: string;
+ details?: string;
}
export interface MeetRecordingFilters extends SortAndPagination {
- roomId?: string;
- roomName?: string;
- status?: MeetRecordingStatus;
- fields?: string;
+ roomId?: string;
+ roomName?: string;
+ status?: MeetRecordingStatus;
+ fields?: string;
}
diff --git a/meet-ce/typings/src/room-config.ts b/meet-ce/typings/src/room-config.ts
index 01ccfa12..7c46e43f 100644
--- a/meet-ce/typings/src/room-config.ts
+++ b/meet-ce/typings/src/room-config.ts
@@ -1,4 +1,4 @@
-import { MeetRecordingLayout } from './recording.model';
+import { MeetRecordingEncodingOptions, MeetRecordingEncodingPreset, MeetRecordingLayout } from './recording.model';
/**
* Interface representing the config for a room.
@@ -18,6 +18,11 @@ export interface MeetRoomConfig {
export interface MeetRecordingConfig {
enabled: boolean;
layout?: MeetRecordingLayout;
+ /**
+ * Encoding configuration: use a preset string for common scenarios,
+ * or provide detailed options for fine-grained control.
+ */
+ encoding?: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions;
allowAccessTo?: MeetRecordingAccess;
}