openvibers/openvibe.live Commits
A screen share's camera goes out as a stream of its own: its own VOD, shown as the PIP (#393)
Changes
@@@ -94,6 +94,18 @@ live, and appears within 30 seconds when that stream goes live after the page op**Limits:** an 18+ stream shows only over another 18+ stream, and a banned streamer's slot shows nowhere.++**Your camera during a screen share** goes out as a stream of its own by default (screen share settings, **How the++camera goes out**):++- It goes live on one of your slots: the first of your idle slots already listed as a PIP here, else a new++ "<title> camera" slot, added at the corner and size you chose.++- It gets its own VOD.++- Viewers can move it or hide it.++- It plays over the screen share's VOD where it was.++- It ends with the screen share, or when you turn the camera off.++++Restreams to Twitch, YouTube and the like get the screen only. Choose **Drawn into the screen share** to paint the++camera into the picture as before, so restreams show it too.++API: `GET`/`PUT /api/streams/managed/:id/pips` (`{ pips: [{ source_msid, x, y, w, muted }] }`, fractions of theplayer), `GET /api/streams/pip-sources?username=` (a streamer's slots), and `GET /api/streams/:id/pips` (a livestream's PIPs now). The stream objects carry `pip_overlays`.@@@ -214,6 +214,7 @@"/js/call.js","/js/broadcast-workspace.js","/js/broadcast.js",++ "/js/broadcast-cam-pip.js","/js/broadcast-studio.js"]},nnew file mode 100644@@@ -0,0 +1,192 @@++/* ───────────────────────────────────────────────────────────────++ broadcast-cam-pip.js — a screen share's camera as a stream of its own (owner, 2026-10-26: "when a browser streamer++ uses the PIP feature for their camera, the PIP camera should actually be using it's own independent stream … own++ vods").++++ Instead of painting the camera into the screen share (_startPipComposite), the page publishes it to one of the++ streamer's slots, which is the screen share slot's picture-in-picture (stream_pips). So the camera:++ - is a live stream of its own: viewers move, resize, mute or hide it over the screen share (stream-pip.js);++ - is recorded to its own VOD by the server, like any WebRTC stream (recorder.reconcileLiveRecordings);++ - plays over the screen share's VOD again, where it was (streaming/pip-history.js, openvibe.video).++ Its slot: the first of the streamer's own slots the screen share slot already lists as a PIP and that is not live,++ else a new "<title> camera" slot, added as a PIP where the screen share settings put the camera. Restreams (Twitch,++ YouTube …) get the screen share only; "Drawn into the screen" keeps the old composite for them.++++ OVCamPip.wanted(ss) → whether this stream's camera goes out on its own (the setting, a slot, the module)++ OVCamPip.prepare(ss) → its slot and live stream, before capture; false → broadcast.js draws it in as before++ OVCamPip.start(ss, camStream) → publish it (or swap its track when already out); resolves true when live++ OVCamPip.stop(mainStreamId) → end the camera stream and stop its tracks++ It talks to /ws/broadcast as a broadcaster, like broadcast.js's _handleSfuProduceRequest, with that file's helpers.++ ─────────────────────────────────────────────────────────────── */++(function () {++ 'use strict';++++ const SIZES = { small: 0.18, medium: 0.25, large: 0.35 };++ const MARGIN = 0.02;++ const sessions = new Map(); // main stream id → { camStreamId, camMsid, ws, transport, producer, stream, closing, retries }++++ /** The screen share settings' corner and size as the PIP's place (fractions of the player, the overlay 16:9). */++ function placeOf(pos, size) {++ const w = SIZES[size] || SIZES.small;++ const h = w * 9 / 16;++ const right = 1 - w - MARGIN, bottom = 1 - h - MARGIN;++ const p = String(pos || 'bottom-right');++ return { w, x: p.endsWith('left') ? MARGIN : right, y: p.startsWith('top') ? MARGIN : bottom };++ }++++ function wanted(ss) {++ const mode = (typeof broadcastState !== 'undefined' && broadcastState.settings && broadcastState.settings.screenCamMode) || 'stream';++ return mode !== 'overlay' && !!(ss && ss.streamData && ss.streamData.managed_stream_id);++ }++++ /** The camera's slot: an idle one of the streamer's own already listed as a PIP here, else a new one, added as a PIP. */++ async function cameraSlot(mainMsid, title) {++ const me = (typeof currentUser !== 'undefined' && currentUser && currentUser.username) || '';++ const listed = await api(`/streams/managed/${mainMsid}/pips`);++ const pips = (listed && listed.pips) || [];++ const own = pips.find((p) => p.username && p.username.toLowerCase() === me.toLowerCase() && !p.live);++ if (own) return own.source_msid;++ const created = await api('/streams/managed', {++ method: 'POST',++ body: { title: `${String(title || 'Stream').slice(0, 80)} camera`, protocol: 'webrtc', streaming_method: 'browser' },++ });++ const msid = created && created.managed_stream && created.managed_stream.id;++ if (!msid) throw new Error('could not make a camera slot');++ const s = (typeof broadcastState !== 'undefined' && broadcastState.settings) || {};++ const rows = pips.map((p) => ({ source_msid: p.source_msid, x: p.defaults.x, y: p.defaults.y, w: p.defaults.w, muted: p.muted }));++ if (rows.length >= ((listed && listed.max) || 3)) rows.pop(); // the newest camera takes the last place++ rows.push({ source_msid: msid, ...placeOf(s.screenPipPos, s.screenPipSize), muted: true });++ await api(`/streams/managed/${mainMsid}/pips`, { method: 'PUT', body: { pips: rows } });++ return msid;++ }++++ function waitFor(ws, type, ms = 15000) {++ return new Promise((resolve, reject) => {++ const timer = setTimeout(() => { ws.removeEventListener('message', on); reject(new Error(`camera: no ${type}`)); }, ms);++ function on(ev) {++ let m; try { m = JSON.parse(ev.data); } catch { return; }++ if (m.type === type) { clearTimeout(timer); ws.removeEventListener('message', on); resolve(m); }++ else if (m.type === 'sfu-error') { clearTimeout(timer); ws.removeEventListener('message', on); reject(new Error(m.error || 'camera: SFU error')); }++ }++ ws.addEventListener('message', on);++ });++ }++++ /** Broadcaster socket for the camera stream, and its video producer in the SFU. */++ async function publish(sess) {++ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';++ const token = localStorage.getItem('token') || '';++ const ws = new WebSocket(`${proto}://${window.location.host}/ws/broadcast?token=${encodeURIComponent(token)}&streamId=${sess.camStreamId}&role=broadcaster`);++ sess.ws = ws;++ await new Promise((res, rej) => { ws.addEventListener('open', res, { once: true }); ws.addEventListener('error', () => rej(new Error('camera: socket failed')), { once: true }); });++ ws.addEventListener('close', () => { if (!sess.closing) retry(sess); });++ const send = (m) => ws.readyState === 1 && ws.send(JSON.stringify(m));++ const mod = await loadRobotStreamerMediasoup();++ const Device = mod.Device || mod.default?.Device;++ const device = new Device();++ send({ type: 'sfu-get-capabilities' });++ const caps = await waitFor(ws, 'sfu-capabilities');++ await device.load({ routerRtpCapabilities: caps.rtpCapabilities });++ send({ type: 'sfu-create-transport' });++ const tp = await waitFor(ws, 'sfu-transport-created');++ const transport = device.createSendTransport({ id: tp.id, iceParameters: tp.iceParameters, iceCandidates: tp.iceCandidates,++ dtlsParameters: tp.dtlsParameters, iceServers: tp.iceServers || [{ urls: 'stun:stun.l.google.com:19302' }] });++ sess.transport = transport;++ transport.on('connect', ({ dtlsParameters }, cb, errb) => { send({ type: 'sfu-connect-transport', transportId: transport.id, dtlsParameters }); waitFor(ws, 'sfu-transport-connected').then(() => cb()).catch(errb); });++ transport.on('produce', ({ kind, rtpParameters }, cb, errb) => { send({ type: 'sfu-produce', transportId: transport.id, kind, rtpParameters }); waitFor(ws, 'sfu-produced').then((m) => cb({ id: m.id })).catch(errb); });++ const track = sess.stream.getVideoTracks()[0];++ if (!track) throw new Error('camera: no video track');++ const opts = typeof sfuVideoProduceOptions === 'function' ? sfuVideoProduceOptions(device, track) : { track, stopTracks: false };++ try { sess.producer = await transport.produce(opts); } catch (err) {++ if (!opts.codec) throw err;++ delete opts.codec;++ sess.producer = await transport.produce(opts);++ }++ if (typeof applyVideoSenderPrefs === 'function') await applyVideoSenderPrefs(sess.producer).catch(() => {});++ }++++ /** A dropped camera socket comes back (three tries, a few seconds apart); the screen share is never touched. */++ function retry(sess) {++ if (sess.closing || sess.retries >= 3) return;++ sess.retries += 1;++ try { sess.transport && sess.transport.close(); } catch { /* */ }++ setTimeout(() => { if (!sess.closing) publish(sess).catch((e) => console.warn('[Camera PiP] reconnect failed:', e.message)); }, 2000 * sess.retries);++ }++++ /**++ * The camera's slot and its live stream, before anything is captured into it: true when the camera can go out on its++ * own. broadcast.js asks first and draws the camera into the screen share as before when this is false, so a slot or++ * a go-live that fails never costs the stream its camera.++ */++ async function prepare(ss) {++ const mainId = ss && ss.streamData && ss.streamData.id;++ if (!mainId) return false;++ let sess = sessions.get(mainId);++ if (sess && !sess.closing && sess.camStreamId) return true;++ if (!sess || sess.closing) {++ sess = { camStreamId: null, camMsid: null, ws: null, transport: null, producer: null, stream: null, closing: false, retries: 0 };++ sessions.set(mainId, sess);++ }++ try {++ const title = ss.streamData.title || 'Stream';++ sess.camMsid = await cameraSlot(ss.streamData.managed_stream_id, title);++ if (sess.closing) return false;++ const made = await api('/streams', { method: 'POST', body: { title: `${title} · camera`, protocol: 'webrtc', managed_stream_id: sess.camMsid } });++ const live = made && (made.stream || made);++ sess.camStreamId = live && live.id;++ if (!sess.camStreamId) throw new Error('the camera stream did not start');++ // Stopped while it was starting: stop() could not end a stream it did not know yet.++ if (sess.closing) { api(`/streams/${sess.camStreamId}`, { method: 'DELETE' }).catch(() => {}); return false; }++ return true;++ } catch (err) {++ console.warn('[Camera PiP] no stream of its own for the camera:', err.message);++ if (sessions.get(mainId) === sess) sessions.delete(mainId);++ return false;++ }++ }++++ /** Publish the camera into its stream (prepared first if it was not), or swap its track when it already goes out. */++ async function start(ss, camStream) {++ const mainId = ss && ss.streamData && ss.streamData.id;++ if (!mainId || !camStream) return false;++ let sess = sessions.get(mainId);++ if (!sess || sess.closing || !sess.camStreamId) {++ if (!(await prepare(ss))) return false;++ sess = sessions.get(mainId);++ }++ if (sess.producer) {++ // Already out (a camera switch, the camera turned back on): the same stream, the new track.++ const track = camStream.getVideoTracks()[0];++ const old = sess.stream;++ sess.stream = camStream;++ try { if (track) await sess.producer.replaceTrack({ track }); } catch (e) { console.warn('[Camera PiP] track swap failed:', e.message); }++ if (old && old !== camStream) old.getTracks().forEach((t) => t.stop());++ return true;++ }++ sess.stream = camStream;++ try {++ await publish(sess);++ console.log(`[Camera PiP] camera live as stream ${sess.camStreamId} on slot ${sess.camMsid}`);++ return true;++ } catch (err) {++ console.warn('[Camera PiP] the camera could not go out on its own:', err.message);++ if (typeof toast === 'function') toast(`Your camera could not go live as its own stream (${err.message}). The screen share carries on.`, 'error');++ stop(mainId);++ return false;++ }++ }++++ function stop(mainId) {++ const sess = sessions.get(mainId);++ if (!sess) return;++ sess.closing = true;++ sessions.delete(mainId);++ try { sess.producer && sess.producer.close(); } catch { /* */ }++ try { sess.transport && sess.transport.close(); } catch { /* */ }++ try { sess.ws && sess.ws.close(); } catch { /* */ }++ try { sess.stream && sess.stream.getTracks().forEach((t) => t.stop()); } catch { /* */ }++ if (sess.camStreamId) api(`/streams/${sess.camStreamId}`, { method: 'DELETE' }).catch(() => {});++ }++++ window.OVCamPip = { wanted, prepare, start, stop, placeOf, _sessions: sessions };++})();@@@ -447,6 +447,14 @@ function _wsRenderPanel() {</div></div><div id="bc-ws-screen-pip-opts" style="${p.screenCam ? '' : 'display:none'}">++ <div class="form-group" style="margin:6px 0 0">++ <label style="font-size:0.78rem" for="bc-ws-screen-cam-mode">How the camera goes out</label>++ <select id="bc-ws-screen-cam-mode" class="form-input form-input-sm" onchange="_wsMarkDirty()">++ <option value="stream"${(p.screenCamMode || 'stream') === 'stream' ? ' selected' : ''}>Its own stream: its own VOD, viewers move or hide it</option>++ <option value="overlay"${p.screenCamMode === 'overlay' ? ' selected' : ''}>Drawn into the screen share: restreams show it too</option>++ </select>++ <small class="muted" style="font-size:0.74rem">As its own stream, the camera goes live on a slot of its own, shown over this one where you put it below. Twitch, YouTube and other restreams get the screen only.</small>++ </div><div class="bc-ws-row" style="margin-top:6px"><div class="form-group" style="margin:0;flex:1"><label style="font-size:0.78rem">Position</label>@@@ -1553,9 +1561,11 @@ function _wsSyncScreenShareHiddenDefaults(browserMode) {// Sync PiP position/size to broadcastState for use in _startPipCompositeconst pipPos = document.getElementById('bc-ws-screen-pip-pos')?.value || 'bottom-right';const pipSize = document.getElementById('bc-ws-screen-pip-size')?.value || 'small';++ const camMode = document.getElementById('bc-ws-screen-cam-mode')?.value || 'stream';if (typeof broadcastState !== 'undefined') {broadcastState.settings.screenPipPos = pipPos;broadcastState.settings.screenPipSize = pipSize;++ broadcastState.settings.screenCamMode = camMode; // broadcast-cam-pip.js: its own stream, or drawn in}}@@@ -1707,6 +1717,7 @@ async function _wsSaveAll() {screenSysAudio: document.getElementById('bc-ws-screen-sysaudio')?.checked ?? true,screenPipPos: document.getElementById('bc-ws-screen-pip-pos')?.value || 'bottom-right',screenPipSize: document.getElementById('bc-ws-screen-pip-size')?.value || 'small',++ screenCamMode: document.getElementById('bc-ws-screen-cam-mode')?.value || 'stream',screenResolution: document.getElementById('bc-ws-screen-resolution')?.value || '1080',screenFps: document.getElementById('bc-ws-screen-fps')?.value || '30',screenBitrate: parseInt(document.getElementById('bc-ws-screen-bitrate')?.value) || 3000,@@@ -2126,6 +2126,7 @@ function cleanupStream(streamId) {stopTrackKeepalive(streamId);ss._suppressRecovery = true;_cleanupComposite(ss);++ if (window.OVCamPip) OVCamPip.stop(streamId); // the camera's own stream ends with this oneif (ss._screenStream) { ss._screenStream.getTracks().forEach(t => t.stop()); ss._screenStream = null; }if (ss.localStream) { ss.localStream.getTracks().forEach(t => t.stop()); ss.localStream = null; }_teardownAudioMixer(ss);@@@ -2874,7 +2875,17 @@ async function startMediaCapture(streamId, opts = {}) {}ss._cameraOverlayStream = camStream;-- if (camStream) {++ if (camStream && window.OVCamPip && OVCamPip.wanted(ss) && await OVCamPip.prepare(ss)) {++ // The camera as a stream of its own (broadcast-cam-pip.js): the screen share goes out alone, and the camera++ // goes live on its own slot, this slot's picture-in-picture, with its own VOD. When its slot or its++ // go-live fails (prepare), it is drawn into the screen share below, as before.++ const combined = new MediaStream();++ screenStream.getVideoTracks().forEach(t => combined.addTrack(t));++ if (finalAudioTrack) combined.addTrack(finalAudioTrack);++ ss.localStream = combined;++ ss._cameraOverlayStream = null; // OVCamPip owns it now, and stops it++ OVCamPip.start(ss, camStream);++ } else if (camStream) {_startPipComposite(ss, screenStream, screenTrack, camStream, finalAudioTrack, res);} else {// Camera not available — screen-only@@@ -2884,7 +2895,8 @@ async function startMediaCapture(streamId, opts = {}) {ss.localStream = combined;}} else {-- // Screen share without camera overlay++ // Screen share without camera overlay (a camera that was its own stream ends with it)++ if (window.OVCamPip) OVCamPip.stop(streamId);const combined = new MediaStream();screenStream.getVideoTracks().forEach(t => combined.addTrack(t));if (finalAudioTrack) combined.addTrack(finalAudioTrack);@@@ -1257,6 +1257,21 @@ async function setPipsForManagedStream(managedStreamId, pips) {return list;}++/**++ * Slots live right now as a PIP over another live stream of their own streamer (a screen share's camera,++ * public/js/broadcast-cam-pip.js): the live list leaves them out, since they show inside that stream. A co-host's stream++ * shown as a PIP is not among them: it is someone else's channel and keeps its own place.++ */++async function getLiveOwnPipSourceSlots() {++ try {++ const rows = await all(`SELECT DISTINCT p.source_msid FROM stream_pips p++ JOIN managed_streams src ON src.id = p.source_msid++ JOIN streams host ON host.managed_stream_id = p.managed_stream_id AND host.is_live = 1++ WHERE host.user_id = src.user_id`);++ return new Set(rows.map((r) => Number(r.source_msid)));++ } catch { return new Set(); }++}++/** A person's slots, as PIP sources: id, title, slug, and whether each is live now. */async function getPipSourceSlots(userId) {try {@@@ -3657,7 +3672,7 @@ module.exports = {getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,updateManagedStream, deleteManagedStream,getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,-- getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, MAX_PIPS,++ getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, getLiveOwnPipSourceSlots, MAX_PIPS,countManagedStreamsByUser, getManagedStreamLimit,isValidManagedStreamSlug, isManagedStreamSlugTaken,ensureStreamerRoleOnFeed,@@@ -1143,7 +1143,9 @@ router.get('/', optionalAuth, async (req, res) => {// the poll period so a card can never look stale for a whole cycle.res.set('Cache-Control', 'public, max-age=5');const restreamManager = require('./restream-manager');-- const streams = await db.getLiveStreams();++ // A streamer's camera that is the PIP of their own live stream shows inside it, not as a stream of its own.++ const insidePip = await db.getLiveOwnPipSourceSlots();++ const streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id))));const channelMap = await db.getChannelsByUserIds(streams.map(s => s.user_id)); // one query, not Nconst enriched = await Promise.all(streams.map(async (s) => {const channel = channelMap[s.user_id] || null;nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++/**++ * A screen share's camera as a stream of its own (owner, 2026-10-26: "when a browser streamer uses the PIP feature for++ * their camera, the PIP camera should actually be using it's own independent stream … own vods"). public/js/++ * broadcast-cam-pip.js, run here in a VM with a stub api():++ * - the camera's slot: an idle one of the streamer's own already listed as this slot's PIP, else a new "<title> camera"++ * slot added as a PIP where the screen share settings put the camera (the newest camera takes the last place when++ * the list is full);++ * - it goes live on that slot; if it cannot, the camera stream it started is ended and the streamer is told;++ * - broadcast.js sends the camera out on its own when the setting says so (the default) and the stream has a slot, and++ * ends it with the stream or when the camera is turned off.++ *++ * node test/broadcast-cam-pip.test.js++ */++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');++const SRC = read('public/js/broadcast-cam-pip.js');++++function load({ pips = [], max = 3, settings = {}, failSocket = true, onStreamPost = null, fail = null } = {}) {++ const calls = [];++ const toasts = [];++ let nextSlot = 77;++ const api = async (p, opts = {}) => {++ calls.push([opts.method || 'GET', p, opts.body ? JSON.parse(JSON.stringify(opts.body)) : null]); // a copy in this realm (the VM's objects compare unequal)++ if (p.endsWith('/pips') && !opts.method) return { pips, max };++ if (p.endsWith('/pips') && opts.method === 'PUT') return { pips: opts.body.pips, max };++ if (p === '/streams/managed' && opts.method === 'POST') { if (fail === 'slot') throw new Error('You have reached your slot limit'); return { managed_stream: { id: nextSlot++ } }; }++ if (p === '/streams' && opts.method === 'POST') { if (onStreamPost) onStreamPost(); if (fail === 'go-live') return {}; return { stream: { id: 9001 } }; }++ if (opts.method === 'DELETE') return { ok: true };++ throw new Error(`unexpected ${p}`);++ };++ class FakeSocket {++ constructor(url) { this.url = url; this.listeners = {}; calls.push(['WS', url]); setTimeout(() => (this.listeners[failSocket ? 'error' : 'open'] || []).forEach((f) => f()), 5); }++ addEventListener(t, f) { (this.listeners[t] = this.listeners[t] || []).push(f); }++ send() {} close() {}++ }++ const window = { location: { protocol: 'https:', host: 'openvibe.live' } };++ const ctx = vm.createContext({++ window, api, WebSocket: FakeSocket, console: { log() {}, warn() {} }, setTimeout, clearTimeout,++ localStorage: { getItem: () => 'tok_abc' }, toast: (m, k) => toasts.push([m, k]),++ currentUser: { username: 'Goosely' }, broadcastState: { settings }, encodeURIComponent,++ });++ vm.runInContext(SRC, ctx);++ return { cam: window.OVCamPip, calls, toasts };++}++const track = () => ({ stopped: false, stop() { this.stopped = true; } });++const camStream = () => { const t = track(); return { t, getVideoTracks: () => [t], getTracks: () => [t] }; };++const ss = (msid = 10) => ({ streamData: { id: 600, managed_stream_id: msid, title: 'Coding' } });++const tick = () => new Promise((r) => setTimeout(r, 30));++++(async () => {++ // Where it starts: the screen share settings' corner and size, as fractions (16:9, 0.02 from the edges).++ {++ const { cam } = load();++ assert.deepStrictEqual({ ...cam.placeOf('bottom-right', 'small') }, { w: 0.18, x: 0.8, y: 1 - 0.18 * 9 / 16 - 0.02 });++ assert.deepStrictEqual({ ...cam.placeOf('top-left', 'large') }, { w: 0.35, x: 0.02, y: 0.02 });++ assert.strictEqual(cam.wanted(ss()), true, 'its own stream by default');++ assert.strictEqual(cam.wanted({ streamData: { id: 1, managed_stream_id: null } }), false, 'not without a slot');++ assert.strictEqual(load({ settings: { screenCamMode: 'overlay' } }).cam.wanted(ss()), false, '"drawn into the screen" keeps the composite');++ }++++ // An idle slot of the streamer's own already listed as a PIP is the camera's.++ {++ const pips = [{ source_msid: 12, username: 'cohost', live: false, defaults: { x: 0, y: 0, w: 0.2 }, muted: true },++ { source_msid: 11, username: 'goosely', live: true, defaults: { x: 0, y: 0, w: 0.2 }, muted: true },++ { source_msid: 13, username: 'goosely', live: false, defaults: { x: 0.5, y: 0.5, w: 0.25 }, muted: true }];++ const { cam, calls, toasts } = load({ pips });++ const s = camStream();++ assert.strictEqual(await cam.start(ss(), s), false, 'the fake socket fails');++ assert.deepStrictEqual(calls.filter((c) => c[0] !== 'WS').map((c) => [c[0], c[1], c[2] ? c[2].managed_stream_id : undefined]), [++ ['GET', '/streams/managed/10/pips', undefined],++ ['POST', '/streams', 13],++ ['DELETE', '/streams/9001', undefined],++ ], "its own idle PIP slot (not the live one, not someone else's); no new slot; the camera stream it started is ended when it cannot publish");++ assert.match(calls.find((c) => c[0] === 'WS')[1], /^wss:\/\/openvibe\.live\/ws\/broadcast\?token=tok_abc&streamId=9001&role=broadcaster$/);++ assert.ok(s.t.stopped, 'the camera is let go');++ assert.match(toasts[0][0], /could not go live as its own stream .* The screen share carries on\./);++ assert.strictEqual(cam._sessions.size, 0);++ }++++ // None: a new "<title> camera" slot, added as a PIP where the settings put the camera.++ {++ const pips = [{ source_msid: 12, username: 'cohost', live: false, defaults: { x: 0.02, y: 0.02, w: 0.2 }, muted: false }];++ const { cam, calls } = load({ pips, settings: { screenPipPos: 'top-right', screenPipSize: 'medium' } });++ await cam.start(ss(), camStream());++ const made = calls.find((c) => c[1] === '/streams/managed');++ assert.deepStrictEqual(made[2], { title: 'Coding camera', protocol: 'webrtc', streaming_method: 'browser' });++ const put = calls.find((c) => c[0] === 'PUT');++ assert.deepStrictEqual(put[2].pips.map((p) => [p.source_msid, p.w, p.muted]), [[12, 0.2, false], [77, 0.25, true]], 'the PIPs it had, then the camera');++ assert.deepStrictEqual([put[2].pips[1].x, put[2].pips[1].y], [1 - 0.25 - 0.02, 0.02], 'top right, medium');++ assert.deepStrictEqual(calls.find((c) => c[1] === '/streams')[2], { title: 'Coding · camera', protocol: 'webrtc', managed_stream_id: 77 });++ }++++ // Its slot or its go-live fails: prepare says false, nothing is left half started (no socket, no stream to end), and++ // broadcast.js draws the camera into the screen share as before.++ for (const fail of ['slot', 'go-live']) {++ const { cam, calls } = load({ fail });++ assert.strictEqual(await cam.prepare(ss()), false, `${fail} refused`);++ assert.strictEqual(cam._sessions.size, 0);++ assert.ok(!calls.some((c) => c[0] === 'WS' || c[0] === 'DELETE'), `${fail}: nothing to undo`);++ }++++ // A full list: the newest camera takes the last place.++ {++ const pips = [1, 2, 3].map((i) => ({ source_msid: 20 + i, username: 'other', live: false, defaults: { x: 0, y: 0, w: 0.2 }, muted: true }));++ const { cam, calls } = load({ pips });++ await cam.start(ss(), camStream());++ assert.deepStrictEqual(calls.find((c) => c[0] === 'PUT')[2].pips.map((p) => p.source_msid), [21, 22, 77]);++ }++++ // Stopped while it was starting: early, no camera stream is started at all; during the go-live request, the stream++ // it then started is ended.++ {++ const early = load({ failSocket: false });++ const p1 = early.cam.start(ss(), camStream());++ early.cam.stop(600);++ assert.strictEqual(await p1, false);++ assert.ok(!early.calls.some((c) => c[1] === '/streams' && c[0] === 'POST'), 'stopped early: nothing goes live');++ let late = null;++ late = load({ failSocket: false, onStreamPost: () => late.cam.stop(600) });++ assert.strictEqual(await late.cam.start(ss(), camStream()), false);++ await tick();++ assert.ok(late.calls.some((c) => c[0] === 'DELETE' && c[1] === '/streams/9001'), 'stopped during go-live: never left live');++ assert.ok(!late.calls.some((c) => c[0] === 'WS'), 'and never published');++ }++++ // broadcast.js: the camera goes out on its own when wanted, the composite otherwise; it ends with the stream.++ {++ const b = read('public/js/broadcast.js');++ assert.ok(/if \(camStream && window\.OVCamPip && OVCamPip\.wanted\(ss\) && await OVCamPip\.prepare\(ss\)\) \{[\s\S]{0,900}OVCamPip\.start\(ss, camStream\);\n\s+\} else if \(camStream\) \{\n\s+_startPipComposite\(/.test(b),++ 'its own stream when its slot and go-live are ready, drawn in as before otherwise');++ assert.ok(/\/\/ Screen share without camera overlay \(a camera that was its own stream ends with it\)\n\s+if \(window\.OVCamPip\) OVCamPip\.stop\(streamId\);/.test(b), 'turning the camera off ends its stream');++ assert.ok(/_cleanupComposite\(ss\);\n\s+if \(window\.OVCamPip\) OVCamPip\.stop\(streamId\);/.test(b), 'ending the stream ends the camera');++ const feats = JSON.parse(read('public/features.json')).features.broadcast.js;++ assert.ok(feats.indexOf('/js/broadcast-cam-pip.js') === feats.indexOf('/js/broadcast.js') + 1, 'loads right after broadcast.js (its helpers)');++ const ws = read('public/js/broadcast-workspace.js');++ assert.ok(ws.includes('id="bc-ws-screen-cam-mode"') && ws.includes("screenCamMode: document.getElementById('bc-ws-screen-cam-mode')?.value || 'stream',"), 'the choice is in the screen share settings and saved');++ }++++ console.log('camera PiP as its own stream: slot, go-live, failure and stop paths, and broadcast.js wiring all pass');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -259,6 +259,21 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mutconsole.log('OK O: the viewer draws every live PIP, polls, and plays WebRTC and RTMP sources');}++// ── P: the live list: a streamer's camera that is the PIP of their own live stream shows inside it, not as a tile ──++{++ // Slot 10 (owner, live as 502) lists 11 (owner's webcam, live as 500) and 12 (someone else's, live as 501).++ const app = express();++ app.use('/api/streams', require('../server/streaming/routes'));++ const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });++ try {++ const ids = async () => (await (await fetch(`http://127.0.0.1:${server.address().port}/api/streams`)).json()).streams.map((x) => Number(x.id)).sort();++ assert.deepStrictEqual(await ids(), [501, 502], "the owner's webcam (500) is inside the screen share; the co-host (501) keeps a tile");++ await db.run('UPDATE streams SET is_live = 0 WHERE id = 502');++ assert.deepStrictEqual(await ids(), [500, 501], 'once the screen share ends, the webcam is a stream like any other');++ console.log("OK P: the live list leaves out a streamer's own camera as long as it is the PIP of their live stream");++ } finally { server.close(); }++}++console.log('✅ PiP camera slot test passed');process.exit(0);})().catch((e) => { console.error(e); process.exit(1); });
@@ -94,6 +94,18 @@ live, and appears within 30 seconds when that stream goes live after the page op
@@ -94,6 +94,18 @@ live, and appears within 30 seconds when that stream goes live after the page op
**Limits:** an 18+ stream shows only over another 18+ stream, and a banned streamer's slot shows nowhere.
**Limits:** an 18+ stream shows only over another 18+ stream, and a banned streamer's slot shows nowhere.
+**Your camera during a screen share** goes out as a stream of its own by default (screen share settings, **How the
+camera goes out**):
+- It goes live on one of your slots: the first of your idle slots already listed as a PIP here, else a new
+ "<title> camera" slot, added at the corner and size you chose.
+- It gets its own VOD.
+- Viewers can move it or hide it.
+- It plays over the screen share's VOD where it was.
+- It ends with the screen share, or when you turn the camera off.
+
+Restreams to Twitch, YouTube and the like get the screen only. Choose **Drawn into the screen share** to paint the
+camera into the picture as before, so restreams show it too.
+
API: `GET`/`PUT /api/streams/managed/:id/pips` (`{ pips: [{ source_msid, x, y, w, muted }] }`, fractions of the
API: `GET`/`PUT /api/streams/managed/:id/pips` (`{ pips: [{ source_msid, x, y, w, muted }] }`, fractions of the
player), `GET /api/streams/pip-sources?username=` (a streamer's slots), and `GET /api/streams/:id/pips` (a live
player), `GET /api/streams/pip-sources?username=` (a streamer's slots), and `GET /api/streams/:id/pips` (a live
stream's PIPs now). The stream objects carry `pip_overlays`.
stream's PIPs now). The stream objects carry `pip_overlays`.
@@ -214,6 +214,7 @@
@@ -214,6 +214,7 @@
"/js/call.js",
"/js/call.js",
"/js/broadcast-workspace.js",
"/js/broadcast-workspace.js",
"/js/broadcast.js",
"/js/broadcast.js",
+ "/js/broadcast-cam-pip.js",
"/js/broadcast-studio.js"
"/js/broadcast-studio.js"
]
]
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
+/* ───────────────────────────────────────────────────────────────
+ broadcast-cam-pip.js — a screen share's camera as a stream of its own (owner, 2026-10-26: "when a browser streamer
+ uses the PIP feature for their camera, the PIP camera should actually be using it's own independent stream … own
+ vods").
+
+ Instead of painting the camera into the screen share (_startPipComposite), the page publishes it to one of the
+ streamer's slots, which is the screen share slot's picture-in-picture (stream_pips). So the camera:
+ - is a live stream of its own: viewers move, resize, mute or hide it over the screen share (stream-pip.js);
+ - is recorded to its own VOD by the server, like any WebRTC stream (recorder.reconcileLiveRecordings);
+ - plays over the screen share's VOD again, where it was (streaming/pip-history.js, openvibe.video).
+ Its slot: the first of the streamer's own slots the screen share slot already lists as a PIP and that is not live,
+ else a new "<title> camera" slot, added as a PIP where the screen share settings put the camera. Restreams (Twitch,
+ YouTube …) get the screen share only; "Drawn into the screen" keeps the old composite for them.
+
+ OVCamPip.wanted(ss) → whether this stream's camera goes out on its own (the setting, a slot, the module)
+ OVCamPip.prepare(ss) → its slot and live stream, before capture; false → broadcast.js draws it in as before
+ OVCamPip.start(ss, camStream) → publish it (or swap its track when already out); resolves true when live
+ OVCamPip.stop(mainStreamId) → end the camera stream and stop its tracks
+ It talks to /ws/broadcast as a broadcaster, like broadcast.js's _handleSfuProduceRequest, with that file's helpers.
+ ─────────────────────────────────────────────────────────────── */
+(function () {
+ 'use strict';
+
+ const SIZES = { small: 0.18, medium: 0.25, large: 0.35 };
+ const MARGIN = 0.02;
+ const sessions = new Map(); // main stream id → { camStreamId, camMsid, ws, transport, producer, stream, closing, retries }
+
+ /** The screen share settings' corner and size as the PIP's place (fractions of the player, the overlay 16:9). */
+ function placeOf(pos, size) {
+ const w = SIZES[size] || SIZES.small;
+ const h = w * 9 / 16;
+ const right = 1 - w - MARGIN, bottom = 1 - h - MARGIN;
+ const p = String(pos || 'bottom-right');
+ return { w, x: p.endsWith('left') ? MARGIN : right, y: p.startsWith('top') ? MARGIN : bottom };
+ }
+
+ function wanted(ss) {
+ const mode = (typeof broadcastState !== 'undefined' && broadcastState.settings && broadcastState.settings.screenCamMode) || 'stream';
+ return mode !== 'overlay' && !!(ss && ss.streamData && ss.streamData.managed_stream_id);
+ }
+
+ /** The camera's slot: an idle one of the streamer's own already listed as a PIP here, else a new one, added as a PIP. */
+ async function cameraSlot(mainMsid, title) {
+ const me = (typeof currentUser !== 'undefined' && currentUser && currentUser.username) || '';
+ const listed = await api(`/streams/managed/${mainMsid}/pips`);
+ const pips = (listed && listed.pips) || [];
+ const own = pips.find((p) => p.username && p.username.toLowerCase() === me.toLowerCase() && !p.live);
+ if (own) return own.source_msid;
+ const created = await api('/streams/managed', {
+ method: 'POST',
+ body: { title: `${String(title || 'Stream').slice(0, 80)} camera`, protocol: 'webrtc', streaming_method: 'browser' },
+ });
+ const msid = created && created.managed_stream && created.managed_stream.id;
+ if (!msid) throw new Error('could not make a camera slot');
+ const s = (typeof broadcastState !== 'undefined' && broadcastState.settings) || {};
+ const rows = pips.map((p) => ({ source_msid: p.source_msid, x: p.defaults.x, y: p.defaults.y, w: p.defaults.w, muted: p.muted }));
+ if (rows.length >= ((listed && listed.max) || 3)) rows.pop(); // the newest camera takes the last place
+ rows.push({ source_msid: msid, ...placeOf(s.screenPipPos, s.screenPipSize), muted: true });
+ await api(`/streams/managed/${mainMsid}/pips`, { method: 'PUT', body: { pips: rows } });
+ return msid;
+ }
+
+ function waitFor(ws, type, ms = 15000) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { ws.removeEventListener('message', on); reject(new Error(`camera: no ${type}`)); }, ms);
+ function on(ev) {
+ let m; try { m = JSON.parse(ev.data); } catch { return; }
+ if (m.type === type) { clearTimeout(timer); ws.removeEventListener('message', on); resolve(m); }
+ else if (m.type === 'sfu-error') { clearTimeout(timer); ws.removeEventListener('message', on); reject(new Error(m.error || 'camera: SFU error')); }
+ }
+ ws.addEventListener('message', on);
+ });
+ }
+
+ /** Broadcaster socket for the camera stream, and its video producer in the SFU. */
+ async function publish(sess) {
+ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
+ const token = localStorage.getItem('token') || '';
+ const ws = new WebSocket(`${proto}://${window.location.host}/ws/broadcast?token=${encodeURIComponent(token)}&streamId=${sess.camStreamId}&role=broadcaster`);
+ sess.ws = ws;
+ await new Promise((res, rej) => { ws.addEventListener('open', res, { once: true }); ws.addEventListener('error', () => rej(new Error('camera: socket failed')), { once: true }); });
+ ws.addEventListener('close', () => { if (!sess.closing) retry(sess); });
+ const send = (m) => ws.readyState === 1 && ws.send(JSON.stringify(m));
+ const mod = await loadRobotStreamerMediasoup();
+ const Device = mod.Device || mod.default?.Device;
+ const device = new Device();
+ send({ type: 'sfu-get-capabilities' });
+ const caps = await waitFor(ws, 'sfu-capabilities');
+ await device.load({ routerRtpCapabilities: caps.rtpCapabilities });
+ send({ type: 'sfu-create-transport' });
+ const tp = await waitFor(ws, 'sfu-transport-created');
+ const transport = device.createSendTransport({ id: tp.id, iceParameters: tp.iceParameters, iceCandidates: tp.iceCandidates,
+ dtlsParameters: tp.dtlsParameters, iceServers: tp.iceServers || [{ urls: 'stun:stun.l.google.com:19302' }] });
+ sess.transport = transport;
+ transport.on('connect', ({ dtlsParameters }, cb, errb) => { send({ type: 'sfu-connect-transport', transportId: transport.id, dtlsParameters }); waitFor(ws, 'sfu-transport-connected').then(() => cb()).catch(errb); });
+ transport.on('produce', ({ kind, rtpParameters }, cb, errb) => { send({ type: 'sfu-produce', transportId: transport.id, kind, rtpParameters }); waitFor(ws, 'sfu-produced').then((m) => cb({ id: m.id })).catch(errb); });
+ const track = sess.stream.getVideoTracks()[0];
+ if (!track) throw new Error('camera: no video track');
+ const opts = typeof sfuVideoProduceOptions === 'function' ? sfuVideoProduceOptions(device, track) : { track, stopTracks: false };
+ try { sess.producer = await transport.produce(opts); } catch (err) {
+ if (!opts.codec) throw err;
+ delete opts.codec;
+ sess.producer = await transport.produce(opts);
+ }
+ if (typeof applyVideoSenderPrefs === 'function') await applyVideoSenderPrefs(sess.producer).catch(() => {});
+ }
+
+ /** A dropped camera socket comes back (three tries, a few seconds apart); the screen share is never touched. */
+ function retry(sess) {
+ if (sess.closing || sess.retries >= 3) return;
+ sess.retries += 1;
+ try { sess.transport && sess.transport.close(); } catch { /* */ }
+ setTimeout(() => { if (!sess.closing) publish(sess).catch((e) => console.warn('[Camera PiP] reconnect failed:', e.message)); }, 2000 * sess.retries);
+ }
+
+ /**
+ * The camera's slot and its live stream, before anything is captured into it: true when the camera can go out on its
+ * own. broadcast.js asks first and draws the camera into the screen share as before when this is false, so a slot or
+ * a go-live that fails never costs the stream its camera.
+ */
+ async function prepare(ss) {
+ const mainId = ss && ss.streamData && ss.streamData.id;
+ if (!mainId) return false;
+ let sess = sessions.get(mainId);
+ if (sess && !sess.closing && sess.camStreamId) return true;
+ if (!sess || sess.closing) {
+ sess = { camStreamId: null, camMsid: null, ws: null, transport: null, producer: null, stream: null, closing: false, retries: 0 };
+ sessions.set(mainId, sess);
+ }
+ try {
+ const title = ss.streamData.title || 'Stream';
+ sess.camMsid = await cameraSlot(ss.streamData.managed_stream_id, title);
+ if (sess.closing) return false;
+ const made = await api('/streams', { method: 'POST', body: { title: `${title} · camera`, protocol: 'webrtc', managed_stream_id: sess.camMsid } });
+ const live = made && (made.stream || made);
+ sess.camStreamId = live && live.id;
+ if (!sess.camStreamId) throw new Error('the camera stream did not start');
+ // Stopped while it was starting: stop() could not end a stream it did not know yet.
+ if (sess.closing) { api(`/streams/${sess.camStreamId}`, { method: 'DELETE' }).catch(() => {}); return false; }
+ return true;
+ } catch (err) {
+ console.warn('[Camera PiP] no stream of its own for the camera:', err.message);
+ if (sessions.get(mainId) === sess) sessions.delete(mainId);
+ return false;
+ }
+ }
+
+ /** Publish the camera into its stream (prepared first if it was not), or swap its track when it already goes out. */
+ async function start(ss, camStream) {
+ const mainId = ss && ss.streamData && ss.streamData.id;
+ if (!mainId || !camStream) return false;
+ let sess = sessions.get(mainId);
+ if (!sess || sess.closing || !sess.camStreamId) {
+ if (!(await prepare(ss))) return false;
+ sess = sessions.get(mainId);
+ }
+ if (sess.producer) {
+ // Already out (a camera switch, the camera turned back on): the same stream, the new track.
+ const track = camStream.getVideoTracks()[0];
+ const old = sess.stream;
+ sess.stream = camStream;
+ try { if (track) await sess.producer.replaceTrack({ track }); } catch (e) { console.warn('[Camera PiP] track swap failed:', e.message); }
+ if (old && old !== camStream) old.getTracks().forEach((t) => t.stop());
+ return true;
+ }
+ sess.stream = camStream;
+ try {
+ await publish(sess);
+ console.log(`[Camera PiP] camera live as stream ${sess.camStreamId} on slot ${sess.camMsid}`);
+ return true;
+ } catch (err) {
+ console.warn('[Camera PiP] the camera could not go out on its own:', err.message);
+ if (typeof toast === 'function') toast(`Your camera could not go live as its own stream (${err.message}). The screen share carries on.`, 'error');
+ stop(mainId);
+ return false;
+ }
+ }
+
+ function stop(mainId) {
+ const sess = sessions.get(mainId);
+ if (!sess) return;
+ sess.closing = true;
+ sessions.delete(mainId);
+ try { sess.producer && sess.producer.close(); } catch { /* */ }
+ try { sess.transport && sess.transport.close(); } catch { /* */ }
+ try { sess.ws && sess.ws.close(); } catch { /* */ }
+ try { sess.stream && sess.stream.getTracks().forEach((t) => t.stop()); } catch { /* */ }
+ if (sess.camStreamId) api(`/streams/${sess.camStreamId}`, { method: 'DELETE' }).catch(() => {});
+ }
+
+ window.OVCamPip = { wanted, prepare, start, stop, placeOf, _sessions: sessions };
+})();
@@ -447,6 +447,14 @@ function _wsRenderPanel() {
@@ -447,6 +447,14 @@ function _wsRenderPanel() {
</div>
</div>
</div>
</div>
<div id="bc-ws-screen-pip-opts" style="${p.screenCam ? '' : 'display:none'}">
<div id="bc-ws-screen-pip-opts" style="${p.screenCam ? '' : 'display:none'}">
+ <div class="form-group" style="margin:6px 0 0">
+ <label style="font-size:0.78rem" for="bc-ws-screen-cam-mode">How the camera goes out</label>
+ <select id="bc-ws-screen-cam-mode" class="form-input form-input-sm" onchange="_wsMarkDirty()">
+ <option value="stream"${(p.screenCamMode || 'stream') === 'stream' ? ' selected' : ''}>Its own stream: its own VOD, viewers move or hide it</option>
+ <option value="overlay"${p.screenCamMode === 'overlay' ? ' selected' : ''}>Drawn into the screen share: restreams show it too</option>
+ </select>
+ <small class="muted" style="font-size:0.74rem">As its own stream, the camera goes live on a slot of its own, shown over this one where you put it below. Twitch, YouTube and other restreams get the screen only.</small>
+ </div>
<div class="bc-ws-row" style="margin-top:6px">
<div class="bc-ws-row" style="margin-top:6px">
<div class="form-group" style="margin:0;flex:1">
<div class="form-group" style="margin:0;flex:1">
<label style="font-size:0.78rem">Position</label>
<label style="font-size:0.78rem">Position</label>
@@ -1553,9 +1561,11 @@ function _wsSyncScreenShareHiddenDefaults(browserMode) {
@@ -1553,9 +1561,11 @@ function _wsSyncScreenShareHiddenDefaults(browserMode) {
// Sync PiP position/size to broadcastState for use in _startPipComposite
// Sync PiP position/size to broadcastState for use in _startPipComposite
const pipPos = document.getElementById('bc-ws-screen-pip-pos')?.value || 'bottom-right';
const pipPos = document.getElementById('bc-ws-screen-pip-pos')?.value || 'bottom-right';
const pipSize = document.getElementById('bc-ws-screen-pip-size')?.value || 'small';
const pipSize = document.getElementById('bc-ws-screen-pip-size')?.value || 'small';
+ const camMode = document.getElementById('bc-ws-screen-cam-mode')?.value || 'stream';
if (typeof broadcastState !== 'undefined') {
if (typeof broadcastState !== 'undefined') {
broadcastState.settings.screenPipPos = pipPos;
broadcastState.settings.screenPipPos = pipPos;
broadcastState.settings.screenPipSize = pipSize;
broadcastState.settings.screenPipSize = pipSize;
+ broadcastState.settings.screenCamMode = camMode; // broadcast-cam-pip.js: its own stream, or drawn in
}
}
}
}
@@ -1707,6 +1717,7 @@ async function _wsSaveAll() {
@@ -1707,6 +1717,7 @@ async function _wsSaveAll() {
screenSysAudio: document.getElementById('bc-ws-screen-sysaudio')?.checked ?? true,
screenSysAudio: document.getElementById('bc-ws-screen-sysaudio')?.checked ?? true,
screenPipPos: document.getElementById('bc-ws-screen-pip-pos')?.value || 'bottom-right',
screenPipPos: document.getElementById('bc-ws-screen-pip-pos')?.value || 'bottom-right',
screenPipSize: document.getElementById('bc-ws-screen-pip-size')?.value || 'small',
screenPipSize: document.getElementById('bc-ws-screen-pip-size')?.value || 'small',
+ screenCamMode: document.getElementById('bc-ws-screen-cam-mode')?.value || 'stream',
screenResolution: document.getElementById('bc-ws-screen-resolution')?.value || '1080',
screenResolution: document.getElementById('bc-ws-screen-resolution')?.value || '1080',
screenFps: document.getElementById('bc-ws-screen-fps')?.value || '30',
screenFps: document.getElementById('bc-ws-screen-fps')?.value || '30',
screenBitrate: parseInt(document.getElementById('bc-ws-screen-bitrate')?.value) || 3000,
screenBitrate: parseInt(document.getElementById('bc-ws-screen-bitrate')?.value) || 3000,
@@ -2126,6 +2126,7 @@ function cleanupStream(streamId) {
@@ -2126,6 +2126,7 @@ function cleanupStream(streamId) {
stopTrackKeepalive(streamId);
stopTrackKeepalive(streamId);
ss._suppressRecovery = true;
ss._suppressRecovery = true;
_cleanupComposite(ss);
_cleanupComposite(ss);
+ if (window.OVCamPip) OVCamPip.stop(streamId); // the camera's own stream ends with this one
if (ss._screenStream) { ss._screenStream.getTracks().forEach(t => t.stop()); ss._screenStream = null; }
if (ss._screenStream) { ss._screenStream.getTracks().forEach(t => t.stop()); ss._screenStream = null; }
if (ss.localStream) { ss.localStream.getTracks().forEach(t => t.stop()); ss.localStream = null; }
if (ss.localStream) { ss.localStream.getTracks().forEach(t => t.stop()); ss.localStream = null; }
_teardownAudioMixer(ss);
_teardownAudioMixer(ss);
@@ -2874,7 +2875,17 @@ async function startMediaCapture(streamId, opts = {}) {
@@ -2874,7 +2875,17 @@ async function startMediaCapture(streamId, opts = {}) {
}
}
ss._cameraOverlayStream = camStream;
ss._cameraOverlayStream = camStream;
- if (camStream) {
+ if (camStream && window.OVCamPip && OVCamPip.wanted(ss) && await OVCamPip.prepare(ss)) {
+ // The camera as a stream of its own (broadcast-cam-pip.js): the screen share goes out alone, and the camera
+ // goes live on its own slot, this slot's picture-in-picture, with its own VOD. When its slot or its
+ // go-live fails (prepare), it is drawn into the screen share below, as before.
+ const combined = new MediaStream();
+ screenStream.getVideoTracks().forEach(t => combined.addTrack(t));
+ if (finalAudioTrack) combined.addTrack(finalAudioTrack);
+ ss.localStream = combined;
+ ss._cameraOverlayStream = null; // OVCamPip owns it now, and stops it
+ OVCamPip.start(ss, camStream);
+ } else if (camStream) {
_startPipComposite(ss, screenStream, screenTrack, camStream, finalAudioTrack, res);
_startPipComposite(ss, screenStream, screenTrack, camStream, finalAudioTrack, res);
} else {
} else {
// Camera not available — screen-only
// Camera not available — screen-only
@@ -2884,7 +2895,8 @@ async function startMediaCapture(streamId, opts = {}) {
@@ -2884,7 +2895,8 @@ async function startMediaCapture(streamId, opts = {}) {
ss.localStream = combined;
ss.localStream = combined;
}
}
} else {
} else {
- // Screen share without camera overlay
+ // Screen share without camera overlay (a camera that was its own stream ends with it)
+ if (window.OVCamPip) OVCamPip.stop(streamId);
const combined = new MediaStream();
const combined = new MediaStream();
screenStream.getVideoTracks().forEach(t => combined.addTrack(t));
screenStream.getVideoTracks().forEach(t => combined.addTrack(t));
if (finalAudioTrack) combined.addTrack(finalAudioTrack);
if (finalAudioTrack) combined.addTrack(finalAudioTrack);
@@ -1257,6 +1257,21 @@ async function setPipsForManagedStream(managedStreamId, pips) {
@@ -1257,6 +1257,21 @@ async function setPipsForManagedStream(managedStreamId, pips) {
return list;
return list;
}
}
+/**
+ * Slots live right now as a PIP over another live stream of their own streamer (a screen share's camera,
+ * public/js/broadcast-cam-pip.js): the live list leaves them out, since they show inside that stream. A co-host's stream
+ * shown as a PIP is not among them: it is someone else's channel and keeps its own place.
+ */
+async function getLiveOwnPipSourceSlots() {
+ try {
+ const rows = await all(`SELECT DISTINCT p.source_msid FROM stream_pips p
+ JOIN managed_streams src ON src.id = p.source_msid
+ JOIN streams host ON host.managed_stream_id = p.managed_stream_id AND host.is_live = 1
+ WHERE host.user_id = src.user_id`);
+ return new Set(rows.map((r) => Number(r.source_msid)));
+ } catch { return new Set(); }
+}
+
/** A person's slots, as PIP sources: id, title, slug, and whether each is live now. */
/** A person's slots, as PIP sources: id, title, slug, and whether each is live now. */
async function getPipSourceSlots(userId) {
async function getPipSourceSlots(userId) {
try {
try {
@@ -3657,7 +3672,7 @@ module.exports = {
@@ -3657,7 +3672,7 @@ module.exports = {
getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,
getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,
updateManagedStream, deleteManagedStream,
updateManagedStream, deleteManagedStream,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
- getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, MAX_PIPS,
+ getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, getLiveOwnPipSourceSlots, MAX_PIPS,
countManagedStreamsByUser, getManagedStreamLimit,
countManagedStreamsByUser, getManagedStreamLimit,
isValidManagedStreamSlug, isManagedStreamSlugTaken,
isValidManagedStreamSlug, isManagedStreamSlugTaken,
ensureStreamerRoleOnFeed,
ensureStreamerRoleOnFeed,
@@ -1143,7 +1143,9 @@ router.get('/', optionalAuth, async (req, res) => {
@@ -1143,7 +1143,9 @@ router.get('/', optionalAuth, async (req, res) => {
// the poll period so a card can never look stale for a whole cycle.
// the poll period so a card can never look stale for a whole cycle.
res.set('Cache-Control', 'public, max-age=5');
res.set('Cache-Control', 'public, max-age=5');
const restreamManager = require('./restream-manager');
const restreamManager = require('./restream-manager');
- const streams = await db.getLiveStreams();
+ // A streamer's camera that is the PIP of their own live stream shows inside it, not as a stream of its own.
+ const insidePip = await db.getLiveOwnPipSourceSlots();
+ const streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id))));
const channelMap = await db.getChannelsByUserIds(streams.map(s => s.user_id)); // one query, not N
const channelMap = await db.getChannelsByUserIds(streams.map(s => s.user_id)); // one query, not N
const enriched = await Promise.all(streams.map(async (s) => {
const enriched = await Promise.all(streams.map(async (s) => {
const channel = channelMap[s.user_id] || null;
const channel = channelMap[s.user_id] || null;
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+/**
+ * A screen share's camera as a stream of its own (owner, 2026-10-26: "when a browser streamer uses the PIP feature for
+ * their camera, the PIP camera should actually be using it's own independent stream … own vods"). public/js/
+ * broadcast-cam-pip.js, run here in a VM with a stub api():
+ * - the camera's slot: an idle one of the streamer's own already listed as this slot's PIP, else a new "<title> camera"
+ * slot added as a PIP where the screen share settings put the camera (the newest camera takes the last place when
+ * the list is full);
+ * - it goes live on that slot; if it cannot, the camera stream it started is ended and the streamer is told;
+ * - broadcast.js sends the camera out on its own when the setting says so (the default) and the stream has a slot, and
+ * ends it with the stream or when the camera is turned off.
+ *
+ * node test/broadcast-cam-pip.test.js
+ */
+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');
+const SRC = read('public/js/broadcast-cam-pip.js');
+
+function load({ pips = [], max = 3, settings = {}, failSocket = true, onStreamPost = null, fail = null } = {}) {
+ const calls = [];
+ const toasts = [];
+ let nextSlot = 77;
+ const api = async (p, opts = {}) => {
+ calls.push([opts.method || 'GET', p, opts.body ? JSON.parse(JSON.stringify(opts.body)) : null]); // a copy in this realm (the VM's objects compare unequal)
+ if (p.endsWith('/pips') && !opts.method) return { pips, max };
+ if (p.endsWith('/pips') && opts.method === 'PUT') return { pips: opts.body.pips, max };
+ if (p === '/streams/managed' && opts.method === 'POST') { if (fail === 'slot') throw new Error('You have reached your slot limit'); return { managed_stream: { id: nextSlot++ } }; }
+ if (p === '/streams' && opts.method === 'POST') { if (onStreamPost) onStreamPost(); if (fail === 'go-live') return {}; return { stream: { id: 9001 } }; }
+ if (opts.method === 'DELETE') return { ok: true };
+ throw new Error(`unexpected ${p}`);
+ };
+ class FakeSocket {
+ constructor(url) { this.url = url; this.listeners = {}; calls.push(['WS', url]); setTimeout(() => (this.listeners[failSocket ? 'error' : 'open'] || []).forEach((f) => f()), 5); }
+ addEventListener(t, f) { (this.listeners[t] = this.listeners[t] || []).push(f); }
+ send() {} close() {}
+ }
+ const window = { location: { protocol: 'https:', host: 'openvibe.live' } };
+ const ctx = vm.createContext({
+ window, api, WebSocket: FakeSocket, console: { log() {}, warn() {} }, setTimeout, clearTimeout,
+ localStorage: { getItem: () => 'tok_abc' }, toast: (m, k) => toasts.push([m, k]),
+ currentUser: { username: 'Goosely' }, broadcastState: { settings }, encodeURIComponent,
+ });
+ vm.runInContext(SRC, ctx);
+ return { cam: window.OVCamPip, calls, toasts };
+}
+const track = () => ({ stopped: false, stop() { this.stopped = true; } });
+const camStream = () => { const t = track(); return { t, getVideoTracks: () => [t], getTracks: () => [t] }; };
+const ss = (msid = 10) => ({ streamData: { id: 600, managed_stream_id: msid, title: 'Coding' } });
+const tick = () => new Promise((r) => setTimeout(r, 30));
+
+(async () => {
+ // Where it starts: the screen share settings' corner and size, as fractions (16:9, 0.02 from the edges).
+ {
+ const { cam } = load();
+ assert.deepStrictEqual({ ...cam.placeOf('bottom-right', 'small') }, { w: 0.18, x: 0.8, y: 1 - 0.18 * 9 / 16 - 0.02 });
+ assert.deepStrictEqual({ ...cam.placeOf('top-left', 'large') }, { w: 0.35, x: 0.02, y: 0.02 });
+ assert.strictEqual(cam.wanted(ss()), true, 'its own stream by default');
+ assert.strictEqual(cam.wanted({ streamData: { id: 1, managed_stream_id: null } }), false, 'not without a slot');
+ assert.strictEqual(load({ settings: { screenCamMode: 'overlay' } }).cam.wanted(ss()), false, '"drawn into the screen" keeps the composite');
+ }
+
+ // An idle slot of the streamer's own already listed as a PIP is the camera's.
+ {
+ const pips = [{ source_msid: 12, username: 'cohost', live: false, defaults: { x: 0, y: 0, w: 0.2 }, muted: true },
+ { source_msid: 11, username: 'goosely', live: true, defaults: { x: 0, y: 0, w: 0.2 }, muted: true },
+ { source_msid: 13, username: 'goosely', live: false, defaults: { x: 0.5, y: 0.5, w: 0.25 }, muted: true }];
+ const { cam, calls, toasts } = load({ pips });
+ const s = camStream();
+ assert.strictEqual(await cam.start(ss(), s), false, 'the fake socket fails');
+ assert.deepStrictEqual(calls.filter((c) => c[0] !== 'WS').map((c) => [c[0], c[1], c[2] ? c[2].managed_stream_id : undefined]), [
+ ['GET', '/streams/managed/10/pips', undefined],
+ ['POST', '/streams', 13],
+ ['DELETE', '/streams/9001', undefined],
+ ], "its own idle PIP slot (not the live one, not someone else's); no new slot; the camera stream it started is ended when it cannot publish");
+ assert.match(calls.find((c) => c[0] === 'WS')[1], /^wss:\/\/openvibe\.live\/ws\/broadcast\?token=tok_abc&streamId=9001&role=broadcaster$/);
+ assert.ok(s.t.stopped, 'the camera is let go');
+ assert.match(toasts[0][0], /could not go live as its own stream .* The screen share carries on\./);
+ assert.strictEqual(cam._sessions.size, 0);
+ }
+
+ // None: a new "<title> camera" slot, added as a PIP where the settings put the camera.
+ {
+ const pips = [{ source_msid: 12, username: 'cohost', live: false, defaults: { x: 0.02, y: 0.02, w: 0.2 }, muted: false }];
+ const { cam, calls } = load({ pips, settings: { screenPipPos: 'top-right', screenPipSize: 'medium' } });
+ await cam.start(ss(), camStream());
+ const made = calls.find((c) => c[1] === '/streams/managed');
+ assert.deepStrictEqual(made[2], { title: 'Coding camera', protocol: 'webrtc', streaming_method: 'browser' });
+ const put = calls.find((c) => c[0] === 'PUT');
+ assert.deepStrictEqual(put[2].pips.map((p) => [p.source_msid, p.w, p.muted]), [[12, 0.2, false], [77, 0.25, true]], 'the PIPs it had, then the camera');
+ assert.deepStrictEqual([put[2].pips[1].x, put[2].pips[1].y], [1 - 0.25 - 0.02, 0.02], 'top right, medium');
+ assert.deepStrictEqual(calls.find((c) => c[1] === '/streams')[2], { title: 'Coding · camera', protocol: 'webrtc', managed_stream_id: 77 });
+ }
+
+ // Its slot or its go-live fails: prepare says false, nothing is left half started (no socket, no stream to end), and
+ // broadcast.js draws the camera into the screen share as before.
+ for (const fail of ['slot', 'go-live']) {
+ const { cam, calls } = load({ fail });
+ assert.strictEqual(await cam.prepare(ss()), false, `${fail} refused`);
+ assert.strictEqual(cam._sessions.size, 0);
+ assert.ok(!calls.some((c) => c[0] === 'WS' || c[0] === 'DELETE'), `${fail}: nothing to undo`);
+ }
+
+ // A full list: the newest camera takes the last place.
+ {
+ const pips = [1, 2, 3].map((i) => ({ source_msid: 20 + i, username: 'other', live: false, defaults: { x: 0, y: 0, w: 0.2 }, muted: true }));
+ const { cam, calls } = load({ pips });
+ await cam.start(ss(), camStream());
+ assert.deepStrictEqual(calls.find((c) => c[0] === 'PUT')[2].pips.map((p) => p.source_msid), [21, 22, 77]);
+ }
+
+ // Stopped while it was starting: early, no camera stream is started at all; during the go-live request, the stream
+ // it then started is ended.
+ {
+ const early = load({ failSocket: false });
+ const p1 = early.cam.start(ss(), camStream());
+ early.cam.stop(600);
+ assert.strictEqual(await p1, false);
+ assert.ok(!early.calls.some((c) => c[1] === '/streams' && c[0] === 'POST'), 'stopped early: nothing goes live');
+ let late = null;
+ late = load({ failSocket: false, onStreamPost: () => late.cam.stop(600) });
+ assert.strictEqual(await late.cam.start(ss(), camStream()), false);
+ await tick();
+ assert.ok(late.calls.some((c) => c[0] === 'DELETE' && c[1] === '/streams/9001'), 'stopped during go-live: never left live');
+ assert.ok(!late.calls.some((c) => c[0] === 'WS'), 'and never published');
+ }
+
+ // broadcast.js: the camera goes out on its own when wanted, the composite otherwise; it ends with the stream.
+ {
+ const b = read('public/js/broadcast.js');
+ assert.ok(/if \(camStream && window\.OVCamPip && OVCamPip\.wanted\(ss\) && await OVCamPip\.prepare\(ss\)\) \{[\s\S]{0,900}OVCamPip\.start\(ss, camStream\);\n\s+\} else if \(camStream\) \{\n\s+_startPipComposite\(/.test(b),
+ 'its own stream when its slot and go-live are ready, drawn in as before otherwise');
+ assert.ok(/\/\/ Screen share without camera overlay \(a camera that was its own stream ends with it\)\n\s+if \(window\.OVCamPip\) OVCamPip\.stop\(streamId\);/.test(b), 'turning the camera off ends its stream');
+ assert.ok(/_cleanupComposite\(ss\);\n\s+if \(window\.OVCamPip\) OVCamPip\.stop\(streamId\);/.test(b), 'ending the stream ends the camera');
+ const feats = JSON.parse(read('public/features.json')).features.broadcast.js;
+ assert.ok(feats.indexOf('/js/broadcast-cam-pip.js') === feats.indexOf('/js/broadcast.js') + 1, 'loads right after broadcast.js (its helpers)');
+ const ws = read('public/js/broadcast-workspace.js');
+ assert.ok(ws.includes('id="bc-ws-screen-cam-mode"') && ws.includes("screenCamMode: document.getElementById('bc-ws-screen-cam-mode')?.value || 'stream',"), 'the choice is in the screen share settings and saved');
+ }
+
+ console.log('camera PiP as its own stream: slot, go-live, failure and stop paths, and broadcast.js wiring all pass');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -259,6 +259,21 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
@@ -259,6 +259,21 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
console.log('OK O: the viewer draws every live PIP, polls, and plays WebRTC and RTMP sources');
console.log('OK O: the viewer draws every live PIP, polls, and plays WebRTC and RTMP sources');
}
}
+// ── P: the live list: a streamer's camera that is the PIP of their own live stream shows inside it, not as a tile ──
+{
+ // Slot 10 (owner, live as 502) lists 11 (owner's webcam, live as 500) and 12 (someone else's, live as 501).
+ const app = express();
+ app.use('/api/streams', require('../server/streaming/routes'));
+ const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
+ try {
+ const ids = async () => (await (await fetch(`http://127.0.0.1:${server.address().port}/api/streams`)).json()).streams.map((x) => Number(x.id)).sort();
+ assert.deepStrictEqual(await ids(), [501, 502], "the owner's webcam (500) is inside the screen share; the co-host (501) keeps a tile");
+ await db.run('UPDATE streams SET is_live = 0 WHERE id = 502');
+ assert.deepStrictEqual(await ids(), [500, 501], 'once the screen share ends, the webcam is a stream like any other');
+ console.log("OK P: the live list leaves out a streamer's own camera as long as it is the PIP of their live stream");
+ } finally { server.close(); }
+}
+
console.log('✅ PiP camera slot test passed');
console.log('✅ PiP camera slot test passed');
process.exit(0);
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });