diff --git a/src/class/websockethandler.ts b/src/class/websockethandler.ts index c824c22..2a3f560 100644 --- a/src/class/websockethandler.ts +++ b/src/class/websockethandler.ts @@ -406,6 +406,17 @@ function unwrapRecordingClientPayload(message: any): any { return payload; } +function getSdpMediaSections(sdp: string): string[] { + return sdp + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => /^m=(audio|video)\s/i.test(line)); +} + +function hasRecordableSdpMedia(sdp: string): boolean { + return getSdpMediaSections(sdp).length > 0; +} + function getActiveRecordingSessions(connectionId: string): RecordingSession[] { return listRecordingSessions(connectionId).filter((session) => session.status === 'recording'); } @@ -899,6 +910,23 @@ async function onRecordingOffer(ws: WebSocket, message: any): Promise { safeSendOnMessage(ws, connectionId, { type: 'recording-status', recordingId, connectionId, status: 'invalid-offer' }); return; } + if (!hasRecordableSdpMedia(sdp)) { + const participantId = getParticipantId(ws) || message.participantId || 'unknown'; + log(LogLevel.warn, 'Rejected recording offer without audio/video media sections:', { + recordingId, + connectionId, + participantId, + mediaSections: getSdpMediaSections(sdp) + }); + safeSendOnMessage(ws, connectionId, { + type: 'recording-status', + recordingId, + connectionId, + status: 'no-media-offer', + participantId + }); + return; + } const offer = registerRecordingPeerOffer({ recordingId, diff --git a/src/recording/composer.ts b/src/recording/composer.ts index bcd4815..a5bdc61 100644 --- a/src/recording/composer.ts +++ b/src/recording/composer.ts @@ -55,7 +55,7 @@ type CompositionInputSets = { type VideoTimelineSegment = { startMs: number; - endMs: number; + endMs: number | null; activeInputs: ServerTrackRecordingFile[]; }; @@ -275,8 +275,10 @@ function getVideoTimelineSegments( } const activeInputs = sortActiveVideoInputs(files.filter((file) => { - const inputStartMs = getInputStartMs(file) || timelineOriginMs; - const inputEndMs = getInputEndMs(file) || timelineEndMs; + const fileStartMs = getInputStartMs(file); + const fileEndMs = getInputEndMs(file); + const inputStartMs = fileStartMs === null ? timelineOriginMs : fileStartMs; + const inputEndMs = fileEndMs === null ? timelineEndMs : fileEndMs; return inputStartMs < endMs && inputEndMs > startMs; })); segments.push({ startMs, endMs, activeInputs }); @@ -285,6 +287,17 @@ function getVideoTimelineSegments( return segments; } +function getFallbackVideoTimelineSegment( + files: ServerTrackRecordingFile[], + timelineOriginMs: number | null +): VideoTimelineSegment { + return { + startMs: timelineOriginMs === null ? 0 : timelineOriginMs, + endMs: null, + activeInputs: sortActiveVideoInputs(files) + }; +} + export function buildFfmpegCompositionArgs(input: { videoInputs: ServerTrackRecordingFile[]; audioInputs: ServerTrackRecordingFile[]; @@ -299,7 +312,10 @@ export function buildFfmpegCompositionArgs(input: { const timelineOriginMs = getTimelineOriginMs(videoInputs.concat(input.audioInputs)); const timelineEndMs = getTimelineEndMs(videoInputs.concat(input.audioInputs)); const timelineDurationSeconds = getTimelineDurationSeconds(videoInputs.concat(input.audioInputs), timelineOriginMs); - const videoSegments = getVideoTimelineSegments(videoInputs, timelineOriginMs, timelineEndMs); + const timelineVideoSegments = getVideoTimelineSegments(videoInputs, timelineOriginMs, timelineEndMs); + const videoSegments = timelineVideoSegments.length > 0 + ? timelineVideoSegments + : [getFallbackVideoTimelineSegment(videoInputs, timelineOriginMs)]; const args = ['-y']; const orderedInputs = videoInputs.concat(input.audioInputs); orderedInputs.forEach((file) => { @@ -322,8 +338,11 @@ export function buildFfmpegCompositionArgs(input: { }); videoSegments.forEach((segment, segmentIndex) => { - const segmentDurationSeconds = (segment.endMs - segment.startMs) / 1000; + const segmentDurationSeconds = segment.endMs === null ? null : (segment.endMs - segment.startMs) / 1000; if (segment.activeInputs.length === 0) { + if (segmentDurationSeconds === null) { + return; + } filters.push(`color=color=black:size=${outputWidth}x${outputHeight}:rate=30:duration=${formatSeconds(segmentDurationSeconds)},format=yuv420p[seg${segmentIndex}]`); return; } @@ -333,7 +352,8 @@ export function buildFfmpegCompositionArgs(input: { const inputLabel = videoInputUseCounts[inputIndex] > 1 ? `vin${inputIndex}_${videoInputUsePositions[inputIndex]++}` : `${inputIndex}:v`; - const inputStartMs = getInputStartMs(file) || segment.startMs; + const fileStartMs = getInputStartMs(file); + const inputStartMs = fileStartMs === null ? segment.startMs : fileStartMs; const trimStartSeconds = Math.max(0, (segment.startMs - inputStartMs) / 1000); const width = segment.activeInputs.length === 1 ? outputWidth @@ -341,7 +361,11 @@ export function buildFfmpegCompositionArgs(input: { const height = segment.activeInputs.length === 1 ? outputHeight : activeIndex === 0 ? hostHeight : bottomHeight; - filters.push(`[${inputLabel}]trim=start=${formatSeconds(trimStartSeconds)}:duration=${formatSeconds(segmentDurationSeconds)},setpts=PTS-STARTPTS,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[seg${segmentIndex}v${activeIndex}]`); + const trimOptions = [`start=${formatSeconds(trimStartSeconds)}`]; + if (segmentDurationSeconds !== null) { + trimOptions.push(`duration=${formatSeconds(segmentDurationSeconds)}`); + } + filters.push(`[${inputLabel}]trim=${trimOptions.join(':')},setpts=PTS-STARTPTS,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[seg${segmentIndex}v${activeIndex}]`); }); if (segment.activeInputs.length === 1) { @@ -435,6 +459,16 @@ function toOutput(job: RecordingCompositionJob, target: ServerTrackRecordingTarg }; } +function getActiveCompositionJob(input: StartCompositionInput): RecordingCompositionJob | null { + const recordingId = normalizeOption(input.recordingId, ''); + const meetingId = normalizeOption(input.meetingId, ''); + return Array.from(jobs.values()).find((job) => { + return job.recordingId === recordingId + && job.meetingId === meetingId + && (job.status === 'queued' || job.status === 'running'); + }) || null; +} + async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise { const timestamp = nowIso(); job.status = 'running'; @@ -489,6 +523,11 @@ async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise } export function startRecordingCompositionJob(input: StartCompositionInput): RecordingCompositionJob { + const activeJob = getActiveCompositionJob(input); + if (activeJob) { + return activeJob; + } + const timestamp = nowIso(); const inputSets = getInputSets(input); const job: RecordingCompositionJob = { diff --git a/test/recording-composer.test.ts b/test/recording-composer.test.ts index 3ae73c1..ecbdb7a 100644 --- a/test/recording-composer.test.ts +++ b/test/recording-composer.test.ts @@ -93,6 +93,24 @@ describe('recording composer', () => { 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: [ diff --git a/test/websockethandler.test.ts b/test/websockethandler.test.ts index d9bfe89..59a3518 100644 --- a/test/websockethandler.test.ts +++ b/test/websockethandler.test.ts @@ -292,3 +292,53 @@ describe('websocket signaling test in private mode', () => { await wsHandler.remove(client); }); }); + +describe('recording offer validation', () => { + let server: WS; + let client: WebSocket; + const connectionId = "recording-room"; + + beforeAll(async () => { + wsHandler.reset("private"); + server = new WS("ws://localhost:1234", { jsonProtocol: true }); + client = new WebSocket("ws://localhost:1234"); + await server.connected; + await wsHandler.add(client); + await wsHandler.onConnect(client, connectionId); + await expect(server).toReceiveMessage({ + type: "connect", + connectionId: connectionId, + polite: false, + role: "host", + participantId: anyParticipantId + }); + }); + + afterAll(() => { + WS.clean(); + }); + + test('rejects recording offers without audio or video media sections', async () => { + await wsHandler.onRecordingOffer(client, { + recordingId: 'recording-empty-offer', + connectionId, + sdp: [ + 'v=0', + 'o=- 25268170 0 IN IP4 0.0.0.0', + 's=-', + 't=0 0', + 'a=group:BUNDLE ', + 'a=extmap-allow-mixed', + 'a=msid-semantic:WMS *', + '' + ].join('\r\n') + }); + + await expect(server).toReceiveMessage(expect.objectContaining({ + from: connectionId, + to: "", + type: "on-message", + data: expect.stringContaining('"status":"no-media-offer"') + })); + }); +});