openvibers/openvibe.live Commits

The studio shows what the server really receives, and says "No picture" when it gets none (#473)

OP OpenVibers committed 13 days ago · 2344017678 · 5 files, +140 −1

Plan L23 R2. The studio's health pill was worked out from the browser's own sender stats, so it could say "Good"
while no picture reached the server: stream 2827 (moosely, 2026-10-30) had her camera in the preview and nothing in
the SFU.

- Every 5 s while a broadcaster is connected, the server sends it ingest-health: video and audio, each present,
  kbps (from two readings of the SFU producer's byte count) and score (mediasoup's 0-10). It starts with the
  broadcaster, and stops when the broadcaster leaves or the stream ends.
- Two reports in a row with the camera on and no video arriving turn the health pill and card "No picture" (red),
  with one toast saying what to do; the picture arriving clears it. A camera the streamer turned off is never
  flagged, and the first report alone never is (a camera may still be starting).
- The health line's tooltip says how much video the server receives.
- test/ingest-health.test.js runs the studio's side and checks the server's wiring.

Changes

ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex c2eafe15..82767aa4 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -525,6 +525,7 @@
const HEALTH = {
good: ['is-ok', 'Good'], fair: ['is-warn', 'Weak'], poor: ['is-bad', 'Poor'], offline: ['is-bad', 'Offline'],
connecting: ['is-warn', 'Connecting'], measuring: ['is-warn', 'Checking'],
++ 'no picture': ['is-bad', 'No picture'], // the server gets no video while the camera is on (ingest-health)
};
/** Brings the overlay up to date with the page (once a second, and after every action). */
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 40e6f689..f65d54e8 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -2389,10 +2389,14 @@ function startGlobalDisplayTimers() {
} else if (connState === 'checking' || connState === 'new') {
health = 'Connecting'; color = '#f59e0b';
}
++ // The server's word wins: a camera that is on while no picture reaches the server (ingest-health).
++ if (ss.ingestNoPicture) { health = 'No picture'; color = '#ef4444'; }
healthEl.textContent = health;
healthEl.style.color = color;
++ const ing = ss.ingestHealth && ss.ingestHealth.video;
++ healthEl.title = ing && ing.kbps != null ? `The server receives ${ing.kbps >= 1000 ? (ing.kbps / 1000).toFixed(1) + ' Mbps' : ing.kbps + ' kbps'} of video` : '';
const card = document.getElementById('bc-stats-overlay');
-- if (card) card.dataset.health = ({ Good: 'good', Fair: 'fair', Poor: 'poor', Offline: 'offline' })[health] || 'checking';
++ if (card) card.dataset.health = ({ Good: 'good', Fair: 'fair', Poor: 'poor', Offline: 'offline', 'No picture': 'poor' })[health] || 'checking';
}
_checkLowBitrate(ss, hasStats ? bitrateKbps : 0, connState);
} finally {
@@@ -4460,10 +4464,33 @@ async function _rebuildScreenShareAudio(audioId, streamId) {
toast(newMicStream ? 'Microphone updated (screen share mode)' : 'Microphone off (screen share mode)', 'success');
}
++/**
++ * What the server really receives (ingest-health, every 5 s; plan L23 R2). The sender's own stats can look fine while
++ * no picture reaches the server (stream 2827: the camera was never produced), so two reports in a row with the camera
++ * on and no video arriving mark the stream "No picture" (the health pill and card) and say once what to do.
++ */
++function _applyIngestHealth(streamId, ss, msg) {
++ ss.ingestHealth = msg;
++ const vt = ss.localStream && ss.localStream.getVideoTracks()[0];
++ const camOn = !!(vt && vt.readyState === 'live' && vt.enabled);
++ const video = msg.video || {};
++ const noPicture = camOn && (!video.present || video.kbps === 0);
++ ss._ingestBad = noPicture ? (ss._ingestBad || 0) + 1 : 0;
++ ss.ingestNoPicture = ss._ingestBad >= 2;
++ if (ss.ingestNoPicture && !ss._ingestWarned && broadcastState.activeStreamId === streamId) {
++ ss._ingestWarned = true;
++ toast('Viewers aren\'t getting your camera. Switch the camera off and on, or close any other app that uses it.', 'warning');
++ }
++ if (!ss.ingestNoPicture) ss._ingestWarned = false;
++}
++
async function handleSignalingMessage(streamId, msg) {
const ss = getStreamState(streamId);
if (!ss) return;
switch (msg.type) {
++ case 'ingest-health':
++ _applyIngestHealth(streamId, ss, msg);
++ break;
case 'viewer-count':
_perStreamViewerCounts.set(streamId, msg.count || 0);
_updateTabViewerCount(streamId, msg.count || 0);
ddiff --git a/server/streaming/broadcast-server.js b/server/streaming/broadcast-server.js
iindex 3211c285..541ea942 100644
---- a/server/streaming/broadcast-server.js
++++ b/server/streaming/broadcast-server.js
@@@ -304,6 +304,7 @@ class BroadcastServer extends EventEmitter {
}
room.broadcaster = ws;
console.log(`[Broadcast] Broadcaster connected: stream ${streamId} (${user.username})`);
++ this._startIngestHealth(streamId, room);
require('./stream-history').broadcasterBack(streamId).catch(() => {});
this.emit('broadcaster-connected', { streamId, userId: user.id });
@@@ -522,6 +523,41 @@ class BroadcastServer extends EventEmitter {
}
}
++ /**
++ * Every 5 s while a broadcaster is connected: what the SFU really receives (plan L23 R2), sent to that broadcaster
++ * as ingest-health { video: { present, kbps, score }, audio: { present, kbps } }. The studio shows "No picture"
++ * when its camera is on and the server gets none, instead of a sender-side "Good".
++ */
++ _startIngestHealth(streamId, room) {
++ this._stopIngestHealth(room);
++ const roomId = `stream-${streamId}`;
++ let last = null;
++ room._ingestTimer = setInterval(async () => {
++ const ws = room.broadcaster;
++ if (!ws || ws.readyState !== WebSocket.OPEN) return;
++ let h;
++ try { h = await webrtcSFU.ingestHealth(roomId); } catch { return; }
++ const at = Date.now();
++ const kbps = (kind) => {
++ if (!last || !h[kind].present || !last[kind].present) return null;
++ const bytes = h[kind].bytes - last[kind].bytes;
++ return bytes >= 0 ? Math.round((bytes * 8) / Math.max(1, at - last.at)) : null; // a new producer starts from 0
++ };
++ const report = {
++ type: 'ingest-health',
++ video: { present: h.video.present, kbps: kbps('video'), score: h.video.score },
++ audio: { present: h.audio.present, kbps: kbps('audio') },
++ };
++ last = { at, video: h.video, audio: h.audio };
++ this.safeSend(ws, report);
++ }, 5000);
++ if (room._ingestTimer.unref) room._ingestTimer.unref();
++ }
++
++ _stopIngestHealth(room) {
++ if (room && room._ingestTimer) { clearInterval(room._ingestTimer); room._ingestTimer = null; }
++ }
++
handleDisconnect(ws) {
const client = this.clients.get(ws);
if (!client) return;
@@@ -529,6 +565,7 @@ class BroadcastServer extends EventEmitter {
const room = this.rooms.get(client.streamId);
if (room) {
if (client.role === 'broadcaster') {
++ if (room.broadcaster === ws) this._stopIngestHealth(room);
room.broadcaster = null;
room._sfuProduceRequestedAt = 0;
console.log(`[Broadcast] Broadcaster disconnected: stream ${client.streamId}`);
@@@ -1121,6 +1158,7 @@ class BroadcastServer extends EventEmitter {
try { room.broadcaster.close(4020, 'Stream ended'); } catch {}
room.broadcaster = null;
}
++ this._stopIngestHealth(room);
// Notify all viewers
for (const [, viewerWs] of room.viewers) {
ddiff --git a/server/streaming/webrtc-sfu.js b/server/streaming/webrtc-sfu.js
iindex fd96866d..67b0c040 100644
---- a/server/streaming/webrtc-sfu.js
++++ b/server/streaming/webrtc-sfu.js
@@@ -640,6 +640,27 @@ class WebRTCSFU extends EventEmitter {
return total;
}
++ /**
++ * What the SFU really receives from a room's broadcaster, per kind (plan L23 R2): whether a producer exists, the
++ * bytes it has received so far (mediasoup Producer.getStats byteCount, summed over its streams; the caller turns
++ * two readings into a bitrate) and its score (0-10, the lowest of its streams). The studio trusts this over the
++ * browser's own sender stats: a sender can look healthy while nothing reaches the server (stream 2827, 2026-10-30).
++ */
++ async ingestHealth(roomId) {
++ const out = { video: { present: false, bytes: 0, score: null }, audio: { present: false, bytes: 0, score: null } };
++ const room = this.rooms.get(roomId);
++ if (!room) return out;
++ for (const { producer } of room.producers.values()) {
++ if (!producer || producer.closed || !out[producer.kind]) continue;
++ const k = out[producer.kind];
++ k.present = true;
++ try { for (const st of await producer.getStats()) k.bytes += Number(st.byteCount) || 0; } catch { /* closed meanwhile */ }
++ const scores = Array.isArray(producer.score) ? producer.score.map((x) => Number(x && x.score)).filter(Number.isFinite) : [];
++ if (scores.length) k.score = Math.min(k.score == null ? 10 : k.score, ...scores);
++ }
++ return out;
++ }
++
/**
* Get viewer count for a room
*/
ddiff --git a/test/ingest-health.test.js b/test/ingest-health.test.js
nnew file mode 100644
iindex 00000000..6d99c4fc
---- /dev/null
++++ b/test/ingest-health.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++// The streamer sees what the server really receives (plan L23 R2). Stream 2827 (2026-10-30) looked healthy in the
++// studio while no picture reached the server. Every 5 s the server sends the broadcaster ingest-health (video and
++// audio: present, kbps, score); two reports in a row with the camera on and no video arriving turn the health pill
++// "No picture" and say once what to do.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++
++// ── the studio's side, run for real ──
++const bc = read('public/js/broadcast.js');
++const start = bc.indexOf('function _applyIngestHealth(');
++let i = bc.indexOf('{', bc.indexOf(')', start)), depth = 0;
++for (; i < bc.length; i++) { if (bc[i] === '{') depth++; else if (bc[i] === '}' && --depth === 0) break; }
++const toasts = [];
++const ctx = vm.createContext({ toast: (m, k) => toasts.push([m, k]), broadcastState: { activeStreamId: 7 } });
++vm.runInContext(bc.slice(start, i + 1), ctx);
++const apply = (ss, msg) => vm.runInContext('_applyIngestHealth', ctx)(7, ss, msg);
++
++const cam = (on = true) => ({ getVideoTracks: () => [{ readyState: 'live', enabled: on }] });
++const ss = { localStream: cam() };
++apply(ss, { video: { present: false, kbps: null }, audio: { present: true, kbps: 40 } });
++assert.strictEqual(ss.ingestNoPicture, false, 'one report is not enough: a camera may still be starting');
++apply(ss, { video: { present: false, kbps: null }, audio: { present: true, kbps: 40 } });
++assert.strictEqual(ss.ingestNoPicture, true, 'two in a row: no picture');
++assert.strictEqual(toasts.length, 1, 'said once');
++assert.match(toasts[0][0], /Viewers aren't getting your camera/);
++apply(ss, { video: { present: true, kbps: 0 } });
++assert.strictEqual(toasts.length, 1, 'not again while it lasts');
++apply(ss, { video: { present: true, kbps: 2400, score: 10 } });
++assert.strictEqual(ss.ingestNoPicture, false, 'the picture arrives: all clear');
++
++const off = { localStream: cam(false) };
++apply(off, { video: { present: false } }); apply(off, { video: { present: false } });
++assert.strictEqual(off.ingestNoPicture, false, 'a camera the streamer turned off is not a problem');
++
++// The health pill takes the server's word.
++assert.match(bc, /if \(ss\.ingestNoPicture\) \{ health = 'No picture'; color = '#ef4444'; \}/);
++assert.match(read('public/js/broadcast-studio.js'), /'no picture': \['is-bad', 'No picture'\]/);
++
++// ── the server's side ──
++const server = read('server/streaming/broadcast-server.js');
++assert.match(server, /room\.broadcaster = ws;[\s\S]{0,200}this\._startIngestHealth\(streamId, room\);/, 'it starts with the broadcaster');
++assert.match(server, /if \(room\.broadcaster === ws\) this\._stopIngestHealth\(room\);/, 'it stops when the broadcaster leaves');
++assert.match(server, /this\._stopIngestHealth\(room\);\s*\n\s*\/\/ Notify all viewers\s*\n\s*for \(const \[, viewerWs\] of room\.viewers\) \{\s*\n\s*this\.safeSend\(viewerWs, \{ type: 'stream-ended' \}\);/, 'and when the stream ends');
++assert.match(server, /room\._ingestTimer = setInterval\(async \(\) => \{[\s\S]{0,1600}\}, 5000\);/, 'every 5 s');
++assert.match(server, /type: 'ingest-health',\s*\n\s*video: \{ present: h\.video\.present, kbps: kbps\('video'\), score: h\.video\.score \},/);
++assert.match(read('server/streaming/webrtc-sfu.js'), /async ingestHealth\(roomId\) \{[\s\S]{0,900}producer\.getStats\(\)/);
++
++console.log('ingest health: the studio shows what the server receives, and says "No picture" when it gets none');