Adds recording encoding options to room config and start recording
Adds configuration options for recording encoding, including presets and advanced settings, allowing users to customize video and audio quality. This enhancement introduces new schemas for recording encoding presets and advanced options, enabling users to select from predefined encoding profiles or fine-tune specific video and audio parameters. A conversion helper is implemented to translate between the internal encoding configurations and the format required by the LiveKit SDK. backend: Adds recording encoding configuration options Allows users to specify custom audio and video encoding settings for recordings, overriding room defaults. This enhancement provides greater flexibility in controlling recording quality and file size. It introduces new schema definitions for encoding options and validates these configurations through Zod schemas. Enforces complete video/audio encoding options Requires both video and audio configurations with all their properties when using advanced encoding options for recordings. This change ensures complete encoding setups and prevents potential recording failures due to missing encoding parameters. It also corrects a typo of keyframeInterval. Add video depth option to recording encoding settings
This commit is contained in:
parent
1add921ce0
commit
accb35c7e1
@ -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'
|
||||
|
||||
@ -9,7 +9,8 @@ content:
|
||||
example:
|
||||
config:
|
||||
recording:
|
||||
enabled: false
|
||||
enabled: true
|
||||
encoding: H264_720P_30
|
||||
chat:
|
||||
enabled: true
|
||||
virtualBackground:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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.<br/>
|
||||
This allows participants to see real-time captions of the all participants' speech during the meeting.<br/>
|
||||
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
|
||||
|
||||
192
meet-ce/backend/src/helpers/encoding-converter.helper.ts
Normal file
192
meet-ce/backend/src/helpers/encoding-converter.helper.ts
Normal file
@ -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, EncodingOptionsPreset>([
|
||||
[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, VideoCodec>([
|
||||
[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, AudioCodec>([
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -46,7 +46,11 @@ const MeetRecordingSchema = new Schema<MeetRecordingDocument>(
|
||||
layout: {
|
||||
type: String,
|
||||
enum: Object.values(MeetRecordingLayout),
|
||||
required: false
|
||||
required: true
|
||||
},
|
||||
encoding: {
|
||||
type: Schema.Types.Mixed,
|
||||
required: true
|
||||
},
|
||||
filename: {
|
||||
type: String,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<MeetRecordingFilters> = z.object({
|
||||
|
||||
@ -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<MeetRecordingEncodingOptions> = 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<MeetRecordingAccess> = z.nativeEnum(MeetRecordingAccess);
|
||||
|
||||
const RecordingConfigSchema: z.ZodType<MeetRecordingConfig> = 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<MeetRoomOptions> = z.object({
|
||||
recording: {
|
||||
enabled: true,
|
||||
layout: MeetRecordingLayout.GRID,
|
||||
encoding: MeetRecordingEncodingPreset.H264_720P_30,
|
||||
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
|
||||
},
|
||||
chat: { enabled: true },
|
||||
|
||||
@ -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<MeetRecordingInfo> {
|
||||
let acquiredLock: RedisLock | null = null;
|
||||
let eventListener!: (info: Record<string, unknown>) => 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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}'`);
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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<MeetRoomConfig> = {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<MeetRecordingEncodingOptions['video']>;
|
||||
audio: Partial<MeetRecordingEncodingOptions['audio']>;
|
||||
}>
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,8 @@
|
||||
import { SortAndPagination } from './sort-pagination.js';
|
||||
|
||||
/**
|
||||
* Recording status enumeration.
|
||||
*/
|
||||
export enum MeetRecordingStatus {
|
||||
STARTING = 'starting',
|
||||
ACTIVE = 'active',
|
||||
@ -7,8 +10,12 @@ export enum MeetRecordingStatus {
|
||||
COMPLETE = 'complete',
|
||||
FAILED = 'failed',
|
||||
ABORTED = 'aborted',
|
||||
LIMIT_REACHED = 'limit_reached'
|
||||
LIMIT_REACHED = 'limit_reached',
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout options for recordings.
|
||||
*/
|
||||
export enum MeetRecordingLayout {
|
||||
GRID = 'grid',
|
||||
SPEAKER = 'speaker',
|
||||
@ -18,6 +25,108 @@ export enum MeetRecordingLayout {
|
||||
// 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 {
|
||||
// COMPOSED = 'composed',
|
||||
// }
|
||||
@ -32,6 +141,7 @@ export interface MeetRecordingInfo {
|
||||
// outputMode: MeetRecordingOutputMode;
|
||||
status: MeetRecordingStatus;
|
||||
layout?: MeetRecordingLayout;
|
||||
encoding?: MeetRecordingEncodingPreset | MeetRecordingEncodingOptions;
|
||||
filename?: string;
|
||||
startDate?: number;
|
||||
endDate?: number;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user