openvibers/openvibe.live Commits
The studio shows what the server really receives, and says "No picture" when it gets none (#473)
Changes
@@@ -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). */@@@ -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);@@@ -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 viewersfor (const [, viewerWs] of room.viewers) {@@@ -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*/nnew file mode 100644@@@ -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');
@@ -525,6 +525,7 @@
@@ -525,6 +525,7 @@
const HEALTH = {
const HEALTH = {
good: ['is-ok', 'Good'], fair: ['is-warn', 'Weak'], poor: ['is-bad', 'Poor'], offline: ['is-bad', 'Offline'],
good: ['is-ok', 'Good'], fair: ['is-warn', 'Weak'], poor: ['is-bad', 'Poor'], offline: ['is-bad', 'Offline'],
connecting: ['is-warn', 'Connecting'], measuring: ['is-warn', 'Checking'],
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). */
/** Brings the overlay up to date with the page (once a second, and after every action). */
@@ -2389,10 +2389,14 @@ function startGlobalDisplayTimers() {
@@ -2389,10 +2389,14 @@ function startGlobalDisplayTimers() {
} else if (connState === 'checking' || connState === 'new') {
} else if (connState === 'checking' || connState === 'new') {
health = 'Connecting'; color = '#f59e0b';
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.textContent = health;
healthEl.style.color = color;
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');
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);
_checkLowBitrate(ss, hasStats ? bitrateKbps : 0, connState);
} finally {
} finally {
@@ -4460,10 +4464,33 @@ async function _rebuildScreenShareAudio(audioId, streamId) {
@@ -4460,10 +4464,33 @@ async function _rebuildScreenShareAudio(audioId, streamId) {
toast(newMicStream ? 'Microphone updated (screen share mode)' : 'Microphone off (screen share mode)', 'success');
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) {
async function handleSignalingMessage(streamId, msg) {
const ss = getStreamState(streamId);
const ss = getStreamState(streamId);
if (!ss) return;
if (!ss) return;
switch (msg.type) {
switch (msg.type) {
+ case 'ingest-health':
+ _applyIngestHealth(streamId, ss, msg);
+ break;
case 'viewer-count':
case 'viewer-count':
_perStreamViewerCounts.set(streamId, msg.count || 0);
_perStreamViewerCounts.set(streamId, msg.count || 0);
_updateTabViewerCount(streamId, msg.count || 0);
_updateTabViewerCount(streamId, msg.count || 0);
@@ -304,6 +304,7 @@ class BroadcastServer extends EventEmitter {
@@ -304,6 +304,7 @@ class BroadcastServer extends EventEmitter {
}
}
room.broadcaster = ws;
room.broadcaster = ws;
console.log(`[Broadcast] Broadcaster connected: stream ${streamId} (${user.username})`);
console.log(`[Broadcast] Broadcaster connected: stream ${streamId} (${user.username})`);
+ this._startIngestHealth(streamId, room);
require('./stream-history').broadcasterBack(streamId).catch(() => {});
require('./stream-history').broadcasterBack(streamId).catch(() => {});
this.emit('broadcaster-connected', { streamId, userId: user.id });
this.emit('broadcaster-connected', { streamId, userId: user.id });
@@ -522,6 +523,41 @@ class BroadcastServer extends EventEmitter {
@@ -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) {
handleDisconnect(ws) {
const client = this.clients.get(ws);
const client = this.clients.get(ws);
if (!client) return;
if (!client) return;
@@ -529,6 +565,7 @@ class BroadcastServer extends EventEmitter {
@@ -529,6 +565,7 @@ class BroadcastServer extends EventEmitter {
const room = this.rooms.get(client.streamId);
const room = this.rooms.get(client.streamId);
if (room) {
if (room) {
if (client.role === 'broadcaster') {
if (client.role === 'broadcaster') {
+ if (room.broadcaster === ws) this._stopIngestHealth(room);
room.broadcaster = null;
room.broadcaster = null;
room._sfuProduceRequestedAt = 0;
room._sfuProduceRequestedAt = 0;
console.log(`[Broadcast] Broadcaster disconnected: stream ${client.streamId}`);
console.log(`[Broadcast] Broadcaster disconnected: stream ${client.streamId}`);
@@ -1121,6 +1158,7 @@ class BroadcastServer extends EventEmitter {
@@ -1121,6 +1158,7 @@ class BroadcastServer extends EventEmitter {
try { room.broadcaster.close(4020, 'Stream ended'); } catch {}
try { room.broadcaster.close(4020, 'Stream ended'); } catch {}
room.broadcaster = null;
room.broadcaster = null;
}
}
+ this._stopIngestHealth(room);
// Notify all viewers
// Notify all viewers
for (const [, viewerWs] of room.viewers) {
for (const [, viewerWs] of room.viewers) {
@@ -640,6 +640,27 @@ class WebRTCSFU extends EventEmitter {
@@ -640,6 +640,27 @@ class WebRTCSFU extends EventEmitter {
return total;
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
* Get viewer count for a room
*/
*/
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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');