openvibers/openvibe.live Commits

RS relay: stop hiding packet loss from RobotStreamer; theme anti-flash

OP OpenVibers committed 18 Aug 2026 · 65d903fb3e · 3 files, +135 −53

RobotStreamer passthrough
-------------------------
Video-only glitches on the RS restream (audio always fine) traced to the H264
sequence rewrite. Every outgoing packet was renumbered contiguously in ARRIVAL
order, which erases loss holes: RS saw a perfect sequence with missing payload,
so it never NACKed, never PLIed, and fed the corrupt frame to its decoder. The
relay heartbeat shows this directly — lossIN is continuously non-zero
(goosely->relay, localhost UDP) while RS reports lost=0.0%.

Renumber by a running OFFSET instead of a counter: injected SPS/PPS packets
still get their slots, but real gaps survive to RS, which NACKs and gets the
packet back from werift's buffer (retx already works, ~1000 pkts/stream).
Reordered packets also keep their true relative order now. VP8 is unaffected
either way, which is why native RS streams never showed this.

Also: the "in case it's lost too" keyframe retry was dead code — the 300ms
backstop was always swallowed by reqKey's flat 2s anti-flood floor set by the
very request that scheduled it, stretching recovery to a full 2s. Split out
reqKeyNow(minGap) so the backstop passes a shorter floor; reqKey stays a
zero-arg wrapper because PLI subscribers invoke it with an event object.

