openvibers/openvibe.live Commits
A WebRTC recording asks the broadcaster for a keyframe every 4 s, so the file stays seconds behind live (#378)
Changes
@@@ -38,6 +38,12 @@ VOD 5986 (2026-10-25) was recorded silent because the recorder asked for the audarrived, just before the microphone did. A stream with no microphone records video only after the wait. A microphonethat comes later, or a new one after the recorded one stopped, rolls the recording to a new part that has the sound.++A browser encoder sends a keyframe only when something asks for one, and the recording grows a keyframe at a time: until++the next keyframe, the newest footage is not in the file. Stream 2795 (2026-10-26) went up to 100 s between keyframes,++so a live clip of the last 30 s was cut from footage 80 s old and the live rewind moved in minute-long steps. The++recording now asks the broadcaster for a keyframe every 4 s (`VOD_KEYFRAME_SEC`, 0 turns it off), like an OBS++keyframe interval; viewers who lose a packet recover sooner too.++### Recording Lifecycle1. **Start**: Recording begins when the stream goes live@@@ -162,6 +162,8 @@ class StreamRecorder {}static get AUDIO_WAIT_MS() { return Number(process.env.VOD_AUDIO_WAIT_MS) || 5000; }++ /** How often a WebRTC recording asks the broadcaster for a keyframe (webrtc-sfu.js createPlainConsumer); 0 turns it off. */++ static get KEYFRAME_SEC() { const v = process.env.VOD_KEYFRAME_SEC; const n = Number(v); return v != null && v !== '' && Number.isFinite(n) && n >= 0 ? n : 4; }static get DISK_RETRY_DELAY_MS() { return 5 * 60 * 1000; }static get DISK_RETRY_MAX() { return 12; } // ≈ 1 h of a still-live stream@@@ -290,7 +292,8 @@ class StreamRecorder {// Media listens on 127.0.0.1:<port> (RTCP on port+1 — Media allocates its// ingest pool in rtp/rtcp pairs, mirroring the inherited recorder).const videoConsumer = await webrtcSFU.createPlainConsumer(-- roomId, videoProducer.id, '127.0.0.1', videoPort, videoPort + 1++ roomId, videoProducer.id, '127.0.0.1', videoPort, videoPort + 1,++ { keyframeEverySec: StreamRecorder.KEYFRAME_SEC });webrtcState.videoTransportId = videoConsumer.transportId;if (video.payloadType !== videoConsumer.payloadType) {@@@ -291,9 +291,11 @@ class WebRTCSFU extends EventEmitter {* @param {string} remoteIp - Where to send RTP (FFmpeg listen address)* @param {number} remoteRtpPort - FFmpeg RTP listen port* @param {number} remoteRtcpPort - FFmpeg RTCP listen port++ * @param {{ keyframeEverySec?: number }} [opts] - keyframeEverySec: keep asking the source for a keyframe this often++ * (a recording: see RECORDING_KEYFRAME_SEC in recorder.js)* @returns {{ transportId, consumerId, kind, rtpParameters, payloadType, ssrc, clockRate }}*/-- async createPlainConsumer(roomId, producerId, remoteIp, remoteRtpPort, remoteRtcpPort) {++ async createPlainConsumer(roomId, producerId, remoteIp, remoteRtpPort, remoteRtcpPort, opts = {}) {const room = this.rooms.get(roomId);if (!room) throw new Error(`Room ${roomId} not found`);@@@ -380,6 +382,23 @@ class WebRTCSFU extends EventEmitter {scheduleKeyframe(500, 2);scheduleKeyframe(1500, 3);scheduleKeyframe(3000, 4);++ // A browser encoder sends a keyframe only when asked (a viewer joining, a loss), so a recording went 100 s++ // without one (stream 2795, 2026-10-26). The recorder writes a fragment per keyframe: until the next one,++ // the newest footage is not in the file, so a clip of the last 30 s was cut 80 s short of the moment, and++ // the live rewind moved in steps as long as the gaps. Asking every few seconds keeps the file seconds++ // behind live, like an OBS keyframe interval. Cleared with the other timers (clearTimeout clears an++ // interval too).++ const every = Number(opts.keyframeEverySec) || 0;++ if (every > 0) {++ const steady = setInterval(() => {++ if (consumer.closed) return;++ consumer.requestKeyFrame().catch(() => {});++ }, every * 1000);++ if (steady.unref) steady.unref();++ const timers = room.plainRecordingTimers.get(consumer.id) || [];++ timers.push(steady);++ room.plainRecordingTimers.set(consumer.id, timers);++ }}return {nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++// A WebRTC recording keeps asking the broadcaster for keyframes (server/streaming/webrtc-sfu.js createPlainConsumer,++// keyframeEverySec; the recorder passes VOD_KEYFRAME_SEC). Stream 2795 (2026-10-26) went 100 s between keyframes, so++// the growing recording lagged live by as much: a live clip of the last 30 s was cut 80 s short of the moment.++// - With keyframeEverySec the consumer is asked again and again, and closing it stops the asking.++// - Without it (AI audio, thumbnails, restreams) only the start-up burst is sent.++// - The recorder's interval: 4 s by default, VOD_KEYFRAME_SEC overrides, 0 turns it off, an empty value is the default.++++const assert = require('assert');++const sfu = require('../server/streaming/webrtc-sfu');++const StreamRecorder = require('../server/streaming/recorder').constructor;++++const wait = (ms) => new Promise((r) => setTimeout(r, ms));++++function room() {++ let n = 0;++ const consumers = [];++ const r = {++ transports: new Map(), consumers: new Map(), plainRecordingTimers: new Map(), plainRecordingTransportConsumers: new Map(),++ router: {++ rtpCapabilities: { codecs: [] },++ createPlainTransport: async () => {++ const t = { id: `t${++n}`, closed: false, handlers: {}, connect: async () => {}, close() { this.closed = true; (this.handlers.close || []).forEach((f) => f()); } };++ t.consume = async () => {++ const c = { id: `c${n}`, kind: 'video', closed: false, keyframes: 0, handlers: {},++ rtpParameters: { codecs: [{ payloadType: 102, clockRate: 90000, mimeType: 'video/H264' }], encodings: [{ ssrc: 1 }] },++ on(ev, f) { (this.handlers[ev] = this.handlers[ev] || []).push(f); },++ async requestKeyFrame() { this.keyframes++; } };++ consumers.push(c);++ return c;++ };++ return t;++ },++ },++ };++ return { r, consumers };++}++++(async () => {++ // Asked steadily while it lives; nothing more after it closes.++ {++ const { r, consumers } = room();++ sfu.rooms.set('stream-90', r);++ const info = await sfu.createPlainConsumer('stream-90', 'video-1', '127.0.0.1', 12000, 12001, { keyframeEverySec: 0.05 });++ const c = consumers[0];++ await wait(3200); // the start-up burst (0, 0.5, 1.5, 3 s) is over++ const afterBurst = c.keyframes;++ assert.ok(afterBurst >= 4 + 20, `the recording consumer is asked steadily (got ${afterBurst})`);++ sfu.closePlainConsumer('stream-90', info.transportId);++ const atClose = c.keyframes;++ await wait(200);++ assert.strictEqual(c.keyframes, atClose, 'closing the consumer stops the requests');++ assert.ok(!r.plainRecordingTimers.size, 'no timers are left behind');++ sfu.rooms.delete('stream-90');++ }++++ // Other plain consumers keep the start-up burst only.++ {++ const { r, consumers } = room();++ sfu.rooms.set('stream-91', r);++ const info = await sfu.createPlainConsumer('stream-91', 'video-1', '127.0.0.1', 12010, 12011);++ await wait(3200);++ assert.strictEqual(consumers[0].keyframes, 4, 'a consumer without the option gets the four start-up requests');++ sfu.closePlainConsumer('stream-91', info.transportId);++ sfu.rooms.delete('stream-91');++ }++++ // The recorder's interval.++ const env = process.env.VOD_KEYFRAME_SEC;++ delete process.env.VOD_KEYFRAME_SEC;++ assert.strictEqual(StreamRecorder.KEYFRAME_SEC, 4);++ process.env.VOD_KEYFRAME_SEC = '';++ assert.strictEqual(StreamRecorder.KEYFRAME_SEC, 4, 'an empty value is the default');++ process.env.VOD_KEYFRAME_SEC = '2.5';++ assert.strictEqual(StreamRecorder.KEYFRAME_SEC, 2.5);++ process.env.VOD_KEYFRAME_SEC = '0';++ assert.strictEqual(StreamRecorder.KEYFRAME_SEC, 0, '0 turns it off');++ process.env.VOD_KEYFRAME_SEC = 'often';++ assert.strictEqual(StreamRecorder.KEYFRAME_SEC, 4, 'nonsense falls back to the default');++ if (env == null) delete process.env.VOD_KEYFRAME_SEC; else process.env.VOD_KEYFRAME_SEC = env;++++ console.log('recording-keyframes: ok');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -38,6 +38,12 @@ VOD 5986 (2026-10-25) was recorded silent because the recorder asked for the aud
@@ -38,6 +38,12 @@ VOD 5986 (2026-10-25) was recorded silent because the recorder asked for the aud
arrived, just before the microphone did. A stream with no microphone records video only after the wait. A microphone
arrived, just before the microphone did. A stream with no microphone records video only after the wait. A microphone
that comes later, or a new one after the recorded one stopped, rolls the recording to a new part that has the sound.
that comes later, or a new one after the recorded one stopped, rolls the recording to a new part that has the sound.
+A browser encoder sends a keyframe only when something asks for one, and the recording grows a keyframe at a time: until
+the next keyframe, the newest footage is not in the file. Stream 2795 (2026-10-26) went up to 100 s between keyframes,
+so a live clip of the last 30 s was cut from footage 80 s old and the live rewind moved in minute-long steps. The
+recording now asks the broadcaster for a keyframe every 4 s (`VOD_KEYFRAME_SEC`, 0 turns it off), like an OBS
+keyframe interval; viewers who lose a packet recover sooner too.
+
### Recording Lifecycle
### Recording Lifecycle
1. **Start**: Recording begins when the stream goes live
1. **Start**: Recording begins when the stream goes live
@@ -162,6 +162,8 @@ class StreamRecorder {
@@ -162,6 +162,8 @@ class StreamRecorder {
}
}
static get AUDIO_WAIT_MS() { return Number(process.env.VOD_AUDIO_WAIT_MS) || 5000; }
static get AUDIO_WAIT_MS() { return Number(process.env.VOD_AUDIO_WAIT_MS) || 5000; }
+ /** How often a WebRTC recording asks the broadcaster for a keyframe (webrtc-sfu.js createPlainConsumer); 0 turns it off. */
+ static get KEYFRAME_SEC() { const v = process.env.VOD_KEYFRAME_SEC; const n = Number(v); return v != null && v !== '' && Number.isFinite(n) && n >= 0 ? n : 4; }
static get DISK_RETRY_DELAY_MS() { return 5 * 60 * 1000; }
static get DISK_RETRY_DELAY_MS() { return 5 * 60 * 1000; }
static get DISK_RETRY_MAX() { return 12; } // ≈ 1 h of a still-live stream
static get DISK_RETRY_MAX() { return 12; } // ≈ 1 h of a still-live stream
@@ -290,7 +292,8 @@ class StreamRecorder {
@@ -290,7 +292,8 @@ class StreamRecorder {
// Media listens on 127.0.0.1:<port> (RTCP on port+1 — Media allocates its
// Media listens on 127.0.0.1:<port> (RTCP on port+1 — Media allocates its
// ingest pool in rtp/rtcp pairs, mirroring the inherited recorder).
// ingest pool in rtp/rtcp pairs, mirroring the inherited recorder).
const videoConsumer = await webrtcSFU.createPlainConsumer(
const videoConsumer = await webrtcSFU.createPlainConsumer(
- roomId, videoProducer.id, '127.0.0.1', videoPort, videoPort + 1
+ roomId, videoProducer.id, '127.0.0.1', videoPort, videoPort + 1,
+ { keyframeEverySec: StreamRecorder.KEYFRAME_SEC }
);
);
webrtcState.videoTransportId = videoConsumer.transportId;
webrtcState.videoTransportId = videoConsumer.transportId;
if (video.payloadType !== videoConsumer.payloadType) {
if (video.payloadType !== videoConsumer.payloadType) {
@@ -291,9 +291,11 @@ class WebRTCSFU extends EventEmitter {
@@ -291,9 +291,11 @@ class WebRTCSFU extends EventEmitter {
* @param {string} remoteIp - Where to send RTP (FFmpeg listen address)
* @param {string} remoteIp - Where to send RTP (FFmpeg listen address)
* @param {number} remoteRtpPort - FFmpeg RTP listen port
* @param {number} remoteRtpPort - FFmpeg RTP listen port
* @param {number} remoteRtcpPort - FFmpeg RTCP listen port
* @param {number} remoteRtcpPort - FFmpeg RTCP listen port
+ * @param {{ keyframeEverySec?: number }} [opts] - keyframeEverySec: keep asking the source for a keyframe this often
+ * (a recording: see RECORDING_KEYFRAME_SEC in recorder.js)
* @returns {{ transportId, consumerId, kind, rtpParameters, payloadType, ssrc, clockRate }}
* @returns {{ transportId, consumerId, kind, rtpParameters, payloadType, ssrc, clockRate }}
*/
*/
- async createPlainConsumer(roomId, producerId, remoteIp, remoteRtpPort, remoteRtcpPort) {
+ async createPlainConsumer(roomId, producerId, remoteIp, remoteRtpPort, remoteRtcpPort, opts = {}) {
const room = this.rooms.get(roomId);
const room = this.rooms.get(roomId);
if (!room) throw new Error(`Room ${roomId} not found`);
if (!room) throw new Error(`Room ${roomId} not found`);
@@ -380,6 +382,23 @@ class WebRTCSFU extends EventEmitter {
@@ -380,6 +382,23 @@ class WebRTCSFU extends EventEmitter {
scheduleKeyframe(500, 2);
scheduleKeyframe(500, 2);
scheduleKeyframe(1500, 3);
scheduleKeyframe(1500, 3);
scheduleKeyframe(3000, 4);
scheduleKeyframe(3000, 4);
+ // A browser encoder sends a keyframe only when asked (a viewer joining, a loss), so a recording went 100 s
+ // without one (stream 2795, 2026-10-26). The recorder writes a fragment per keyframe: until the next one,
+ // the newest footage is not in the file, so a clip of the last 30 s was cut 80 s short of the moment, and
+ // the live rewind moved in steps as long as the gaps. Asking every few seconds keeps the file seconds
+ // behind live, like an OBS keyframe interval. Cleared with the other timers (clearTimeout clears an
+ // interval too).
+ const every = Number(opts.keyframeEverySec) || 0;
+ if (every > 0) {
+ const steady = setInterval(() => {
+ if (consumer.closed) return;
+ consumer.requestKeyFrame().catch(() => {});
+ }, every * 1000);
+ if (steady.unref) steady.unref();
+ const timers = room.plainRecordingTimers.get(consumer.id) || [];
+ timers.push(steady);
+ room.plainRecordingTimers.set(consumer.id, timers);
+ }
}
}
return {
return {
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+// A WebRTC recording keeps asking the broadcaster for keyframes (server/streaming/webrtc-sfu.js createPlainConsumer,
+// keyframeEverySec; the recorder passes VOD_KEYFRAME_SEC). Stream 2795 (2026-10-26) went 100 s between keyframes, so
+// the growing recording lagged live by as much: a live clip of the last 30 s was cut 80 s short of the moment.
+// - With keyframeEverySec the consumer is asked again and again, and closing it stops the asking.
+// - Without it (AI audio, thumbnails, restreams) only the start-up burst is sent.
+// - The recorder's interval: 4 s by default, VOD_KEYFRAME_SEC overrides, 0 turns it off, an empty value is the default.
+
+const assert = require('assert');
+const sfu = require('../server/streaming/webrtc-sfu');
+const StreamRecorder = require('../server/streaming/recorder').constructor;
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function room() {
+ let n = 0;
+ const consumers = [];
+ const r = {
+ transports: new Map(), consumers: new Map(), plainRecordingTimers: new Map(), plainRecordingTransportConsumers: new Map(),
+ router: {
+ rtpCapabilities: { codecs: [] },
+ createPlainTransport: async () => {
+ const t = { id: `t${++n}`, closed: false, handlers: {}, connect: async () => {}, close() { this.closed = true; (this.handlers.close || []).forEach((f) => f()); } };
+ t.consume = async () => {
+ const c = { id: `c${n}`, kind: 'video', closed: false, keyframes: 0, handlers: {},
+ rtpParameters: { codecs: [{ payloadType: 102, clockRate: 90000, mimeType: 'video/H264' }], encodings: [{ ssrc: 1 }] },
+ on(ev, f) { (this.handlers[ev] = this.handlers[ev] || []).push(f); },
+ async requestKeyFrame() { this.keyframes++; } };
+ consumers.push(c);
+ return c;
+ };
+ return t;
+ },
+ },
+ };
+ return { r, consumers };
+}
+
+(async () => {
+ // Asked steadily while it lives; nothing more after it closes.
+ {
+ const { r, consumers } = room();
+ sfu.rooms.set('stream-90', r);
+ const info = await sfu.createPlainConsumer('stream-90', 'video-1', '127.0.0.1', 12000, 12001, { keyframeEverySec: 0.05 });
+ const c = consumers[0];
+ await wait(3200); // the start-up burst (0, 0.5, 1.5, 3 s) is over
+ const afterBurst = c.keyframes;
+ assert.ok(afterBurst >= 4 + 20, `the recording consumer is asked steadily (got ${afterBurst})`);
+ sfu.closePlainConsumer('stream-90', info.transportId);
+ const atClose = c.keyframes;
+ await wait(200);
+ assert.strictEqual(c.keyframes, atClose, 'closing the consumer stops the requests');
+ assert.ok(!r.plainRecordingTimers.size, 'no timers are left behind');
+ sfu.rooms.delete('stream-90');
+ }
+
+ // Other plain consumers keep the start-up burst only.
+ {
+ const { r, consumers } = room();
+ sfu.rooms.set('stream-91', r);
+ const info = await sfu.createPlainConsumer('stream-91', 'video-1', '127.0.0.1', 12010, 12011);
+ await wait(3200);
+ assert.strictEqual(consumers[0].keyframes, 4, 'a consumer without the option gets the four start-up requests');
+ sfu.closePlainConsumer('stream-91', info.transportId);
+ sfu.rooms.delete('stream-91');
+ }
+
+ // The recorder's interval.
+ const env = process.env.VOD_KEYFRAME_SEC;
+ delete process.env.VOD_KEYFRAME_SEC;
+ assert.strictEqual(StreamRecorder.KEYFRAME_SEC, 4);
+ process.env.VOD_KEYFRAME_SEC = '';
+ assert.strictEqual(StreamRecorder.KEYFRAME_SEC, 4, 'an empty value is the default');
+ process.env.VOD_KEYFRAME_SEC = '2.5';
+ assert.strictEqual(StreamRecorder.KEYFRAME_SEC, 2.5);
+ process.env.VOD_KEYFRAME_SEC = '0';
+ assert.strictEqual(StreamRecorder.KEYFRAME_SEC, 0, '0 turns it off');
+ process.env.VOD_KEYFRAME_SEC = 'often';
+ assert.strictEqual(StreamRecorder.KEYFRAME_SEC, 4, 'nonsense falls back to the default');
+ if (env == null) delete process.env.VOD_KEYFRAME_SEC; else process.env.VOD_KEYFRAME_SEC = env;
+
+ console.log('recording-keyframes: ok');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });