2026-06-02 02:34:40 +08:00
|
|
|
import { buildFfmpegCompositionArgs } from '../src/recording/composer';
|
|
|
|
|
import { ServerTrackRecordingFile } from '../src/recording/storage';
|
|
|
|
|
|
2026-06-02 23:59:55 +08:00
|
|
|
function file(
|
|
|
|
|
filename: string,
|
|
|
|
|
trackKind: string,
|
|
|
|
|
participantId: string,
|
|
|
|
|
role = 'participant',
|
|
|
|
|
recordingStartedAt = '2026-06-01T00:00:00.000Z',
|
|
|
|
|
recordingEndedAt = '2026-06-01T00:00:10.000Z'
|
|
|
|
|
): ServerTrackRecordingFile {
|
2026-06-02 02:34:40 +08:00
|
|
|
return {
|
|
|
|
|
meetingId: 'room-1',
|
|
|
|
|
directory: 'recordings/room-1',
|
|
|
|
|
filename,
|
|
|
|
|
filePath: `recordings/room-1/${filename}`,
|
|
|
|
|
metadataPath: `recordings/room-1/${filename}.json`,
|
|
|
|
|
recordingId: 'recording-1',
|
|
|
|
|
participantId,
|
|
|
|
|
trackId: `${participantId}-${trackKind}`,
|
|
|
|
|
trackKind,
|
2026-06-02 23:59:55 +08:00
|
|
|
uploadedAt: recordingStartedAt,
|
|
|
|
|
recordingStartedAt,
|
|
|
|
|
recordingEndedAt,
|
|
|
|
|
metadata: { role, recordingStartedAt, recordingEndedAt, updatedAt: recordingEndedAt }
|
2026-06-02 02:34:40 +08:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('recording composer', () => {
|
|
|
|
|
test('builds ffmpeg args for host-led video layout and mixed audio', () => {
|
|
|
|
|
const args = buildFfmpegCompositionArgs({
|
|
|
|
|
videoInputs: [
|
|
|
|
|
file('p1-video.webm', 'video', 'p1', 'host'),
|
|
|
|
|
file('p2-video.webm', 'video', 'p2')
|
|
|
|
|
],
|
|
|
|
|
audioInputs: [
|
|
|
|
|
file('p1-audio.webm', 'audio', 'p1'),
|
|
|
|
|
file('p2-audio.webm', 'audio', 'p2')
|
|
|
|
|
],
|
|
|
|
|
outputPath: 'recordings/room-1/output.webm',
|
|
|
|
|
format: 'webm'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(args).toContain('-filter_complex');
|
|
|
|
|
expect(args.join(' ')).toContain('xstack=inputs=2');
|
|
|
|
|
expect(args.join(' ')).toContain('scale=1280:540');
|
|
|
|
|
expect(args.join(' ')).toContain('scale=1280:180');
|
|
|
|
|
expect(args.join(' ')).toContain('layout=0_0|0_540');
|
|
|
|
|
expect(args.join(' ')).toContain('amix=inputs=2');
|
|
|
|
|
expect(args).toContain('libvpx-vp9');
|
|
|
|
|
expect(args).toContain('libopus');
|
2026-06-02 23:59:55 +08:00
|
|
|
expect(args).not.toContain('-shortest');
|
2026-06-02 02:34:40 +08:00
|
|
|
expect(args[args.length - 1]).toBe('recordings/room-1/output.webm');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('places host in the first row even when host input is not first', () => {
|
|
|
|
|
const args = buildFfmpegCompositionArgs({
|
|
|
|
|
videoInputs: [
|
|
|
|
|
file('p1-video.webm', 'video', 'p1'),
|
|
|
|
|
file('host-video.webm', 'video', 'host', 'host'),
|
|
|
|
|
file('p2-video.webm', 'video', 'p2')
|
|
|
|
|
],
|
|
|
|
|
audioInputs: [],
|
|
|
|
|
outputPath: 'recordings/room-1/output.webm',
|
|
|
|
|
format: 'webm'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const filter = args[args.indexOf('-filter_complex') + 1];
|
|
|
|
|
expect(args.slice(0, 7)).toEqual([
|
|
|
|
|
'-y',
|
|
|
|
|
'-i',
|
|
|
|
|
'recordings/room-1/host-video.webm',
|
|
|
|
|
'-i',
|
|
|
|
|
'recordings/room-1/p1-video.webm',
|
|
|
|
|
'-i',
|
|
|
|
|
'recordings/room-1/p2-video.webm'
|
|
|
|
|
]);
|
|
|
|
|
expect(filter).toContain('scale=1280:540');
|
|
|
|
|
expect(filter).toContain('scale=640:180');
|
|
|
|
|
expect(filter).toContain('layout=0_0|0_540|640_540');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('builds mp4 encoder args', () => {
|
|
|
|
|
const args = buildFfmpegCompositionArgs({
|
|
|
|
|
videoInputs: [file('p1-video.webm', 'video', 'p1')],
|
|
|
|
|
audioInputs: [],
|
|
|
|
|
outputPath: 'recordings/room-1/output.mp4',
|
|
|
|
|
format: 'mp4'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(args).toContain('libx264');
|
|
|
|
|
expect(args).toContain('-pix_fmt');
|
|
|
|
|
expect(args).not.toContain('libopus');
|
|
|
|
|
});
|
2026-06-02 23:59:55 +08:00
|
|
|
|
|
|
|
|
test('pads late participant tracks to keep the room timeline aligned', () => {
|
|
|
|
|
const args = buildFfmpegCompositionArgs({
|
|
|
|
|
videoInputs: [
|
|
|
|
|
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:10.000Z'),
|
|
|
|
|
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:02.500Z', '2026-06-01T00:00:10.000Z')
|
|
|
|
|
],
|
|
|
|
|
audioInputs: [
|
|
|
|
|
file('host-audio.webm', 'audio', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:10.000Z'),
|
|
|
|
|
file('p1-audio.webm', 'audio', 'p1', 'participant', '2026-06-01T00:00:02.500Z', '2026-06-01T00:00:10.000Z')
|
|
|
|
|
],
|
|
|
|
|
outputPath: 'recordings/room-1/output.webm',
|
|
|
|
|
format: 'webm'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const filter = args[args.indexOf('-filter_complex') + 1];
|
|
|
|
|
expect(filter).toContain('[0:v]split=2[vin0_0][vin0_1]');
|
|
|
|
|
expect(filter).toContain('[vin0_0]trim=start=0:duration=2.5');
|
|
|
|
|
expect(filter).toContain('[vin0_1]trim=start=2.5:duration=7.5');
|
|
|
|
|
expect(filter).toContain('[1:v]trim=start=0:duration=7.5');
|
|
|
|
|
expect(filter).toContain('concat=n=2:v=1:a=0[vout]');
|
|
|
|
|
expect(filter).toContain('[2:a]aresample=async=1:first_pts=0[a0]');
|
|
|
|
|
expect(filter).toContain('[3:a]aresample=async=1:first_pts=0,adelay=2500:all=1[a1]');
|
|
|
|
|
expect(filter).toContain('[a0][a1]amix=inputs=2:duration=longest');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('changes the layout when participants join and leave without overlapping', () => {
|
|
|
|
|
const args = buildFfmpegCompositionArgs({
|
|
|
|
|
videoInputs: [
|
|
|
|
|
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:12.000Z'),
|
|
|
|
|
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:05.000Z'),
|
|
|
|
|
file('p2-video.webm', 'video', 'p2', 'participant', '2026-06-01T00:00:05.000Z', '2026-06-01T00:00:12.000Z')
|
|
|
|
|
],
|
|
|
|
|
audioInputs: [],
|
|
|
|
|
outputPath: 'recordings/room-1/output.webm',
|
|
|
|
|
format: 'webm'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const filter = args[args.indexOf('-filter_complex') + 1];
|
|
|
|
|
expect(filter).toContain('xstack=inputs=2');
|
|
|
|
|
expect(filter).toContain('layout=0_0|0_540');
|
|
|
|
|
expect(filter).toContain('[0:v]split=2[vin0_0][vin0_1]');
|
|
|
|
|
expect(filter).toContain('[vin0_0]trim=start=0:duration=5');
|
|
|
|
|
expect(filter).toContain('[1:v]trim=start=0:duration=5');
|
|
|
|
|
expect(filter).toContain('[vin0_1]trim=start=5:duration=7');
|
|
|
|
|
expect(filter).toContain('[2:v]trim=start=0:duration=7');
|
|
|
|
|
expect(filter).toContain('concat=n=2:v=1:a=0[vout]');
|
|
|
|
|
expect(filter).not.toContain('xstack=inputs=3');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('keeps separate viewports for participants whose video intervals overlap', () => {
|
|
|
|
|
const args = buildFfmpegCompositionArgs({
|
|
|
|
|
videoInputs: [
|
|
|
|
|
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:12.000Z'),
|
|
|
|
|
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:08.000Z'),
|
|
|
|
|
file('p2-video.webm', 'video', 'p2', 'participant', '2026-06-01T00:00:05.000Z', '2026-06-01T00:00:12.000Z')
|
|
|
|
|
],
|
|
|
|
|
audioInputs: [],
|
|
|
|
|
outputPath: 'recordings/room-1/output.webm',
|
|
|
|
|
format: 'webm'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const filter = args[args.indexOf('-filter_complex') + 1];
|
|
|
|
|
expect(filter).toContain('xstack=inputs=3');
|
|
|
|
|
expect(filter).toContain('layout=0_0|0_540|640_540');
|
|
|
|
|
});
|
2026-06-02 02:34:40 +08:00
|
|
|
});
|