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)
Changes
@@@ -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);});nnew file mode 100644@@@ -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); });
@@ -11,6 +11,24 @@ const db = require('../db/database');
@@ -11,6 +11,24 @@ const db = require('../db/database');
const recorder = require('./recorder');
const recorder = require('./recorder');
const RTMP_HEARTBEAT_INTERVAL_MS = 30000; // Refresh live stream timestamp while RTMP session is active
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;
let NodeMediaServer;
try {
try {
@@ -197,13 +215,18 @@ class RTMPServer extends EventEmitter {
@@ -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.
// 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 { /* */ }
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 () => {
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);
}, 2000);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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); });