From e09a1793b91797fe1eddca0dac1304f0bc74af27 Mon Sep 17 00:00:00 2001
From: "russell@unturf.com"
Date: Wed, 3 Jun 2026 15:51:39 -0400
Subject: [PATCH] zebra-report: surface boot errors + watchFirstFrame keyframe
diagnostic
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mirror of zebra-report 3ca9042 + proxy.unturf.com bc04362.
- moderation: boot failures now log to the moderator's page instead
of looking like the button did nothing (stale uuid case).
- diagnostic: watchFirstFrame fires 'still black after 2500ms — no
keyframe?' if a fresh SFU video tile never decodes a first frame.
- proxy-side companion: longer keyframe-burst on subscriber attach
so a dropped PLI no longer leaves the tile permanently black.
---
zebra-report/host-your-own.html | 6 ++---
zebra-report/how-it-works.html | 6 ++---
zebra-report/index.html | 6 ++---
zebra-report/zebra-audio.html | 6 ++---
zebra-report/zebra-spaces.html | 42 ++++++++++++++++++++++++++++++---
5 files changed, 51 insertions(+), 15 deletions(-)
diff --git a/zebra-report/host-your-own.html b/zebra-report/host-your-own.html
index 15af14a..9385b5c 100644
--- a/zebra-report/host-your-own.html
+++ b/zebra-report/host-your-own.html
@@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1
diff --git a/zebra-report/how-it-works.html b/zebra-report/how-it-works.html
index b3ba742..83f40f4 100644
--- a/zebra-report/how-it-works.html
+++ b/zebra-report/how-it-works.html
@@ -341,9 +341,9 @@ try {
diff --git a/zebra-report/index.html b/zebra-report/index.html
index 5a3438b..d0af6ea 100644
--- a/zebra-report/index.html
+++ b/zebra-report/index.html
@@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
})();
diff --git a/zebra-report/zebra-audio.html b/zebra-report/zebra-audio.html
index f744be0..64fe765 100644
--- a/zebra-report/zebra-audio.html
+++ b/zebra-report/zebra-audio.html
@@ -747,9 +747,9 @@ else wirePuppet();
diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index d32fe3f..5984ad4 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -1702,6 +1702,38 @@ function watchVideoTrackForRemoval(track, removeFn, windowMs){
track.addEventListener('unmute', onUnmute);
}
+/* watchFirstFrame — log a warning if a freshly-attached SFU video track
+ * never unmutes within the deadline. Fresh remote tracks start `muted`
+ * until the first RTP packet arrives; if the decoder never gets a
+ * keyframe (PLI dropped in UDP, publisher encoder stalled, codec
+ * mismatch), the tile renders permanently black with no clear signal in
+ * the log. This emits one line so we can tell next session which path
+ * actually broke. No state change — purely diagnostic. */
+function watchFirstFrame(track, kind, pubHex){
+ if (!track) return;
+ if (!track.muted){
+ /* already flowing — nothing to wait for */
+ return;
+ }
+ const deadline = 2500;
+ const start = Date.now();
+ let done = false;
+ const settle = (note) => {
+ if (done) return;
+ done = true;
+ track.removeEventListener('unmute', onUnmute);
+ if (note) logLine('', note);
+ };
+ const onUnmute = () => settle('first frame: kind=' + kind + ' pub=' + pubHex + ' in ' + (Date.now()-start) + 'ms');
+ track.addEventListener('unmute', onUnmute);
+ setTimeout(() => {
+ if (done) return;
+ if (track.readyState !== 'live'){ settle(null); return; }
+ if (!track.muted){ settle('first frame: kind=' + kind + ' pub=' + pubHex + ' (unmute event missed)'); return; }
+ settle('err: ' + kind + ' from ' + pubHex + ' still black after ' + deadline + 'ms — no keyframe?');
+ }, deadline);
+}
+
/* Spotlight model — every tile keeps its thumbnail permanently in the
* cameras column (cameras above, screens-thumbs below). When a tile is
* spotlit, a SECOND larger tile renders in the middle slot pointing at
@@ -2505,6 +2537,7 @@ function handleRemoteSfuTrack(ev){
screenStreams.set(pubHex, s);
renderScreenTile(pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
+ watchFirstFrame(ev.track, kind, pubHex);
return;
}
if (kind === 'camera'){
@@ -2512,6 +2545,7 @@ function handleRemoteSfuTrack(ev){
cameraStreams.set(pubHex, s);
renderCameraTile(pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_MS);
+ watchFirstFrame(ev.track, kind, pubHex);
return;
}
if (kind === 'game'){
@@ -2522,6 +2556,7 @@ function handleRemoteSfuTrack(ev){
gameStreams.set(pubHex, s);
renderVideoTile('gameshare', pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
+ watchFirstFrame(ev.track, kind, pubHex);
return;
}
if (kind !== 'mic'){
@@ -3728,7 +3763,8 @@ function renderRoom(){
/* boot allowed against anyone except host; cohosts also can't boot cohosts (host only) */
if (m.role !== 'host' && !(m.role === 'cohost' && myRole !== 'host')){
const b = document.createElement('button'); b.className='small';
- b.textContent = 'boot'; b.onclick = () => modBoot(m.uuid);
+ b.textContent = 'boot';
+ b.onclick = () => modBoot(m.uuid).catch(e => logLine('err','boot: '+e.message));
acts.appendChild(b);
}
}
@@ -4171,8 +4207,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');