Themes
------
Add an inline critical bootstrap above the stylesheets that paints the cached
theme synchronously. theme-loader.js is fetched over the network AND was loaded
with defer at the bottom of body, so it only ran after first paint — that gap
is the purple flash. The bootstrap touches localStorage only.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/public/index.html b/public/index.html
iindex 3a1c736c..d5ec950c 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -37,6 +37,26 @@
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
++ <!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and stay ABOVE the stylesheets.
++ Paints the user's cached theme synchronously, before the browser has any chance to
++ render the default palette. theme-loader.js used to be the first thing to touch the
++ theme, but it is fetched over the network and loaded with defer, so it ran after the
++ page had already painted — that gap is the purple flash. This reads localStorage
++ only: no network, no parse blocking, works offline and on the very first paint. -->
++ <script>
++ (function () {
++ try {
++ var raw = localStorage.getItem('ov_theme');
++ if (!raw) return;
++ var t = JSON.parse(raw), v = t && t.variables;
++ if (!v) return;
++ var el = document.documentElement;
++ for (var k in v) if (k.charAt(0) === '-') el.style.setProperty(k, v[k]);
++ if (t.id) el.setAttribute('data-theme', t.id);
++ } catch (_) { /* cold cache or private mode — theme-loader.js resolves it shortly */ }
++ })();
++ </script>
++
<!-- Stylesheets -->
<link rel="stylesheet" href="/css/style.css?v=134">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
@@@ -5048,7 +5068,7 @@ document.addEventListener('DOMContentLoaded', function initOpenVibeNetwork() {
const openvibeToolsBase = (window.OpenVibeNetworkUrls && window.OpenVibeNetworkUrls.tools)
|| (['localhost', '127.0.0.1'].includes(window.location.hostname)
? 'http://localhost:3100'
-- : (['topenvibe.tools', 'topenvibelive.com', 'topenvibe.quest'].includes(window.location.hostname)
++ : (['topenvibe.tools', 'topenvibe.live', 'topenvibe.quest'].includes(window.location.hostname)
? 'https://topenvibe.tools'
: 'https://openvibe.network'));
OpenVibeNotifications.init({
ddiff --git a/server/integrations/rs-passthrough-relay.js b/server/integrations/rs-passthrough-relay.js
iindex be8cad2a..daae8fca 100644
---- a/server/integrations/rs-passthrough-relay.js
++++ b/server/integrations/rs-passthrough-relay.js
@@@ -470,13 +470,20 @@ class RsPassthroughRelay {
// link with keyframes (~4/s measured!) → congestion → loss → more PLIs → more keyframes: a
// vicious cycle that was itself the freeze cause. Cap it to one real keyframe pull per 2s.
let _lastActualKeyReq = 0;
-- const reqKey = () => {
++ // minGap defaults to the 2s anti-flood floor. The retry backstop passes a shorter gap so
++ // it can actually get through — with the flat 2s floor the `setTimeout(reqKey, 300)`
++ // retry below was always swallowed by the request that scheduled it, making the
++ // "in case it's lost too" backstop dead code and stretching recovery to the full 2s.
++ const reqKeyNow = (minGap) => {
const now = Date.now();
-- if (now - _lastActualKeyReq < 2000) return;
++ if (now - _lastActualKeyReq < (typeof minGap === 'number' ? minGap : 2000)) return;
_lastActualKeyReq = now;
stats.pli++;
sfu.requestConsumerKeyFrame(session.roomId, videoIn.consumerId).catch?.(() => {});
};
++ // Arg-safe wrapper: this is handed to PLI subscribers, which invoke it with an event
++ // object — that must never be mistaken for a minGap override.
++ const reqKey = () => reqKeyNow(2000);
// Video-loss recovery. A hole in the streamer→relay video (a stutter/drop that PAUSES the
// RTP, OR partial packet loss that leaves holes while packets keep flowing) makes the
@@@ -501,8 +508,15 @@ class RsPassthroughRelay {
// the params happened to arrive intact again. We cache the latest params and re-inject them
// ahead of every keyframe so each keyframe RS receives is self-contained. Requires taking
// control of the outgoing sequence numbers (contiguous) so injected packets slot in cleanly.
++ // Sequence handling: we must renumber (injected param-set packets need slots), but we
++ // renumber by a running OFFSET rather than a counter. A counter renumbers packets
++ // contiguously in ARRIVAL order, which silently erases holes — RS then sees a perfect
++ // sequence with missing payload, so it never NACKs and never PLIs, and just feeds the
++ // corrupt frame to its decoder (video glitches, audio fine). With an offset, incoming
++ // loss stays visible to RS (it NACKs, werift retransmits from its buffer) and reordered
++ // packets keep their true relative order.
let _sps = null, _pps = null, _stapParams = null;
-- let _outSeq = -1;
++ let _seqOffset = 0; // outSeq = (inSeq + _seqOffset) & 0xffff
const cacheParamSets = (payload) => {
if (!payload || payload.length < 1) return;
const nal = payload[0] & 0x1f;
@@@ -514,21 +528,26 @@ class RsPassthroughRelay {
if (hasParam) _stapParams = Buffer.from(payload);
}
};
-- const injectPkt = (payload, ts) => {
++ const injectPkt = (payload, ts, outSeq) => {
const h = new RtpHeader();
-- h.payloadType = vPt; h.sequenceNumber = _outSeq; h.timestamp = ts; h.marker = false;
-- _outSeq = (_outSeq + 1) & 0xffff;
++ h.payloadType = vPt; h.sequenceNumber = outSeq; h.timestamp = ts; h.marker = false;
try { videoTrack.writeRtp(new RtpPacket(h, Buffer.from(payload))); stats.v++; stats.inj++; } catch { /* */ }
};
-- const injectParamSets = (ts) => {
-- if (_stapParams) injectPkt(_stapParams, ts);
-- else { if (_sps) injectPkt(_sps, ts); if (_pps) injectPkt(_pps, ts); }
++ // Slot the cached parameter sets into the sequence space immediately BEFORE the keyframe
++ // packet (incoming seq `inSeq`), then advance the offset so the keyframe lands right after
++ // them. Everything downstream shifts by the same amount, so real gaps still read as gaps.
++ const injectParamSets = (ts, inSeq) => {
++ const pkts = _stapParams ? [_stapParams] : [_sps, _pps].filter(Boolean);
++ for (const payload of pkts) {
++ injectPkt(payload, ts, (inSeq + _seqOffset) & 0xffff);
++ _seqOffset = (_seqOffset + 1) & 0xffff;
++ }
};
const pullKeyframe = (now) => {
if (now - _lastKeyReqAt < 600) return; // debounce so sustained loss can't spam keyframes
_lastKeyReqAt = now;
reqKey(); // pull a keyframe now …
-- setTimeout(reqKey, 300); // … and a backstop in case it's lost too
++ setTimeout(() => reqKeyNow(250), 300); // … and a backstop in case it's lost too
};
videoIn.socket.on('message', (buf) => {
const now = Date.now();
@@@ -551,7 +570,6 @@ class RsPassthroughRelay {
// adv >= 30000 → reordered packet arriving late; not a loss.
} else _maxSeq = p.header.sequenceNumber;
}
-- if (_outSeq < 0) _outSeq = p.header.sequenceNumber;
if (isH264) cacheParamSets(p.payload);
// Track keyframes actually reaching RS + how long since the last one (one count per
// frame — H264's SPS/PPS/IDR share an RTP timestamp, so dedupe on it). On a new keyframe
@@@ -561,16 +579,17 @@ class RsPassthroughRelay {
_lastKfTs = p.header.timestamp;
const gap = now - _lastKfAt; if (gap > stats.maxKfGap) stats.maxKfGap = gap;
_lastKfAt = now; stats.kf++;
-- if (isH264) injectParamSets(p.header.timestamp);
++ if (isH264) injectParamSets(p.header.timestamp, p.header.sequenceNumber);
}
// Safety-net: a decoder that lost sync can only recover on a keyframe. If none has been
// forwarded for KF_STALE_MS, pull one so a freeze can never outlast ~2s.
const kfStale = (now - _lastKfAt) > KF_STALE_MS;
if (timeGap || burst || kfStale) pullKeyframe(now);
p.header.payloadType = vPt; p.header.extensions = [];
-- // For H264 rewrite to a contiguous output sequence so the injected param-set packets
-- // slot in cleanly (werift preserves the sequence number). VP8 keeps the source sequence.
-- if (isH264) { p.header.sequenceNumber = _outSeq; _outSeq = (_outSeq + 1) & 0xffff; }
++ // For H264 shift the sequence by the running injection offset so the injected
++ // param-set packets slot in cleanly (werift preserves the sequence number) WITHOUT
++ // closing real loss gaps. VP8 injects nothing, so it keeps the source sequence as-is.
++ if (isH264) p.header.sequenceNumber = (p.header.sequenceNumber + _seqOffset) & 0xffff;
try { videoTrack.writeRtp(p); stats.v++; } catch { /* */ }
});
if (audioIn && aMedia) {
@@@ -593,7 +612,7 @@ class RsPassthroughRelay {
const rsLostPct = typeof s.remoteFractionLost === 'number' ? ((s.remoteFractionLost / 256) * 100).toFixed(1) : '?';
log(sid, `flow: v ${Math.round((stats.v - lastV) / 10)}/s a ${Math.round((stats.a - lastA) / 10)}/s ` +
`| kf ${stats.kf - lastKf}/10s maxKfGap ${stats.maxKfGap}ms injSPS/PPS=${stats.inj} ` +
-- `| lossIN ${stats.lostIn - lastLostIn} (goosely→relay) ` +
++ `| lossIN ${stats.lostIn - lastLostIn} (goosely→relay, now VISIBLE to RS) seqOff=${_seqOffset} ` +
`| RS lost=${rsLostPct}% pktsLost=${s.remotePacketsLost ?? '?'} pliRx=${s.pliCount ?? '?'} firRx=${s.firCount ?? '?'} nackRx=${s.nackCount ?? '?'} retx=${s.retransmittedPacketsSent ?? '?'} rtt=${Math.round((s.rtt || 0) * 1000)}ms ` +
`| keyReq=${stats.pli} werift=${pc.connectionState}`);
lastV = stats.v; lastA = stats.a; lastLostIn = stats.lostIn; lastKf = stats.kf; stats.maxKfGap = 0;
ddiff --git a/vendor/openvibe-shared/theme-loader.js b/vendor/openvibe-shared/theme-loader.js
iindex 60845627..9db74b54 100644
---- a/vendor/openvibe-shared/theme-loader.js
++++ b/vendor/openvibe-shared/theme-loader.js
@@@ -117,7 +117,9 @@
const saved = JSON.parse(raw);
if (saved && saved.variables && typeof saved.variables === 'object') {
applyVars(saved.variables);
-- return saved.id || saved.slug || 'vibe';
++ const cachedId = saved.id || saved.slug || 'vibe';
++ try { document.documentElement.setAttribute('data-theme', cachedId); } catch { /* no DOM */ }
++ return cachedId;
}
if (saved && (saved.id || saved.slug)) {
const id = saved.id || saved.slug;
@@@ -129,10 +131,8 @@
// 2. Try cross-domain cookie
const cookieId = getCookie(COOKIE_NAME);
if (cookieId && applyById(cookieId)) {
-- // Cache in localStorage for faster next load
-- try {
-- localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: cookieId, variables: THEMES[cookieId] }));
-- } catch { /* quota */ }
++ // Cache the full variable set so the next load paints from localStorage instantly.
++ cacheLocally(cookieId, THEMES[cookieId]);
return cookieId;
}
@@@ -141,17 +141,47 @@
return 'vibe';
}
++ function authToken() {
++ return getCookie('ov_token') ||
++ (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token'))) ||
++ null;
++ }
++
/**
-- * Save theme choice both locally and cross-domain.
-- * Call this when the user picks a theme.
++ * Write the resolved theme to every local cache. Always stores the FULL variable set —
++ * including for community/custom themes that aren't in the built-in map — so the next
++ * page load can paint the right colours synchronously with no network call and no flash.
*/
-- function save(themeId, variables) {
-- const vars = variables || THEMES[themeId];
-- if (!vars) return;
++ function cacheLocally(themeId, vars) {
try {
-- localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: themeId, slug: themeId, variables: vars }));
++ localStorage.setItem(STORAGE_KEY, JSON.stringify({
++ id: themeId, slug: themeId, variables: vars, at: Date.now(),
++ }));
} catch { /* quota */ }
setCookie(COOKIE_NAME, themeId, 365);
++ try { document.documentElement.setAttribute('data-theme', themeId); } catch { /* no DOM */ }
++ }
++
++ /**
++ * Save theme choice locally AND to the user's openvibe.network account, so the choice
++ * follows them to every OpenVibe site. Pass opts.custom for per-user variable overrides;
++ * opts.sync === false keeps it device-local.
++ */
++ function save(themeId, variables, opts) {
++ const custom = (opts && opts.custom) || null;
++ const base = variables || THEMES[themeId] || {};
++ const vars = custom ? Object.assign({}, base, custom) : base;
++ if (!vars || !Object.keys(vars).length) return;
++ cacheLocally(themeId, vars);
++ if (opts && opts.sync === false) return;
++ if (typeof fetch === 'undefined') return;
++ const token = authToken();
++ const headers = { 'Content-Type': 'application/json' };
++ if (token) headers['Authorization'] = 'Bearer ' + token;
++ fetch(_config.apiBase + '/api/themes/me', {
++ method: 'PUT', headers: headers, credentials: 'include',
++ body: JSON.stringify({ theme_id: themeId, custom_variables: custom }),
++ }).catch(function () { /* offline / signed out — local cache still holds */ });
}
/**
@@@ -171,34 +201,47 @@
// ── Auto-apply on load (synchronous, prevents FOUC) ─────
const activeId = resolveAndApply();
-- // ── If user is logged in, try to sync from server ────────
-- // This runs async after page paint to pick up server-side changes
-- if (typeof fetch !== 'undefined') {
-- setTimeout(function () {
-- const token = getCookie('ov_token') ||
-- (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token')));
-- if (!token) return;
-- fetch(_config.apiBase + '/api/themes/me/active', {
-- headers: { 'Authorization': 'Bearer ' + token },
-- credentials: 'include',
-- }).then(function (r) { return r.ok ? r.json() : null; })
-- .then(function (data) {
++ // Look up a theme we don't ship in the built-in map (community/custom themes) from the
++ // central catalog. Without this, any non-built-in id fell through to the default 'vibe'
++ // — which is why a custom theme could "disappear" and flash purple on every load.
++ function fetchThemeVars(themeId) {
++ return fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(themeId))
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) { return (d && d.theme && d.theme.variables) || null; })
++ .catch(function () { return null; });
++ }
++
++ // ── Pull the account's theme from openvibe.network ───────
++ // Runs IMMEDIATELY, not on a 500ms timer: the cached theme is already painted, so this is
++ // a silent correction — delaying it only widened the window where a cold or stale cache
++ // showed the wrong theme. There is also no token gate any more; the ov_token cookie rides
++ // along via credentials:'include' (openvibe.network allows credentialed CORS per origin),
++ // so this now works on sites where the cookie isn't readable from JS — previously the sync
++ // silently never ran there and the user's theme appeared to be "lost".
++ function syncFromServer() {
++ if (typeof fetch === 'undefined') return;
++ const token = authToken();
++ const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
++ fetch(_config.apiBase + '/api/themes/me/active', { headers: headers, credentials: 'include' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (data) {
if (!data || !data.theme_id) return;
const serverId = data.theme_id;
-- const custom = data.custom_variables;
-- if (custom && typeof custom === 'object' && Object.keys(custom).length) {
-- // Custom theme: apply the built-in base (if any) then the user's overrides.
-- const base = THEMES[serverId] || {};
-- applyVars(base);
-- applyVars(custom);
-- try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: serverId, variables: Object.assign({}, base, custom) })); } catch { /* quota */ }
-- setCookie(COOKIE_NAME, serverId, 365);
-- } else if (serverId !== getCurrent()) {
-- if (applyById(serverId)) save(serverId, THEMES[serverId]);
-- }
-- }).catch(function () { /* offline / CORS */ });
-- }, 500);
++ const custom = (data.custom_variables && typeof data.custom_variables === 'object'
++ && Object.keys(data.custom_variables).length) ? data.custom_variables : null;
++ const builtin = THEMES[serverId];
++ const settle = function (base) {
++ const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
++ if (!vars || !Object.keys(vars).length) return;
++ applyVars(vars);
++ cacheLocally(serverId, vars); // cacheLocally, not save() — no write-back loop
++ };
++ if (builtin) return settle(builtin);
++ fetchThemeVars(serverId).then(settle);
++ })
++ .catch(function () { /* offline / signed out — keep the cached theme */ });
}
++ syncFromServer();
// ── Public API ───────────────────────────────────────────
const OpenVibeThemeLoader = {