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; }