openvibers/openvibe.live Commits
The cam button: a viewer takes a still of the live stream with OpenVibe.Cam (#432)
Changes
@@@ -1010,6 +1010,20 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:.ssp-body { padding: 8px 12px; display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; }.ssp-body .ssp-k { color: #99a; }.ssp-body .ssp-v { color: #fff; text-align: right; font-variant-numeric: tabular-nums; word-break: break-word; }++/* The cam button's result overlay sits over the picture (not below it), so a phone viewer can still++ see the stream while the still is open. */++.cam-still-panel {++ position: absolute; top: 12px; right: 12px; z-index: 12;++ width: 288px; max-width: calc(100% - 24px);++ background: rgba(15,15,18,0.92); backdrop-filter: blur(6px);++ border: 1px solid rgba(255,255,255,0.12); border-radius: 10px;++ color: #eee; font-size: 0.8rem; overflow: hidden;++ box-shadow: 0 8px 28px rgba(0,0,0,0.5);++}++.cam-still-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }++.cam-still-body img { width: 100%; border-radius: 8px; display: block; }++.cam-still-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }++.cam-still-link { color: var(--accent); word-break: break-all; }.weather-current {display: flex; align-items: center; gap: 10px;}@@@ -68,6 +68,14 @@</div><div class="ssp-body" id="stream-stats-body"></div></div>++ <!-- Cam still overlay (the cam button's result: link + picture, with an optional chat post) -->++ <div class="cam-still-panel" id="cam-still-panel" style="display:none">++ <div class="ssp-header">++ <span><i class="fa-solid fa-camera"></i> Still</span>++ <button class="ssp-close" id="btn-cam-close" title="Close"><i class="fa-solid fa-xmark"></i></button>++ </div>++ <div class="cam-still-body" id="cam-still-body"></div>++ </div><div class="fullscreen-chat-overlay" id="fullscreen-chat-overlay" aria-hidden="true"><div class="fullscreen-chat-messages" id="fullscreen-chat-messages"></div><div class="fullscreen-chat-chrome" id="fullscreen-chat-chrome">@@@ -120,6 +128,7 @@<div class="ch-info-bar-right"><span class="ch-follower-count" id="ch-follower-count">0 followers</span><a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>++ <button class="btn btn-outline" id="btn-cam" title="Take a still" style="display:none"><i class="fa-solid fa-camera"></i> Still</button><button class="btn btn-follow" id="ch-btn-follow"><i class="fa-solid fa-heart"></i> Follow</button>@@@ -928,6 +928,8 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionIdocument.getElementById('ch-live-area').style.display = 'none';document.getElementById('ch-offline-area').style.display = '';_channelFooter(false);++ _wireCamButton();++ _updateCamButton(null);_placeBelowFold();// Populate offline header@@@ -1687,6 +1689,105 @@ function switchToLiveStream(username, streamId, btn) {.finally(() => { if (myToken === _streamSwitchToken) showStreamSwitchOverlay(false); });}++// ── Cam button: a signed-in viewer takes a still of the live stream ─────────++// The result is an overlay over the picture (not a block below it), so a phone viewer can++// keep watching while the still is open (controls-keep-video-in-view).++let _camBusy = false;++++function _camSlotFor(stream) {++ if (!stream) return null;++ return stream.managed_stream_slug || (stream.managed_stream_id != null ? String(stream.managed_stream_id) : null);++}++++function _updateCamButton(stream) {++ const btn = document.getElementById('btn-cam');++ if (!btn) return;++ const signedIn = !!(typeof currentUser !== 'undefined' && currentUser);++ const slot = _camSlotFor(stream);++ btn.style.display = (signedIn && stream && stream.is_live && slot) ? '' : 'none';++}++++window.addEventListener('openvibe-auth-changed', () => {++ const stream = (typeof currentStreamData !== 'undefined' && currentStreamData) || null;++ _updateCamButton(stream && stream.is_live ? stream : null);++});++++function _camShowPanel(html) {++ const panel = document.getElementById('cam-still-panel');++ if (!panel) return;++ document.getElementById('cam-still-body').innerHTML = html;++ const stats = document.getElementById('stream-stats-panel');++ if (stats) stats.style.display = 'none';++ panel.style.display = '';++}++++function _camStillImageAndLink(data) {++ const still = data && data.still;++ const share = data && data.share;++ const img = still && (still.pics_direct || still.pics_url);++ const url = (share && share.url) || (still && still.pics_url);++ return { img, url, share };++}++++async function takeCamStill() {++ if (_camBusy) return;++ const stream = (typeof currentStreamData !== 'undefined' && currentStreamData) || null;++ if (!currentUser) return showModal('login');++ if (!stream || !stream.is_live) return toast('The stream is offline', 'error');++ const slot = _camSlotFor(stream);++ if (!slot) return toast('This stream has no cam slot', 'error');++++ _camBusy = true;++ const btn = document.getElementById('btn-cam');++ if (btn) btn.disabled = true;++ _camShowPanel('<p class="muted">Taking a still…</p>');++ try {++ const data = await api('/cam/stills', { method: 'POST', body: { slot, stream_id: stream.id } });++ const { img, url, share } = _camStillImageAndLink(data);++ if (!img && !url) {++ _camShowPanel('<p class="muted">No still came back.</p>');++ return;++ }++ const imgHtml = img ? `<img src="${esc(img)}" alt="Your still from the stream" loading="lazy">` : '';++ const linkHtml = url ? `<a class="cam-still-link" href="${esc(url)}" target="_blank" rel="noopener">Open still</a>` : '';++ const postHtml = (share && share.text) ? '<button class="btn btn-small btn-outline" id="btn-cam-post">Post to chat</button>' : '';++ _camShowPanel(`${imgHtml}<div class="cam-still-actions">${linkHtml}${postHtml}</div>`);++ const postBtn = document.getElementById('btn-cam-post');++ if (postBtn && share && share.text) {++ postBtn.addEventListener('click', async () => {++ postBtn.disabled = true;++ try {++ await api('/cam/post-share', { method: 'POST', body: { stream_id: stream.id, text: share.text } });++ postBtn.textContent = 'Posted to chat';++ } catch (err) {++ toast((err && err.message) || 'Could not post to chat', 'error');++ postBtn.disabled = false;++ }++ });++ }++ } catch (err) {++ const msg = (err && err.message) || 'Could not take a still';++ _camShowPanel(`<p class="muted">${esc(msg)}</p>`);++ toast(msg, 'error');++ } finally {++ _camBusy = false;++ if (btn) btn.disabled = false;++ }++}++++function _wireCamButton() {++ const btn = document.getElementById('btn-cam');++ if (btn && !btn.dataset.wired) {++ btn.dataset.wired = '1';++ btn.addEventListener('click', takeCamStill);++ }++ const close = document.getElementById('btn-cam-close');++ if (close && !close.dataset.wired) {++ close.dataset.wired = '1';++ close.addEventListener('click', () => { document.getElementById('cam-still-panel').style.display = 'none'; });++ }++}++function activateChannelStream(stream) {// NSFW age gate — block player init until viewer confirmsif (stream.is_nsfw && !sessionStorage.getItem('nsfw-ok-stream-' + stream.id)) {@@@ -1788,6 +1889,10 @@ function activateChannelStream(stream) {// Start polling for stream status changesstartStreamStatusPoll(stream);++++ // The cam button appears only on a live slot for a signed-in viewer.++ _wireCamButton();++ _updateCamButton(stream);}const STREAM_POLL_INTERVAL = 15000; // 15 seconds// After the player reports the stream ended (or we otherwise know the channel justnnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++/**++ * OpenVibe.Cam — Live's server-side client for the cam button.++ *++ * Live calls Cam's POST /api/v1/stills with the viewer's own Network token (never a Cam service++ * token). Cam fetches OpenVibe.Media's public frame, stores the still in OpenVibe.Pics as that++ * person, and answers the still plus the exact line Live posts to the stream's chat.++ *++ * Env:++ * CAM_URL base URL (default https://openvibe.cam)++ */++const drill = require('../drill');++++const CAM_URL = (process.env.CAM_URL || 'https://openvibe.cam').replace(/\/+$/, '');++const TIMEOUT_MS = 10000;++++class CamApiError extends Error {++ constructor(message, status, code) {++ super(message);++ this.name = 'CamApiError';++ this.status = status || 0;++ this.code = code || null;++ }++}++++function available() { return !!CAM_URL && !drill.enabled; }++++/** POST /api/v1/stills as the viewer. Resolves to Cam's JSON answer; rejects with CamApiError. */++async function createStill({ token, slot, streamId, caption }) {++ if (!available()) {++ throw new CamApiError('OpenVibe.Cam is unavailable right now', 503, 'cam.unavailable');++ }++ if (!token) {++ throw new CamApiError('A signed-in Network account is required', 401, 'cam.sign_in_required');++ }++ const res = await fetch(`${CAM_URL}/api/v1/stills`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },++ body: JSON.stringify({ slot, stream_id: streamId, caption }),++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ let data = null;++ try { data = await res.json(); } catch { /* Cam's problem bodies are JSON; a proxy page is not */ }++ if (!res.ok) {++ const message = (data && (data.message || data.error)) || `OpenVibe.Cam answered ${res.status}`;++ throw new CamApiError(message, res.status, data && data.code);++ }++ return data;++}++++module.exports = { CAM_URL, available, createStill, CamApiError };nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * OpenVibe.Live — the cam button API.++ *++ * POST /api/cam/stills { slot, stream_id, caption? } take a still (Cam → Pics as the viewer)++ * POST /api/cam/post-share { stream_id, text } post Cam's returned share.text to chat++ *++ * A signed-in viewer takes a still of a live stream. Live's server sends the viewer's own++ * Network token to OpenVibe.Cam, returns the still (link and picture), and — when the viewer++ * chooses — posts Cam's returned share.text into the stream's chat through Live's chat authority.++ */++const express = require('express');++const db = require('../db/database');++const { requireAuth, extractToken } = require('../auth/auth');++const cam = require('./cam-client');++const delivery = require('../chat/chat-delivery');++++const router = express.Router();++++// Cam's own slot rule (server/store.js SLOT_RE): a slot id, slug or @username.++const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;++const SHARE_TEXT_MAX = 500;++++async function liveStream(req, res) {++ const streamId = req.body && Number(req.body.stream_id);++ if (!Number.isInteger(streamId) || streamId <= 0) {++ res.status(400).json({ error: 'stream_id must be a positive whole number' });++ return null;++ }++ const stream = await db.getStreamById(streamId);++ if (!stream) {++ res.status(404).json({ error: 'Stream not found' });++ return null;++ }++ if (!stream.is_live) {++ res.status(409).json({ error: 'That stream is offline' });++ return null;++ }++ return stream;++}++++router.post('/stills', requireAuth, async (req, res) => {++ try {++ if (req.authSource === 'api_token') {++ return res.status(403).json({ error: 'The cam button needs your signed-in Network account, not an API token' });++ }++++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ const slot = String(body.slot == null ? '' : body.slot).trim();++ const caption = body.caption == null ? '' : String(body.caption).trim();++++ if (!SLOT_RE.test(slot)) return res.status(400).json({ error: 'A slot id, slug or @username is required' });++++ const stream = await liveStream(req, res);++ if (!stream) return;++++ const token = extractToken(req);++ if (!token || String(token).startsWith('hbt_')) {++ return res.status(401).json({ error: 'A Network sign-in is required' });++ }++++ let result;++ try {++ result = await cam.createStill({ token, slot, streamId: stream.id, caption });++ } catch (err) {++ const status = err && err.status ? err.status : 502;++ const message = (err && err.message) || 'OpenVibe.Cam could not take a still';++ return res.status(status).json({ error: message, ...(err && err.code ? { code: err.code } : {}) });++ }++++ return res.json(result);++ } catch (err) {++ console.error('[Cam] still error:', err.message);++ res.status(500).json({ error: 'Failed to take a still' });++ }++});++++router.post('/post-share', requireAuth, async (req, res) => {++ try {++ if (req.authSource === 'api_token') {++ return res.status(403).json({ error: 'The cam button needs your signed-in Network account, not an API token' });++ }++ const text = String((req.body && req.body.text) || '').trim().slice(0, SHARE_TEXT_MAX);++ if (!text) return res.status(400).json({ error: 'A share line is required' });++++ const stream = await liveStream(req, res);++ if (!stream) return;++++ const chatId = await delivery.message({++ stream_id: stream.id,++ channel_user_id: stream.user_id,++ user_id: req.user.id,++ username: req.user.username,++ message: text,++ message_type: 'chat',++ is_global: false,++ source_platform: 'cam',++ frame: { role: 'user' },++ });++++ return res.json({ posted: !!chatId });++ } catch (err) {++ console.error('[Cam] post-share error:', err.message);++ res.status(500).json({ error: 'Failed to post the still to chat' });++ }++});++++module.exports = router;@@@ -185,6 +185,9 @@ function buildConfig(registryValues) {publicUrl: process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media',appId: process.env.MEDIA_APP_ID || 'live',},++ cam: {++ url: process.env.CAM_URL || 'https://openvibe.cam',++ },turn: {url: turnUrl,username: process.env.TURN_USERNAME || '',@@@ -793,6 +793,7 @@ app.use('/api/today', require('./today/routes').createTodayRoutes()); // a chaapp.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);++app.use('/api/cam', require('./cam/routes')); // the cam button: a viewer takes a still of a live streamapp.use('/api/comments', commentRoutes);app.use('/api/admin/openre', require('./openre/routes'));app.use('/api/admin', adminRoutes);@@@ -54,6 +54,8 @@ const ROUTES = [['live.money', /^POST$/, /^\/(payments\/(bucks\/checkout|subscribe|subscriptions\/[^/]+\/cancel)|funds\/(purchase|donate|cashout|recycle))$/, { minute: 10, hour: 60 }],// Testing an AI provider key calls that provider.['live.ai.test', /^POST$/, /^\/ai-viewers\/byo\/test$/, { minute: 5, hour: 30 }],++ // The cam button fetches Media's frame and uploads a still to Pics as the viewer.++ ['live.cam.still', /^POST$/, /^\/cam\/stills$/, { minute: 6, hour: 60 }],];function createLiveActorLimits({ env = process.env, registry = null, now } = {}) {nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++// The cam button (CAM-1): a signed-in viewer takes a still of a live stream. Live's server sends the++// viewer's own Network token to OpenVibe.Cam, returns the still (link + picture), and — when the viewer++// chooses — posts Cam's returned share.text into the stream's chat with Live's chat authority.++// node test/cam-button.test.js++++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++const express = require('express');++const jwt = require('jsonwebtoken');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-cam-'));++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');++process.env.OV_NETWORK_URL = 'https://openvibe.network';++process.env.NODE_ENV = 'test';++console.warn = () => {};++++const ISS = 'https://openvibe.network';++const SUBJECT = 'usr_01J0000000000000000000000C1';++const viewerToken = () => jwt.sign({ sub: '77', username: 'alice', subject_id: SUBJECT, role: 'user' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 600 });++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ const d = db.getDb();++ await d.prepare("INSERT INTO users (id, username, display_name, password_hash) OVERRIDING SYSTEM VALUE VALUES (501, 'alice', 'Alice', 'x')").run();++ await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);++++ const slotId = Number((await db.run("INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key) VALUES (501, 'whip', 'Whip', 'webrtc', 'whip-key') RETURNING id")).lastInsertRowid);++ const streamId = Number((await db.run("INSERT INTO streams (user_id, managed_stream_id, title, protocol, is_live) VALUES (501, ?, 'Alice live', 'webrtc', 1) RETURNING id", [slotId])).lastInsertRowid);++++ const delivery = require('../server/chat/chat-delivery');++ const sent = [];++ let nextChatId = 900;++ delivery.message = async (body) => { sent.push(body); return nextChatId++; };++++ const cam = require('../server/cam/cam-client');++ const camCalls = [];++ const shareText = 'A still from whip: https://openvibe.cam/s/cms_test';++ cam.createStill = async ({ token, slot, streamId: sid, caption }) => {++ camCalls.push({ token, slot, sid, caption });++ return {++ still: { id: 'cms_test', stream_id: sid, slot, caption, visibility: 'unlisted', pics_slug: 'slug', pics_url: 'https://pics.test/page', pics_direct: 'https://pics.test/direct' },++ frame_url: 'https://openvibe.media/live/whip/frame.jpg?w=640',++ share: { url: 'https://openvibe.cam/s/cms_test', caption, text: shareText },++ };++ };++++ const app = express();++ app.use(express.json());++ app.use('/api/cam', require('../server/cam/routes'));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++++ const post = (p, body, token = viewerToken()) => fetch(`${base}/api/cam${p}`, {++ method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body),++ }).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));++++ try {++ // Signed-out viewers cannot take a still.++ assert.strictEqual((await post('/stills', { slot: 'whip', stream_id: streamId }, null)).status, 401);++++ // A signed-in viewer takes a still: Cam is called with the viewer's own Network token.++ const taken = await post('/stills', { slot: 'whip', stream_id: streamId, caption: 'the moment' });++ assert.strictEqual(taken.status, 200, JSON.stringify(taken.json));++ assert.strictEqual(taken.json.still.id, 'cms_test');++ assert.strictEqual(taken.json.share.text, shareText);++ assert.strictEqual(camCalls.length, 1);++ assert.strictEqual(camCalls[0].slot, 'whip');++ assert.strictEqual(camCalls[0].sid, streamId);++ assert.strictEqual(camCalls[0].token, viewerToken());++++ // A viewer chooses to post the returned share.text into the stream chat.++ const posted = await post('/post-share', { stream_id: streamId, text: shareText });++ assert.deepStrictEqual(posted.json, { posted: true });++ assert.strictEqual(sent.length, 1);++ assert.deepStrictEqual([sent[0].stream_id, sent[0].channel_user_id, sent[0].username, sent[0].message, sent[0].message_type, sent[0].source_platform],++ [streamId, 501, 'alice', shareText, 'chat', 'cam']);++++ // The button refuses a still while the stream is offline.++ await d.prepare('UPDATE streams SET is_live = 0 WHERE id = ?').run(streamId);++ const offline = await post('/stills', { slot: 'whip', stream_id: streamId });++ assert.strictEqual(offline.status, 409);++ assert.strictEqual(offline.json.error, 'That stream is offline');++++ // Bad slot/stream inputs are refused before Cam is asked.++ assert.strictEqual((await post('/stills', { slot: '', stream_id: streamId })).status, 400);++ assert.strictEqual((await post('/stills', { slot: 'whip', stream_id: 'nope' })).status, 400);++ } finally {++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++++ process.stdout.write('cam-button: all passed\n');++ process.exit(0);++})().catch((e) => { process.stderr.write(`${e.stack}\n`); process.exit(1); });@@@ -349,6 +349,7 @@ async function check(name, fn) {'server/social/preview.js': 'OpenVibe.Network public profile API (configured OV_NETWORK_INTERNAL_URL, fixed path, username checked); every other preview goes through egress','server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)','server/bot/bot-client.js': 'OpenVibe.Bot command API for a bound channel\'s chat robot commands (configured LIVE_BOT_INTERNAL_URL)',++ 'server/cam/cam-client.js': 'OpenVibe.Cam stills API for the cam button, as the viewer (configured CAM_URL)','server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)','server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)','server/chat/chat-delivery.js': 'OpenVibe.Tools anon resolve (configured)',
@@ -1010,6 +1010,20 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -1010,6 +1010,20 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
.ssp-body { padding: 8px 12px; display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; }
.ssp-body { padding: 8px 12px; display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; }
.ssp-body .ssp-k { color: #99a; }
.ssp-body .ssp-k { color: #99a; }
.ssp-body .ssp-v { color: #fff; text-align: right; font-variant-numeric: tabular-nums; word-break: break-word; }
.ssp-body .ssp-v { color: #fff; text-align: right; font-variant-numeric: tabular-nums; word-break: break-word; }
+/* The cam button's result overlay sits over the picture (not below it), so a phone viewer can still
+ see the stream while the still is open. */
+.cam-still-panel {
+ position: absolute; top: 12px; right: 12px; z-index: 12;
+ width: 288px; max-width: calc(100% - 24px);
+ background: rgba(15,15,18,0.92); backdrop-filter: blur(6px);
+ border: 1px solid rgba(255,255,255,0.12); border-radius: 10px;
+ color: #eee; font-size: 0.8rem; overflow: hidden;
+ box-shadow: 0 8px 28px rgba(0,0,0,0.5);
+}
+.cam-still-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
+.cam-still-body img { width: 100%; border-radius: 8px; display: block; }
+.cam-still-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.cam-still-link { color: var(--accent); word-break: break-all; }
.weather-current {
.weather-current {
display: flex; align-items: center; gap: 10px;
display: flex; align-items: center; gap: 10px;
}
}
@@ -68,6 +68,14 @@
@@ -68,6 +68,14 @@
</div>
</div>
<div class="ssp-body" id="stream-stats-body"></div>
<div class="ssp-body" id="stream-stats-body"></div>
</div>
</div>
+ <!-- Cam still overlay (the cam button's result: link + picture, with an optional chat post) -->
+ <div class="cam-still-panel" id="cam-still-panel" style="display:none">
+ <div class="ssp-header">
+ <span><i class="fa-solid fa-camera"></i> Still</span>
+ <button class="ssp-close" id="btn-cam-close" title="Close"><i class="fa-solid fa-xmark"></i></button>
+ </div>
+ <div class="cam-still-body" id="cam-still-body"></div>
+ </div>
<div class="fullscreen-chat-overlay" id="fullscreen-chat-overlay" aria-hidden="true">
<div class="fullscreen-chat-overlay" id="fullscreen-chat-overlay" aria-hidden="true">
<div class="fullscreen-chat-messages" id="fullscreen-chat-messages"></div>
<div class="fullscreen-chat-messages" id="fullscreen-chat-messages"></div>
<div class="fullscreen-chat-chrome" id="fullscreen-chat-chrome">
<div class="fullscreen-chat-chrome" id="fullscreen-chat-chrome">
@@ -120,6 +128,7 @@
@@ -120,6 +128,7 @@
<div class="ch-info-bar-right">
<div class="ch-info-bar-right">
<span class="ch-follower-count" id="ch-follower-count">0 followers</span>
<span class="ch-follower-count" id="ch-follower-count">0 followers</span>
<a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
<a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
+ <button class="btn btn-outline" id="btn-cam" title="Take a still" style="display:none"><i class="fa-solid fa-camera"></i> Still</button>
<button class="btn btn-follow" id="ch-btn-follow">
<button class="btn btn-follow" id="ch-btn-follow">
<i class="fa-solid fa-heart"></i> Follow
<i class="fa-solid fa-heart"></i> Follow
</button>
</button>
@@ -928,6 +928,8 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -928,6 +928,8 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-live-area').style.display = 'none';
document.getElementById('ch-live-area').style.display = 'none';
document.getElementById('ch-offline-area').style.display = '';
document.getElementById('ch-offline-area').style.display = '';
_channelFooter(false);
_channelFooter(false);
+ _wireCamButton();
+ _updateCamButton(null);
_placeBelowFold();
_placeBelowFold();
// Populate offline header
// Populate offline header
@@ -1687,6 +1689,105 @@ function switchToLiveStream(username, streamId, btn) {
@@ -1687,6 +1689,105 @@ function switchToLiveStream(username, streamId, btn) {
.finally(() => { if (myToken === _streamSwitchToken) showStreamSwitchOverlay(false); });
.finally(() => { if (myToken === _streamSwitchToken) showStreamSwitchOverlay(false); });
}
}
+// ── Cam button: a signed-in viewer takes a still of the live stream ─────────
+// The result is an overlay over the picture (not a block below it), so a phone viewer can
+// keep watching while the still is open (controls-keep-video-in-view).
+let _camBusy = false;
+
+function _camSlotFor(stream) {
+ if (!stream) return null;
+ return stream.managed_stream_slug || (stream.managed_stream_id != null ? String(stream.managed_stream_id) : null);
+}
+
+function _updateCamButton(stream) {
+ const btn = document.getElementById('btn-cam');
+ if (!btn) return;
+ const signedIn = !!(typeof currentUser !== 'undefined' && currentUser);
+ const slot = _camSlotFor(stream);
+ btn.style.display = (signedIn && stream && stream.is_live && slot) ? '' : 'none';
+}
+
+window.addEventListener('openvibe-auth-changed', () => {
+ const stream = (typeof currentStreamData !== 'undefined' && currentStreamData) || null;
+ _updateCamButton(stream && stream.is_live ? stream : null);
+});
+
+function _camShowPanel(html) {
+ const panel = document.getElementById('cam-still-panel');
+ if (!panel) return;
+ document.getElementById('cam-still-body').innerHTML = html;
+ const stats = document.getElementById('stream-stats-panel');
+ if (stats) stats.style.display = 'none';
+ panel.style.display = '';
+}
+
+function _camStillImageAndLink(data) {
+ const still = data && data.still;
+ const share = data && data.share;
+ const img = still && (still.pics_direct || still.pics_url);
+ const url = (share && share.url) || (still && still.pics_url);
+ return { img, url, share };
+}
+
+async function takeCamStill() {
+ if (_camBusy) return;
+ const stream = (typeof currentStreamData !== 'undefined' && currentStreamData) || null;
+ if (!currentUser) return showModal('login');
+ if (!stream || !stream.is_live) return toast('The stream is offline', 'error');
+ const slot = _camSlotFor(stream);
+ if (!slot) return toast('This stream has no cam slot', 'error');
+
+ _camBusy = true;
+ const btn = document.getElementById('btn-cam');
+ if (btn) btn.disabled = true;
+ _camShowPanel('<p class="muted">Taking a still…</p>');
+ try {
+ const data = await api('/cam/stills', { method: 'POST', body: { slot, stream_id: stream.id } });
+ const { img, url, share } = _camStillImageAndLink(data);
+ if (!img && !url) {
+ _camShowPanel('<p class="muted">No still came back.</p>');
+ return;
+ }
+ const imgHtml = img ? `<img src="${esc(img)}" alt="Your still from the stream" loading="lazy">` : '';
+ const linkHtml = url ? `<a class="cam-still-link" href="${esc(url)}" target="_blank" rel="noopener">Open still</a>` : '';
+ const postHtml = (share && share.text) ? '<button class="btn btn-small btn-outline" id="btn-cam-post">Post to chat</button>' : '';
+ _camShowPanel(`${imgHtml}<div class="cam-still-actions">${linkHtml}${postHtml}</div>`);
+ const postBtn = document.getElementById('btn-cam-post');
+ if (postBtn && share && share.text) {
+ postBtn.addEventListener('click', async () => {
+ postBtn.disabled = true;
+ try {
+ await api('/cam/post-share', { method: 'POST', body: { stream_id: stream.id, text: share.text } });
+ postBtn.textContent = 'Posted to chat';
+ } catch (err) {
+ toast((err && err.message) || 'Could not post to chat', 'error');
+ postBtn.disabled = false;
+ }
+ });
+ }
+ } catch (err) {
+ const msg = (err && err.message) || 'Could not take a still';
+ _camShowPanel(`<p class="muted">${esc(msg)}</p>`);
+ toast(msg, 'error');
+ } finally {
+ _camBusy = false;
+ if (btn) btn.disabled = false;
+ }
+}
+
+function _wireCamButton() {
+ const btn = document.getElementById('btn-cam');
+ if (btn && !btn.dataset.wired) {
+ btn.dataset.wired = '1';
+ btn.addEventListener('click', takeCamStill);
+ }
+ const close = document.getElementById('btn-cam-close');
+ if (close && !close.dataset.wired) {
+ close.dataset.wired = '1';
+ close.addEventListener('click', () => { document.getElementById('cam-still-panel').style.display = 'none'; });
+ }
+}
+
function activateChannelStream(stream) {
function activateChannelStream(stream) {
// NSFW age gate — block player init until viewer confirms
// NSFW age gate — block player init until viewer confirms
if (stream.is_nsfw && !sessionStorage.getItem('nsfw-ok-stream-' + stream.id)) {
if (stream.is_nsfw && !sessionStorage.getItem('nsfw-ok-stream-' + stream.id)) {
@@ -1788,6 +1889,10 @@ function activateChannelStream(stream) {
@@ -1788,6 +1889,10 @@ function activateChannelStream(stream) {
// Start polling for stream status changes
// Start polling for stream status changes
startStreamStatusPoll(stream);
startStreamStatusPoll(stream);
+
+ // The cam button appears only on a live slot for a signed-in viewer.
+ _wireCamButton();
+ _updateCamButton(stream);
}
}
const STREAM_POLL_INTERVAL = 15000; // 15 seconds
const STREAM_POLL_INTERVAL = 15000; // 15 seconds
// After the player reports the stream ended (or we otherwise know the channel just
// After the player reports the stream ended (or we otherwise know the channel just
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+/**
+ * OpenVibe.Cam — Live's server-side client for the cam button.
+ *
+ * Live calls Cam's POST /api/v1/stills with the viewer's own Network token (never a Cam service
+ * token). Cam fetches OpenVibe.Media's public frame, stores the still in OpenVibe.Pics as that
+ * person, and answers the still plus the exact line Live posts to the stream's chat.
+ *
+ * Env:
+ * CAM_URL base URL (default https://openvibe.cam)
+ */
+const drill = require('../drill');
+
+const CAM_URL = (process.env.CAM_URL || 'https://openvibe.cam').replace(/\/+$/, '');
+const TIMEOUT_MS = 10000;
+
+class CamApiError extends Error {
+ constructor(message, status, code) {
+ super(message);
+ this.name = 'CamApiError';
+ this.status = status || 0;
+ this.code = code || null;
+ }
+}
+
+function available() { return !!CAM_URL && !drill.enabled; }
+
+/** POST /api/v1/stills as the viewer. Resolves to Cam's JSON answer; rejects with CamApiError. */
+async function createStill({ token, slot, streamId, caption }) {
+ if (!available()) {
+ throw new CamApiError('OpenVibe.Cam is unavailable right now', 503, 'cam.unavailable');
+ }
+ if (!token) {
+ throw new CamApiError('A signed-in Network account is required', 401, 'cam.sign_in_required');
+ }
+ const res = await fetch(`${CAM_URL}/api/v1/stills`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ slot, stream_id: streamId, caption }),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ let data = null;
+ try { data = await res.json(); } catch { /* Cam's problem bodies are JSON; a proxy page is not */ }
+ if (!res.ok) {
+ const message = (data && (data.message || data.error)) || `OpenVibe.Cam answered ${res.status}`;
+ throw new CamApiError(message, res.status, data && data.code);
+ }
+ return data;
+}
+
+module.exports = { CAM_URL, available, createStill, CamApiError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * OpenVibe.Live — the cam button API.
+ *
+ * POST /api/cam/stills { slot, stream_id, caption? } take a still (Cam → Pics as the viewer)
+ * POST /api/cam/post-share { stream_id, text } post Cam's returned share.text to chat
+ *
+ * A signed-in viewer takes a still of a live stream. Live's server sends the viewer's own
+ * Network token to OpenVibe.Cam, returns the still (link and picture), and — when the viewer
+ * chooses — posts Cam's returned share.text into the stream's chat through Live's chat authority.
+ */
+const express = require('express');
+const db = require('../db/database');
+const { requireAuth, extractToken } = require('../auth/auth');
+const cam = require('./cam-client');
+const delivery = require('../chat/chat-delivery');
+
+const router = express.Router();
+
+// Cam's own slot rule (server/store.js SLOT_RE): a slot id, slug or @username.
+const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
+const SHARE_TEXT_MAX = 500;
+
+async function liveStream(req, res) {
+ const streamId = req.body && Number(req.body.stream_id);
+ if (!Number.isInteger(streamId) || streamId <= 0) {
+ res.status(400).json({ error: 'stream_id must be a positive whole number' });
+ return null;
+ }
+ const stream = await db.getStreamById(streamId);
+ if (!stream) {
+ res.status(404).json({ error: 'Stream not found' });
+ return null;
+ }
+ if (!stream.is_live) {
+ res.status(409).json({ error: 'That stream is offline' });
+ return null;
+ }
+ return stream;
+}
+
+router.post('/stills', requireAuth, async (req, res) => {
+ try {
+ if (req.authSource === 'api_token') {
+ return res.status(403).json({ error: 'The cam button needs your signed-in Network account, not an API token' });
+ }
+
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ const slot = String(body.slot == null ? '' : body.slot).trim();
+ const caption = body.caption == null ? '' : String(body.caption).trim();
+
+ if (!SLOT_RE.test(slot)) return res.status(400).json({ error: 'A slot id, slug or @username is required' });
+
+ const stream = await liveStream(req, res);
+ if (!stream) return;
+
+ const token = extractToken(req);
+ if (!token || String(token).startsWith('hbt_')) {
+ return res.status(401).json({ error: 'A Network sign-in is required' });
+ }
+
+ let result;
+ try {
+ result = await cam.createStill({ token, slot, streamId: stream.id, caption });
+ } catch (err) {
+ const status = err && err.status ? err.status : 502;
+ const message = (err && err.message) || 'OpenVibe.Cam could not take a still';
+ return res.status(status).json({ error: message, ...(err && err.code ? { code: err.code } : {}) });
+ }
+
+ return res.json(result);
+ } catch (err) {
+ console.error('[Cam] still error:', err.message);
+ res.status(500).json({ error: 'Failed to take a still' });
+ }
+});
+
+router.post('/post-share', requireAuth, async (req, res) => {
+ try {
+ if (req.authSource === 'api_token') {
+ return res.status(403).json({ error: 'The cam button needs your signed-in Network account, not an API token' });
+ }
+ const text = String((req.body && req.body.text) || '').trim().slice(0, SHARE_TEXT_MAX);
+ if (!text) return res.status(400).json({ error: 'A share line is required' });
+
+ const stream = await liveStream(req, res);
+ if (!stream) return;
+
+ const chatId = await delivery.message({
+ stream_id: stream.id,
+ channel_user_id: stream.user_id,
+ user_id: req.user.id,
+ username: req.user.username,
+ message: text,
+ message_type: 'chat',
+ is_global: false,
+ source_platform: 'cam',
+ frame: { role: 'user' },
+ });
+
+ return res.json({ posted: !!chatId });
+ } catch (err) {
+ console.error('[Cam] post-share error:', err.message);
+ res.status(500).json({ error: 'Failed to post the still to chat' });
+ }
+});
+
+module.exports = router;
@@ -185,6 +185,9 @@ function buildConfig(registryValues) {
@@ -185,6 +185,9 @@ function buildConfig(registryValues) {
publicUrl: process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media',
publicUrl: process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media',
appId: process.env.MEDIA_APP_ID || 'live',
appId: process.env.MEDIA_APP_ID || 'live',
},
},
+ cam: {
+ url: process.env.CAM_URL || 'https://openvibe.cam',
+ },
turn: {
turn: {
url: turnUrl,
url: turnUrl,
username: process.env.TURN_USERNAME || '',
username: process.env.TURN_USERNAME || '',
@@ -793,6 +793,7 @@ app.use('/api/today', require('./today/routes').createTodayRoutes()); // a cha
@@ -793,6 +793,7 @@ app.use('/api/today', require('./today/routes').createTodayRoutes()); // a cha
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
+app.use('/api/cam', require('./cam/routes')); // the cam button: a viewer takes a still of a live stream
app.use('/api/comments', commentRoutes);
app.use('/api/comments', commentRoutes);
app.use('/api/admin/openre', require('./openre/routes'));
app.use('/api/admin/openre', require('./openre/routes'));
app.use('/api/admin', adminRoutes);
app.use('/api/admin', adminRoutes);
@@ -54,6 +54,8 @@ const ROUTES = [
@@ -54,6 +54,8 @@ const ROUTES = [
['live.money', /^POST$/, /^\/(payments\/(bucks\/checkout|subscribe|subscriptions\/[^/]+\/cancel)|funds\/(purchase|donate|cashout|recycle))$/, { minute: 10, hour: 60 }],
['live.money', /^POST$/, /^\/(payments\/(bucks\/checkout|subscribe|subscriptions\/[^/]+\/cancel)|funds\/(purchase|donate|cashout|recycle))$/, { minute: 10, hour: 60 }],
// Testing an AI provider key calls that provider.
// Testing an AI provider key calls that provider.
['live.ai.test', /^POST$/, /^\/ai-viewers\/byo\/test$/, { minute: 5, hour: 30 }],
['live.ai.test', /^POST$/, /^\/ai-viewers\/byo\/test$/, { minute: 5, hour: 30 }],
+ // The cam button fetches Media's frame and uploads a still to Pics as the viewer.
+ ['live.cam.still', /^POST$/, /^\/cam\/stills$/, { minute: 6, hour: 60 }],
];
];
function createLiveActorLimits({ env = process.env, registry = null, now } = {}) {
function createLiveActorLimits({ env = process.env, registry = null, now } = {}) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+// The cam button (CAM-1): a signed-in viewer takes a still of a live stream. Live's server sends the
+// viewer's own Network token to OpenVibe.Cam, returns the still (link + picture), and — when the viewer
+// chooses — posts Cam's returned share.text into the stream's chat with Live's chat authority.
+// node test/cam-button.test.js
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-cam-'));
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
+process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
+process.env.OV_NETWORK_URL = 'https://openvibe.network';
+process.env.NODE_ENV = 'test';
+console.warn = () => {};
+
+const ISS = 'https://openvibe.network';
+const SUBJECT = 'usr_01J0000000000000000000000C1';
+const viewerToken = () => jwt.sign({ sub: '77', username: 'alice', subject_id: SUBJECT, role: 'user' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 600 });
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ const d = db.getDb();
+ await d.prepare("INSERT INTO users (id, username, display_name, password_hash) OVERRIDING SYSTEM VALUE VALUES (501, 'alice', 'Alice', 'x')").run();
+ await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);
+
+ const slotId = Number((await db.run("INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key) VALUES (501, 'whip', 'Whip', 'webrtc', 'whip-key') RETURNING id")).lastInsertRowid);
+ const streamId = Number((await db.run("INSERT INTO streams (user_id, managed_stream_id, title, protocol, is_live) VALUES (501, ?, 'Alice live', 'webrtc', 1) RETURNING id", [slotId])).lastInsertRowid);
+
+ const delivery = require('../server/chat/chat-delivery');
+ const sent = [];
+ let nextChatId = 900;
+ delivery.message = async (body) => { sent.push(body); return nextChatId++; };
+
+ const cam = require('../server/cam/cam-client');
+ const camCalls = [];
+ const shareText = 'A still from whip: https://openvibe.cam/s/cms_test';
+ cam.createStill = async ({ token, slot, streamId: sid, caption }) => {
+ camCalls.push({ token, slot, sid, caption });
+ return {
+ still: { id: 'cms_test', stream_id: sid, slot, caption, visibility: 'unlisted', pics_slug: 'slug', pics_url: 'https://pics.test/page', pics_direct: 'https://pics.test/direct' },
+ frame_url: 'https://openvibe.media/live/whip/frame.jpg?w=640',
+ share: { url: 'https://openvibe.cam/s/cms_test', caption, text: shareText },
+ };
+ };
+
+ const app = express();
+ app.use(express.json());
+ app.use('/api/cam', require('../server/cam/routes'));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const post = (p, body, token = viewerToken()) => fetch(`${base}/api/cam${p}`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body),
+ }).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));
+
+ try {
+ // Signed-out viewers cannot take a still.
+ assert.strictEqual((await post('/stills', { slot: 'whip', stream_id: streamId }, null)).status, 401);
+
+ // A signed-in viewer takes a still: Cam is called with the viewer's own Network token.
+ const taken = await post('/stills', { slot: 'whip', stream_id: streamId, caption: 'the moment' });
+ assert.strictEqual(taken.status, 200, JSON.stringify(taken.json));
+ assert.strictEqual(taken.json.still.id, 'cms_test');
+ assert.strictEqual(taken.json.share.text, shareText);
+ assert.strictEqual(camCalls.length, 1);
+ assert.strictEqual(camCalls[0].slot, 'whip');
+ assert.strictEqual(camCalls[0].sid, streamId);
+ assert.strictEqual(camCalls[0].token, viewerToken());
+
+ // A viewer chooses to post the returned share.text into the stream chat.
+ const posted = await post('/post-share', { stream_id: streamId, text: shareText });
+ assert.deepStrictEqual(posted.json, { posted: true });
+ assert.strictEqual(sent.length, 1);
+ assert.deepStrictEqual([sent[0].stream_id, sent[0].channel_user_id, sent[0].username, sent[0].message, sent[0].message_type, sent[0].source_platform],
+ [streamId, 501, 'alice', shareText, 'chat', 'cam']);
+
+ // The button refuses a still while the stream is offline.
+ await d.prepare('UPDATE streams SET is_live = 0 WHERE id = ?').run(streamId);
+ const offline = await post('/stills', { slot: 'whip', stream_id: streamId });
+ assert.strictEqual(offline.status, 409);
+ assert.strictEqual(offline.json.error, 'That stream is offline');
+
+ // Bad slot/stream inputs are refused before Cam is asked.
+ assert.strictEqual((await post('/stills', { slot: '', stream_id: streamId })).status, 400);
+ assert.strictEqual((await post('/stills', { slot: 'whip', stream_id: 'nope' })).status, 400);
+ } finally {
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+
+ process.stdout.write('cam-button: all passed\n');
+ process.exit(0);
+})().catch((e) => { process.stderr.write(`${e.stack}\n`); process.exit(1); });
@@ -349,6 +349,7 @@ async function check(name, fn) {
@@ -349,6 +349,7 @@ async function check(name, fn) {
'server/social/preview.js': 'OpenVibe.Network public profile API (configured OV_NETWORK_INTERNAL_URL, fixed path, username checked); every other preview goes through egress',
'server/social/preview.js': 'OpenVibe.Network public profile API (configured OV_NETWORK_INTERNAL_URL, fixed path, username checked); every other preview goes through egress',
'server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)',
'server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)',
'server/bot/bot-client.js': 'OpenVibe.Bot command API for a bound channel\'s chat robot commands (configured LIVE_BOT_INTERNAL_URL)',
'server/bot/bot-client.js': 'OpenVibe.Bot command API for a bound channel\'s chat robot commands (configured LIVE_BOT_INTERNAL_URL)',
+ 'server/cam/cam-client.js': 'OpenVibe.Cam stills API for the cam button, as the viewer (configured CAM_URL)',
'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',
'server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',
'server/chat/chat-delivery.js': 'OpenVibe.Tools anon resolve (configured)',
'server/chat/chat-delivery.js': 'OpenVibe.Tools anon resolve (configured)',