openvibers/openvibe.live Commits

RTMP recordings start once the publisher's first video has arrived (up to 20 s): Media's ffmpeg maps only the streams it sees in its first seconds, and an OBS whose encoder starts late sent audio first, so 8 of iZoR_One's 13 recordings were audio only (#243)

OP OpenVibers committed 26 days ago · adcd3b5729 · 2 files, +84 −6

Changes

ddiff --git a/server/streaming/rtmp-server.js b/server/streaming/rtmp-server.js
iindex 2834fd61..813d1a6a 100644
---- a/server/streaming/rtmp-server.js
++++ b/server/streaming/rtmp-server.js
@@@ -11,6 +11,24 @@ const db = require('../db/database');
const recorder = require('./recorder');
const RTMP_HEARTBEAT_INTERVAL_MS = 30000; // Refresh live stream timestamp while RTMP session is active
++const RECORD_WAIT_VIDEO_MS = 20000; // how long the recording waits for the publisher's first video
++
++/**
++ * Wait until the publishing session has sent video (node-media-server sets videoCodec on its first video packet).
++ * Media's ffmpeg maps only the streams it sees in its first seconds, and an OBS whose encoder starts late sends audio
++ * first: 8 of iZoR_One's 13 recordings (2026-10-08..18) were audio only while the stream had a picture. Resolves
++ * 'video', 'timeout' (an audio-only stream still records) or 'gone' (the publisher left).
++ */
++async function waitForVideo(nms, sessionId, { timeoutMs = RECORD_WAIT_VIDEO_MS, stepMs = 250 } = {}) {
++ const until = Date.now() + timeoutMs;
++ for (;;) {
++ const session = nms.getSession(sessionId);
++ if (!session) return 'gone';
++ if (session.videoCodec) return 'video';
++ if (Date.now() >= until) return 'timeout';
++ await new Promise((resolve) => setTimeout(resolve, stepMs));
++ }
++}
let NodeMediaServer;
try {
@@@ -197,13 +215,18 @@ class RTMPServer extends EventEmitter {
// Followers hear it from Network (live.stream.started via Events); the page banner comes from live-events.
try { require('./live-events').announceGoLive(stream || { id: streamId }, resolvedUser); } catch { /* */ }
-- // Start server-side VOD recording via FFmpeg
-- // Small delay to let NMS fully register the RTMP stream before FFmpeg pulls it
++ // Start server-side VOD recording via FFmpeg: 2 s for NMS to register the stream, then once the first
++ // video has arrived (waitForVideo), so Media's ffmpeg sees the picture as well as the sound.
setTimeout(async () => {
-- const mode = await db.resolveStreamRecordingMode(await db.getStreamById(streamId));
-- if (mode !== 'none') {
-- recorder.startRecording(streamId, 'rtmp', { streamKey }, { mode });
-- }
++ try {
++ const seen = await waitForVideo(this.nms, id);
++ if (seen === 'gone') return;
++ if (seen === 'timeout') console.log(`[RTMP] No video from stream ${streamId} after ${RECORD_WAIT_VIDEO_MS / 1000} s: recording what it sends`);
++ const mode = await db.resolveStreamRecordingMode(await db.getStreamById(streamId));
++ if (mode !== 'none') {
++ recorder.startRecording(streamId, 'rtmp', { streamKey }, { mode });
++ }
++ } catch (e) { console.warn(`[RTMP] recording start for stream ${streamId} failed: ${e.message}`); }
}, 2000);
});
ddiff --git a/test/rtmp-record-after-video.test.js b/test/rtmp-record-after-video.test.js
nnew file mode 100644
iindex 00000000..0549af6a
---- /dev/null
++++ b/test/rtmp-record-after-video.test.js
@@@ -0,0 +1,55 @@
++/**
++ * An RTMP recording starts once the publisher has sent video (server/streaming/rtmp-server.js waitForVideo).
++ * Media's ffmpeg maps only the streams it sees in its first seconds; it was asked 2 s after the publish, and an OBS
++ * whose encoder starts late sends audio first, so 8 of iZoR_One's 13 recordings (2026-10-08..18) had no picture while
++ * the live stream did (its live thumbnails were fine).
++ *
++ * node test/rtmp-record-after-video.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'streaming', 'rtmp-server.js'), 'utf8');
++
++/** The source of a top-level function declaration, braces matched (after its parameter list). */
++function extract(name) {
++ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);
++ assert.ok(m, `${name}() must exist`);
++ let i = m.index + m[0].length;
++ for (let parens = 1; parens; i++) { if (src[i] === '(') parens++; else if (src[i] === ')') parens--; }
++ i = src.indexOf('{', i);
++ for (let depth = 0; i < src.length; i++) {
++ if (src[i] === '{') depth++;
++ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
++ }
++ throw new Error(`unbalanced ${name}`);
++}
++
++const ctx = vm.createContext({ setTimeout, Date, Promise, RECORD_WAIT_VIDEO_MS: 20000 });
++vm.runInContext(extract('waitForVideo'), ctx);
++const waitForVideo = (nms, id, o) => vm.runInContext('waitForVideo', ctx)(nms, id, o);
++
++(async () => {
++ // Audio first, video 120 ms later: it waits for the video.
++ const session = { videoCodec: 0 };
++ const nms = { getSession: (id) => (id === 'p1' ? session : undefined) };
++ setTimeout(() => { session.videoCodec = 12; }, 120);
++ const t0 = Date.now();
++ assert.strictEqual(await waitForVideo(nms, 'p1', { timeoutMs: 2000, stepMs: 20 }), 'video');
++ assert.ok(Date.now() - t0 >= 100, 'it waited for the first video packet');
++
++ // An audio-only stream still records, after the wait.
++ assert.strictEqual(await waitForVideo({ getSession: () => ({ videoCodec: 0 }) }, 'p2', { timeoutMs: 80, stepMs: 20 }), 'timeout');
++ // The publisher left: nothing to record.
++ assert.strictEqual(await waitForVideo(nms, 'gone', { timeoutMs: 80, stepMs: 20 }), 'gone');
++
++ // The publish handler asks for the recording only after the wait, and not when the publisher has gone.
++ const start = src.indexOf("const seen = await waitForVideo(this.nms, id);");
++ assert.ok(start > 0, 'the recording start waits for video');
++ assert.ok(src.indexOf("if (seen === 'gone') return;", start) > start);
++ assert.ok(src.indexOf("recorder.startRecording(streamId, 'rtmp'", start) > start, 'and asks for the recording after it');
++ console.log('rtmp record after video: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });