openvibers/openvibe.live Commits

A WebRTC recording asks the broadcaster for a keyframe every 4 s, so the file stays seconds behind live (#378)

OP OpenVibers committed 18 days ago · db95d331f0 · 4 files, +114 −2

Stream 2795 went up to 100 s between keyframes. The recording grows a fragment per keyframe, so a live clip of the
last 30 s was cut from footage 80 s old (clip 505 collapsed to 1 s and failed) and the live rewind moved in
minute-long steps. VOD_KEYFRAME_SEC (default 4, 0 off).

Changes

ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex 72a46735..f8d1e8c1 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -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
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
1. **Start**: Recording begins when the stream goes live
ddiff --git a/server/streaming/recorder.js b/server/streaming/recorder.js
iindex 5cdcd8c7..7364ec0e 100644
---- a/server/streaming/recorder.js
++++ b/server/streaming/recorder.js
@@@ -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) {
ddiff --git a/server/streaming/webrtc-sfu.js b/server/streaming/webrtc-sfu.js
iindex 422d4b52..fd96866d 100644
---- a/server/streaming/webrtc-sfu.js
++++ b/server/streaming/webrtc-sfu.js
@@@ -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 {
ddiff --git a/test/recording-keyframes.test.js b/test/recording-keyframes.test.js
nnew file mode 100644
iindex 00000000..69718341
---- /dev/null
++++ b/test/recording-keyframes.test.js
@@@ -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); });