import { buildFfmpegCompositionArgs } from '../src/recording/composer'; import { ServerTrackRecordingFile } from '../src/recording/storage'; 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 { 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, uploadedAt: recordingStartedAt, recordingStartedAt, recordingEndedAt, metadata: { role, recordingStartedAt, recordingEndedAt, updatedAt: recordingEndedAt } }; } 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=2560:1080'); expect(args.join(' ')).toContain('scale=2560:360'); expect(args.join(' ')).toContain('layout=0_0|0_1080'); expect(args.join(' ')).toContain('fps=60'); expect(args.join(' ')).toContain('amix=inputs=2'); expect(args).toContain('libvpx-vp9'); expect(args).toContain('libopus'); expect(args).toContain('16000k'); expect(args).not.toContain('-shortest'); 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=2560:1080'); expect(filter).toContain('scale=1280:360'); expect(filter).toContain('layout=0_0|0_1080|1280_1080'); }); 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).toContain('16000k'); expect(args).toContain('60'); expect(args).not.toContain('libopus'); }); test('falls back to one video segment when input end timestamps are missing', () => { const args = buildFfmpegCompositionArgs({ videoInputs: [ file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', ''), file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:02.500Z', '') ], 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('trim=start=0,setpts=PTS-STARTPTS'); expect(filter).not.toContain('concat=n=0'); expect(args).not.toContain('-t'); }); 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('tpad=stop_mode=clone:stop_duration=2.5,trim=duration=2.5'); expect(filter).toContain('[vin0_1]trim=start=2.5:duration=7.5'); expect(filter).toContain('tpad=stop_mode=clone:stop_duration=7.5,trim=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:dropout_transition=2,asetpts=N/SR/TB[aout]'); }); test('bounds each video segment to its timeline duration before composition', () => { const args = buildFfmpegCompositionArgs({ videoInputs: [ file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:24.000Z') ], audioInputs: [ file('host-audio.webm', 'audio', 'host', 'host', '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:24.000Z') ], outputPath: 'recordings/room-1/output.webm', format: 'webm' }); const filter = args[args.indexOf('-filter_complex') + 1]; expect(filter).toContain('trim=start=0:duration=24,setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=24,trim=duration=24,setpts=PTS-STARTPTS'); expect(filter).toContain('[1:a]aresample=async=1:first_pts=0,asetpts=N/SR/TB[aout]'); }); 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_1080'); 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_1080|1280_1080'); }); });