openvibers/openvibe.live Commits

Point Go Live WHIP and JSMPEG URLs at OpenRe for switched slots

OP OpenVibers committed 4 Oct 2026 · a9cbf04d1a · 8 files, +110 −24

Requested by finish-f1. Handed to the local harness by the cloud job pr-OpenVibe.Live-25; done by Claude opus.

Changes

ddiff --git a/.env.example b/.env.example
iindex 2c1f3853..d2667202 100644
---- a/.env.example
++++ b/.env.example
@@@ -69,7 +69,7 @@ OV_OAUTH_CLIENT_SECRET=
# OpenRe.Stream (roadmap Wave 7): the ingest/restream authority. Unset = off, and every stream slot
# is ingested by Live exactly as before. When set, an admin can switch one slot at a time
--# (PUT /api/admin/openre/managed/:id/ingest-authority); only RTMP moves. Live's service principal
++# (PUT /api/admin/openre/managed/:id/ingest-authority); RTMP, WHIP and JSMPEG move with it. Live's service principal
# (the OAuth client above) needs openre.stream.read, openre.stream.write, openre.key.rotate and
# openre.session.read on audience openvibe.openre.
#OPENRE_URL=http://127.0.0.1:4500
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex ec7c76f1..564613f0 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -269,11 +269,16 @@ async function _wsLoadProfile(managedStreamId) {
_wsState.whipUrlSource = data.whip_url_source || null;
_wsState.whipUrlWarning = data.whip_url_warning || null;
_wsState.rtmpUrl = data.rtmp_url || null;
++ // OpenRe-ingested slot: OpenRe's WHIP and JSMPEG servers (the key goes after each).
++ _wsState.openreWhipUrl = data.whip_url || null;
++ _wsState.openreJsmpegUrl = data.jsmpeg_url || null;
} catch {
_wsState.profile = {};
_wsState.streamKey = _wsState.selectedMs?.stream_key || null;
_wsState.streamKeyHint = null;
_wsState.rtmpUrl = null;
++ _wsState.openreWhipUrl = null;
++ _wsState.openreJsmpegUrl = null;
}
_wsState.dirty = false;
}
@@@ -1379,9 +1384,17 @@ function _wsRenderBrowserModeCards(selected) {
/* ── Method-specific endpoint info ───────────────────────────── */
++// Live's WHIP takes the slot id in the path (key as Bearer token); OpenRe's takes the key itself.
++function _wsWhipUrl(managedStreamId, streamKey) {
++ if (_wsState.openreWhipUrl) return `${_wsState.openreWhipUrl.replace(/\/$/, '')}/${streamKey}`;
++ const whipBaseUrl = (_wsState.whipUrlBase || window.location.origin).replace(/\/$/, '');
++ return `${whipBaseUrl}/whip/${managedStreamId || streamKey}`;
++}
++
function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
if (!streamKey) return '';
const rtmpServer = _wsState.rtmpUrl || 'rtmp://openvibe.live/live';
++ const jsmpegServer = (_wsState.openreJsmpegUrl || 'http://openvibe.live:PORT').replace(/\/$/, '');
if (method === 'rtmp') {
return `
@@@ -1421,8 +1434,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
}
if (method === 'whip') {
-- const whipBaseUrl = (_wsState.whipUrlBase || window.location.origin).replace(/\/$/, '');
-- const whipUrl = `${whipBaseUrl}/whip/${managedStreamId || streamKey}`;
++ const whipUrl = _wsWhipUrl(managedStreamId, streamKey);
return `
<div class="bc-ws-method-info">
<div class="bc-ws-method-info-row">
@@@ -1446,8 +1458,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
}
if (method === 'cli') {
-- const whipBaseUrl = (_wsState.whipUrlBase || window.location.origin).replace(/\/$/, '');
-- const whipUrl = `${whipBaseUrl}/whip/${managedStreamId || streamKey}`;
++ const whipUrl = _wsWhipUrl(managedStreamId, streamKey);
return `
<div class="bc-ws-method-info">
<h4 style="margin:8px 0 4px"><i class="fa-solid fa-terminal"></i> CLI / FFmpeg Streaming</h4>
@@@ -1472,20 +1483,20 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
-f alsa -i default \\
-f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k \\
-codec:a mp2 -b:a 96k -ar 44100 \\
-- http://openvibe.live:PORT/${esc(streamKey)}/640/480/</pre>
++ ${esc(jsmpegServer)}/${esc(streamKey)}/640/480/</pre>
<h5><i class="fa-solid fa-display"></i> Screen Capture (Linux X11)</h5>
<pre class="bc-ws-cli-code">ffmpeg -f x11grab -s 1280x720 -r 24 -i :0.0 \\
-f pulse -i default \\
-f mpegts -codec:v mpeg1video -s 640x480 -b:v 400k \\
-codec:a mp2 -b:a 96k -ar 44100 \\
-- http://openvibe.live:PORT/${esc(streamKey)}/640/480/</pre>
++ ${esc(jsmpegServer)}/${esc(streamKey)}/640/480/</pre>
<h5><i class="fa-solid fa-film"></i> MP4 / File Loop</h5>
<pre class="bc-ws-cli-code">ffmpeg -re -stream_loop -1 -i video.mp4 \\
-f mpegts -codec:v mpeg1video -s 640x480 -b:v 400k \\
-codec:a mp2 -b:a 96k -ar 44100 \\
-- http://openvibe.live:PORT/${esc(streamKey)}/640/480/</pre>
++ ${esc(jsmpegServer)}/${esc(streamKey)}/640/480/</pre>
<h5><i class="fa-solid fa-microchip"></i> Raspberry Pi Camera</h5>
<pre class="bc-ws-cli-code"># Pi Camera v2 / libcamera &rarr; JSMPEG
@@@ -1493,13 +1504,13 @@ rpicam-vid -t 0 --width 640 --height 480 --framerate 24 \\
--codec yuv420 -o - | \\
ffmpeg -f rawvideo -pix_fmt yuv420p -s 640x480 -r 24 -i - \\
-f mpegts -codec:v mpeg1video -b:v 350k \\
-- http://openvibe.live:PORT/${esc(streamKey)}/640/480/</pre>
++ ${esc(jsmpegServer)}/${esc(streamKey)}/640/480/</pre>
<h5><i class="fa-solid fa-camera-cctv"></i> RTSP / IP Camera</h5>
<pre class="bc-ws-cli-code">ffmpeg -rtsp_transport tcp -i rtsp://user:[email protected]:554/stream \\
-f mpegts -codec:v mpeg1video -s 640x480 -b:v 400k \\
-codec:a mp2 -b:a 96k -ar 44100 \\
-- http://openvibe.live:PORT/${esc(streamKey)}/640/480/</pre>
++ ${esc(jsmpegServer)}/${esc(streamKey)}/640/480/</pre>
<details class="bc-ws-cli-note">
<summary>About JSMPEG</summary>
@@@ -1865,6 +1876,12 @@ async function _wsRegenerateKey(managedStreamId) {
try {
const data = await api(`/streams/managed/${managedStreamId}/regenerate-key`, { method: 'POST' });
_wsState.streamKey = data.stream_key;
++ // OpenRe's rotation answers with its ingest servers too.
++ if (data.stream_key_managed_by === 'openre') {
++ _wsState.rtmpUrl = data.rtmp_url || _wsState.rtmpUrl;
++ _wsState.openreWhipUrl = data.whip_url || _wsState.openreWhipUrl;
++ _wsState.openreJsmpegUrl = data.jsmpeg_url || _wsState.openreJsmpegUrl;
++ }
const input = document.getElementById('bc-ws-stream-key');
if (input) input.value = data.stream_key;
const methodEl = document.getElementById('bc-ws-method-endpoint');
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex e96220f7..9965b652 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -1966,9 +1966,13 @@ async function showJSMPEGInstructions(stream) {
const data = await api(`/streams/${stream.id}/endpoint`);
const ep = data.endpoint || {};
const host = location.hostname;
-- const baseUrl = `http://${host}:${ep.videoPort || 9710}/${data.stream_key}/640/480/`;
-- const audioUrl = `http://${host}:${ep.audioPort || 9711}/${data.stream_key}/`;
-- const hdUrl = `http://${host}:${ep.videoPort || 9710}/${data.stream_key}/1280/720/`;
++ // An OpenRe-ingested slot: OpenRe's JSMPEG server (video and audio share it) and only a key hint.
++ const videoServer = ep.jsmpegUrl ? ep.jsmpegUrl.replace(/\/$/, '') : `http://${host}:${ep.videoPort || 9710}`;
++ const audioServer = ep.jsmpegUrl ? videoServer : `http://${host}:${ep.audioPort || 9711}`;
++ const key = data.stream_key || ep.streamKeyHint || 'N/A';
++ const baseUrl = `${videoServer}/${key}/640/480/`;
++ const audioUrl = `${audioServer}/${key}/`;
++ const hdUrl = `${videoServer}/${key}/1280/720/`;
// Video + Audio
document.getElementById('bc-jsmpeg-cmd').textContent = ep.ffmpegCommand || `ffmpeg -f v4l2 -i /dev/video0 -f alsa -i default -f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k -bf 0 -codec:a mp2 -b:a 128k -ar 44100 -ac 1 -muxdelay 0.001 ${baseUrl}`;
@@@ -2017,8 +2021,11 @@ async function showWHIPInstructions(stream) {
loadLiveControlsStatus(stream.id).catch(() => {});
const isLocalHost = /^(localhost|127\.|\[::1\])$/.test(location.hostname);
let whipBaseUrl = null;
++ let openreWhip = null;
try {
const data = await api(`/streams/${stream.id}/endpoint`);
++ // An OpenRe-ingested slot: OpenRe's WHIP URL takes the key (only its hint is known here).
++ if (data.endpoint?.whipUrl) openreWhip = `${data.endpoint.whipUrl.replace(/\/$/, '')}/${data.endpoint.streamKeyHint || 'N/A'}`;
whipBaseUrl = data.endpoint?.whipUrlBase || null;
if (data.endpoint?.whipUrlSource === 'request_host') {
console.warn('[WHIP] Server did not provide a configured WHIP URL. Request-host fallback is being used; please configure WHIP_PUBLIC_URL or WEBRTC_PUBLIC_URL to avoid incorrect client endpoints.');
@@@ -2026,14 +2033,15 @@ async function showWHIPInstructions(stream) {
} catch (err) {
console.warn('[WHIP] Failed to load canonical WHIP endpoint from server:', err.message);
}
-- if (!whipBaseUrl) {
++ if (!whipBaseUrl && !openreWhip) {
whipBaseUrl = location.origin;
if (!isLocalHost) {
console.warn('[WHIP] Falling back to the current page origin for WHIP URL outside of localhost. This is only safe for local development. Configure WHIP_PUBLIC_URL or WEBRTC_PUBLIC_URL in the registry to fix this.');
}
}
-- document.getElementById('bc-whip-url').textContent = `${whipBaseUrl}/whip/${stream.id}`;
-- const token = getStoredAuthToken() || 'N/A';
++ document.getElementById('bc-whip-url').textContent = openreWhip || `${whipBaseUrl}/whip/${stream.id}`;
++ // Live's sign-in token is never offered as the Bearer for OpenRe's WHIP.
++ const token = (!openreWhip && getStoredAuthToken()) || 'N/A';
const tokenEl = document.getElementById('bc-whip-token');
if (tokenEl) {
tokenEl.dataset.value = token;
ddiff --git a/server/streaming/jsmpeg-relay.js b/server/streaming/jsmpeg-relay.js
iindex 96110bf1..94dc3aab 100644
---- a/server/streaming/jsmpeg-relay.js
++++ b/server/streaming/jsmpeg-relay.js
@@@ -23,8 +23,8 @@ function matchesStreamPath(url, streamKey) {
}
/**
-- * Consult the per-protocol ingest authority for each publish. OpenRe currently ingests RTMP
-- * only, so JSMPEG continues to publish through Live after an RTMP switch.
++ * Consult the per-protocol ingest authority for each publish: a slot (or personal key)
++ * ingested by OpenRe publishes JSMPEG to OpenRe, never to Live's relay.
*/
function refusedByOpenre(streamKey) {
try {
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex a361016f..643782db 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1601,12 +1601,13 @@ router.get('/managed/:managedStreamId/profile', requireAuth, async (req, res) =>
rtmp_url: rtmpUrl,
restream_destinations: restreamDestinations,
};
-- // OpenRe ingests this slot: its RTMP server and key come from OpenRe (the key is only
-- // ever shown by Regenerate). Slots on Live's own ingest get exactly the response above.
++ // OpenRe ingests this slot: its RTMP/WHIP/JSMPEG servers and key come from OpenRe (the key
++ // is only ever shown by Regenerate), and Live's WHIP base is withheld: Live refuses that
++ // publish. Slots on Live's own ingest get exactly the response above.
if (openreAuthority.authorityOf(ms) === 'openre') {
const subject = require('../auth/identity-sync').subjectOf(ms.user_id);
-- Object.assign(body, { stream_key: null }, await openreAuthority.ingestFor(ms, subject).catch((err) => ({
-- ingest_authority: 'openre', stream_key_managed_by: 'openre', rtmp_url: null, stream_key_hint: `OpenRe is unreachable (${err.message})`,
++ Object.assign(body, { stream_key: null, whip_url_base: null, whip_url_source: null, whip_url_warning: null }, await openreAuthority.ingestFor(ms, subject).catch((err) => ({
++ ingest_authority: 'openre', stream_key_managed_by: 'openre', rtmp_url: null, whip_url: null, jsmpeg_url: null, stream_key_hint: `OpenRe is unreachable (${err.message})`,
})));
}
res.json(body);
ddiff --git a/server/streaming/whip-handler.js b/server/streaming/whip-handler.js
iindex b4139a9c..d09a0d62 100644
---- a/server/streaming/whip-handler.js
++++ b/server/streaming/whip-handler.js
@@@ -120,8 +120,8 @@ function sendWhipError(res, status, code, message) {
}
/**
-- * Consult the per-protocol ingest authority before accepting a publisher. OpenRe currently
-- * ingests RTMP only, so WHIP continues to publish through Live after an RTMP switch.
++ * Consult the per-protocol ingest authority before accepting a publisher: a slot (or personal
++ * key) ingested by OpenRe publishes WHIP to OpenRe, never to Live.
*/
function refusedByOpenre(slotId, userId) {
const managedStream = slotId ? db.getManagedStreamById(slotId) : null;
ddiff --git a/test/openre-switch.test.js b/test/openre-switch.test.js
iindex 77c0f44c..ad1e55a3 100644
---- a/test/openre-switch.test.js
++++ b/test/openre-switch.test.js
@@@ -380,6 +380,25 @@ function sessionEvent(type, sessionId, revision, payload = {}) {
else assert.fail(`status route answered ${statusRes.status}`);
statusServer.close();
++ // The Go Live profile and endpoint of an OpenRe WHIP/JSMPEG slot point at OpenRe, never at Live's
++ // WHIP base or relay (Live refuses those publishes).
++ require('../server/auth/auth').requireAuth = (req, res, next) => { req.user = db.getUserById(601); next(); };
++ const streamsApp = express();
++ streamsApp.use('/api/streams', require('../server/streaming/routes'));
++ const streamsServer = await new Promise(r => { const s = streamsApp.listen(0, '127.0.0.1', () => r(s)); });
++ const streamsBase = `http://127.0.0.1:${streamsServer.address().port}/api/streams`;
++ const whipProfile = await (await fetch(`${streamsBase}/managed/708/profile`)).json();
++ assert.deepStrictEqual([whipProfile.stream_key, whipProfile.whip_url_base, whipProfile.whip_url], [null, null, 'https://ingest.openre.stream/whip'], JSON.stringify(whipProfile));
++ const jsmpegProfile = await (await fetch(`${streamsBase}/managed/709/profile`)).json();
++ assert.deepStrictEqual([jsmpegProfile.stream_key, jsmpegProfile.whip_url_base, jsmpegProfile.jsmpeg_url], [null, null, 'http://ingest.openre.stream:8081'], JSON.stringify(jsmpegProfile));
++ const whipStreamId = Number(db.createStream({ user_id: 601, managed_stream_id: 708, title: 'w', protocol: 'webrtc' }).lastInsertRowid);
++ const jsmpegStreamId = Number(db.createStream({ user_id: 601, managed_stream_id: 709, title: 'j', protocol: 'jsmpeg' }).lastInsertRowid);
++ const whipEndpoint = await (await fetch(`${streamsBase}/${whipStreamId}/endpoint`)).json();
++ assert.deepStrictEqual([whipEndpoint.stream_key, whipEndpoint.endpoint.whipUrl, whipEndpoint.endpoint.whipUrlBase], [null, 'https://ingest.openre.stream/whip', undefined], JSON.stringify(whipEndpoint));
++ const jsmpegEndpoint = await (await fetch(`${streamsBase}/${jsmpegStreamId}/endpoint`)).json();
++ assert.deepStrictEqual([jsmpegEndpoint.stream_key, jsmpegEndpoint.endpoint.jsmpegUrl, jsmpegEndpoint.endpoint.videoPort], [null, 'http://ingest.openre.stream:8081', undefined], JSON.stringify(jsmpegEndpoint));
++ streamsServer.close();
++
// ── Rollback ─────────────────────────────────────────────
assert.strictEqual((await authority.setAuthority(701, 'live')).status, 200);
const back = db.getManagedStreamById(701);
ddiff --git a/test/workspace-openre-ingest.test.js b/test/workspace-openre-ingest.test.js
nnew file mode 100644
iindex 00000000..49ff7a07
---- /dev/null
++++ b/test/workspace-openre-ingest.test.js
@@@ -0,0 +1,41 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * The Go Live workspace's ingest URLs for a slot ingested by OpenRe: WHIP and JSMPEG point at
++ * OpenRe's servers with the key in the path, never at Live's /whip/<slot> or JSMPEG relay (Live
++ * refuses those publishes). Slots on Live's own ingest keep Live's URLs.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const src = fs.readFileSync(path.join(__dirname, '../public/js/broadcast-workspace.js'), 'utf8');
++const ctx = {
++ window: { location: { origin: 'https://openvibe.live' }, addEventListener() {} },
++ document: { addEventListener() {}, getElementById() { return null; }, querySelectorAll() { return []; } },
++ localStorage: { getItem() { return null; }, setItem() {} },
++ console,
++ esc: (s) => String(s),
++};
++vm.createContext(ctx);
++vm.runInContext(`${src}\n;this.__ws = { _wsState, _wsWhipUrl, _wsRenderMethodEndpoint };`, ctx);
++const { _wsState, _wsWhipUrl, _wsRenderMethodEndpoint } = ctx.__ws;
++
++// Live's own ingest: the slot id goes after Live's WHIP base, JSMPEG keeps the relay placeholder.
++_wsState.whipUrlBase = 'https://whip.openvibe.live/';
++assert.strictEqual(_wsWhipUrl(708, 'livekey'), 'https://whip.openvibe.live/whip/708');
++assert.ok(_wsRenderMethodEndpoint('cli', 'livekey', 708).includes('http://openvibe.live:PORT/livekey/640/480/'));
++
++// OpenRe: its WHIP URL takes the key, its JSMPEG server takes key/width/height.
++Object.assign(_wsState, { whipUrlBase: null, openreWhipUrl: 'https://ingest.openre.stream/whip', openreJsmpegUrl: 'http://ingest.openre.stream:8081' });
++assert.strictEqual(_wsWhipUrl(708, 'ork_key'), 'https://ingest.openre.stream/whip/ork_key');
++const whipInfo = _wsRenderMethodEndpoint('whip', 'ork_key', 708);
++assert.ok(whipInfo.includes('https://ingest.openre.stream/whip/ork_key'), whipInfo);
++assert.ok(!whipInfo.includes('/whip/708'));
++const cli = _wsRenderMethodEndpoint('cli', 'ork_key', 709);
++assert.ok(cli.includes('http://ingest.openre.stream:8081/ork_key/640/480/'), 'JSMPEG commands use OpenRe');
++assert.ok(!cli.includes('openvibe.live:PORT'));
++assert.ok(cli.includes('https://ingest.openre.stream/whip/ork_key'), 'the CLI WHIP tab uses OpenRe');
++
++console.log('✅ workspace OpenRe ingest: WHIP and JSMPEG URLs point at OpenRe for switched slots, at Live otherwise');