openvibers/openvibe.live Commits
Point Go Live WHIP and JSMPEG URLs at OpenRe for switched slots
Changes
@@@ -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@@@ -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 → 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');@@@ -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 + Audiodocument.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;@@@ -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 {@@@ -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);@@@ -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;@@@ -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);nnew file mode 100644@@@ -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');
@@ -69,7 +69,7 @@ OV_OAUTH_CLIENT_SECRET=
@@ -69,7 +69,7 @@ OV_OAUTH_CLIENT_SECRET=
# OpenRe.Stream (roadmap Wave 7): the ingest/restream authority. Unset = off, and every stream slot
# 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
# 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
# (the OAuth client above) needs openre.stream.read, openre.stream.write, openre.key.rotate and
# openre.session.read on audience openvibe.openre.
# openre.session.read on audience openvibe.openre.
#OPENRE_URL=http://127.0.0.1:4500
#OPENRE_URL=http://127.0.0.1:4500
@@ -269,11 +269,16 @@ async function _wsLoadProfile(managedStreamId) {
@@ -269,11 +269,16 @@ async function _wsLoadProfile(managedStreamId) {
_wsState.whipUrlSource = data.whip_url_source || null;
_wsState.whipUrlSource = data.whip_url_source || null;
_wsState.whipUrlWarning = data.whip_url_warning || null;
_wsState.whipUrlWarning = data.whip_url_warning || null;
_wsState.rtmpUrl = data.rtmp_url || 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 {
} catch {
_wsState.profile = {};
_wsState.profile = {};
_wsState.streamKey = _wsState.selectedMs?.stream_key || null;
_wsState.streamKey = _wsState.selectedMs?.stream_key || null;
_wsState.streamKeyHint = null;
_wsState.streamKeyHint = null;
_wsState.rtmpUrl = null;
_wsState.rtmpUrl = null;
+ _wsState.openreWhipUrl = null;
+ _wsState.openreJsmpegUrl = null;
}
}
_wsState.dirty = false;
_wsState.dirty = false;
}
}
@@ -1379,9 +1384,17 @@ function _wsRenderBrowserModeCards(selected) {
@@ -1379,9 +1384,17 @@ function _wsRenderBrowserModeCards(selected) {
/* ── Method-specific endpoint info ───────────────────────────── */
/* ── 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) {
function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
if (!streamKey) return '';
if (!streamKey) return '';
const rtmpServer = _wsState.rtmpUrl || 'rtmp://openvibe.live/live';
const rtmpServer = _wsState.rtmpUrl || 'rtmp://openvibe.live/live';
+ const jsmpegServer = (_wsState.openreJsmpegUrl || 'http://openvibe.live:PORT').replace(/\/$/, '');
if (method === 'rtmp') {
if (method === 'rtmp') {
return `
return `
@@ -1421,8 +1434,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
@@ -1421,8 +1434,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
}
}
if (method === 'whip') {
if (method === 'whip') {
- const whipBaseUrl = (_wsState.whipUrlBase || window.location.origin).replace(/\/$/, '');
- const whipUrl = `${whipBaseUrl}/whip/${managedStreamId || streamKey}`;
+ const whipUrl = _wsWhipUrl(managedStreamId, streamKey);
return `
return `
<div class="bc-ws-method-info">
<div class="bc-ws-method-info">
<div class="bc-ws-method-info-row">
<div class="bc-ws-method-info-row">
@@ -1446,8 +1458,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
@@ -1446,8 +1458,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
}
}
if (method === 'cli') {
if (method === 'cli') {
- const whipBaseUrl = (_wsState.whipUrlBase || window.location.origin).replace(/\/$/, '');
- const whipUrl = `${whipBaseUrl}/whip/${managedStreamId || streamKey}`;
+ const whipUrl = _wsWhipUrl(managedStreamId, streamKey);
return `
return `
<div class="bc-ws-method-info">
<div class="bc-ws-method-info">
<h4 style="margin:8px 0 4px"><i class="fa-solid fa-terminal"></i> CLI / FFmpeg Streaming</h4>
<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) {
@@ -1472,20 +1483,20 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
-f alsa -i default \\
-f alsa -i default \\
-f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k \\
-f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k \\
-codec:a mp2 -b:a 96k -ar 44100 \\
-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>
<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 \\
<pre class="bc-ws-cli-code">ffmpeg -f x11grab -s 1280x720 -r 24 -i :0.0 \\
-f pulse -i default \\
-f pulse -i default \\
-f mpegts -codec:v mpeg1video -s 640x480 -b:v 400k \\
-f mpegts -codec:v mpeg1video -s 640x480 -b:v 400k \\
-codec:a mp2 -b:a 96k -ar 44100 \\
-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>
<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 \\
<pre class="bc-ws-cli-code">ffmpeg -re -stream_loop -1 -i video.mp4 \\
-f mpegts -codec:v mpeg1video -s 640x480 -b:v 400k \\
-f mpegts -codec:v mpeg1video -s 640x480 -b:v 400k \\
-codec:a mp2 -b:a 96k -ar 44100 \\
-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>
<h5><i class="fa-solid fa-microchip"></i> Raspberry Pi Camera</h5>
<pre class="bc-ws-cli-code"># Pi Camera v2 / libcamera → JSMPEG
<pre class="bc-ws-cli-code"># Pi Camera v2 / libcamera → JSMPEG
@@ -1493,13 +1504,13 @@ rpicam-vid -t 0 --width 640 --height 480 --framerate 24 \\
@@ -1493,13 +1504,13 @@ rpicam-vid -t 0 --width 640 --height 480 --framerate 24 \\
--codec yuv420 -o - | \\
--codec yuv420 -o - | \\
ffmpeg -f rawvideo -pix_fmt yuv420p -s 640x480 -r 24 -i - \\
ffmpeg -f rawvideo -pix_fmt yuv420p -s 640x480 -r 24 -i - \\
-f mpegts -codec:v mpeg1video -b:v 350k \\
-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>
<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 \\
<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 \\
-f mpegts -codec:v mpeg1video -s 640x480 -b:v 400k \\
-codec:a mp2 -b:a 96k -ar 44100 \\
-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">
<details class="bc-ws-cli-note">
<summary>About JSMPEG</summary>
<summary>About JSMPEG</summary>
@@ -1865,6 +1876,12 @@ async function _wsRegenerateKey(managedStreamId) {
@@ -1865,6 +1876,12 @@ async function _wsRegenerateKey(managedStreamId) {
try {
try {
const data = await api(`/streams/managed/${managedStreamId}/regenerate-key`, { method: 'POST' });
const data = await api(`/streams/managed/${managedStreamId}/regenerate-key`, { method: 'POST' });
_wsState.streamKey = data.stream_key;
_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');
const input = document.getElementById('bc-ws-stream-key');
if (input) input.value = data.stream_key;
if (input) input.value = data.stream_key;
const methodEl = document.getElementById('bc-ws-method-endpoint');
const methodEl = document.getElementById('bc-ws-method-endpoint');
@@ -1966,9 +1966,13 @@ async function showJSMPEGInstructions(stream) {
@@ -1966,9 +1966,13 @@ async function showJSMPEGInstructions(stream) {
const data = await api(`/streams/${stream.id}/endpoint`);
const data = await api(`/streams/${stream.id}/endpoint`);
const ep = data.endpoint || {};
const ep = data.endpoint || {};
const host = location.hostname;
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
// 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}`;
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) {
@@ -2017,8 +2021,11 @@ async function showWHIPInstructions(stream) {
loadLiveControlsStatus(stream.id).catch(() => {});
loadLiveControlsStatus(stream.id).catch(() => {});
const isLocalHost = /^(localhost|127\.|\[::1\])$/.test(location.hostname);
const isLocalHost = /^(localhost|127\.|\[::1\])$/.test(location.hostname);
let whipBaseUrl = null;
let whipBaseUrl = null;
+ let openreWhip = null;
try {
try {
const data = await api(`/streams/${stream.id}/endpoint`);
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;
whipBaseUrl = data.endpoint?.whipUrlBase || null;
if (data.endpoint?.whipUrlSource === 'request_host') {
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.');
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) {
@@ -2026,14 +2033,15 @@ async function showWHIPInstructions(stream) {
} catch (err) {
} catch (err) {
console.warn('[WHIP] Failed to load canonical WHIP endpoint from server:', err.message);
console.warn('[WHIP] Failed to load canonical WHIP endpoint from server:', err.message);
}
}
- if (!whipBaseUrl) {
+ if (!whipBaseUrl && !openreWhip) {
whipBaseUrl = location.origin;
whipBaseUrl = location.origin;
if (!isLocalHost) {
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.');
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');
const tokenEl = document.getElementById('bc-whip-token');
if (tokenEl) {
if (tokenEl) {
tokenEl.dataset.value = token;
tokenEl.dataset.value = token;
@@ -23,8 +23,8 @@ function matchesStreamPath(url, streamKey) {
@@ -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) {
function refusedByOpenre(streamKey) {
try {
try {
@@ -1601,12 +1601,13 @@ router.get('/managed/:managedStreamId/profile', requireAuth, async (req, res) =>
@@ -1601,12 +1601,13 @@ router.get('/managed/:managedStreamId/profile', requireAuth, async (req, res) =>
rtmp_url: rtmpUrl,
rtmp_url: rtmpUrl,
restream_destinations: restreamDestinations,
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') {
if (openreAuthority.authorityOf(ms) === 'openre') {
const subject = require('../auth/identity-sync').subjectOf(ms.user_id);
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);
res.json(body);
@@ -120,8 +120,8 @@ function sendWhipError(res, status, code, message) {
@@ -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) {
function refusedByOpenre(slotId, userId) {
const managedStream = slotId ? db.getManagedStreamById(slotId) : null;
const managedStream = slotId ? db.getManagedStreamById(slotId) : null;
@@ -380,6 +380,25 @@ function sessionEvent(type, sessionId, revision, payload = {}) {
@@ -380,6 +380,25 @@ function sessionEvent(type, sessionId, revision, payload = {}) {
else assert.fail(`status route answered ${statusRes.status}`);
else assert.fail(`status route answered ${statusRes.status}`);
statusServer.close();
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 ─────────────────────────────────────────────
// ── Rollback ─────────────────────────────────────────────
assert.strictEqual((await authority.setAuthority(701, 'live')).status, 200);
assert.strictEqual((await authority.setAuthority(701, 'live')).status, 200);
const back = db.getManagedStreamById(701);
const back = db.getManagedStreamById(701);
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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');