openvibers/openvibe.live Commits

The RTMP broadcaster's live preview shows again; live thumbnail cadence pinned (roadmap D20 parity: thumbnail cadence). The preview asked for /thumbnails/stream-<id>-live.jpg, which nothing served since live thumbnails became /api/thumbnails/stream-<id>-<ts>.jpg (a new name per capture), and its <img> was loading="lazy" inside a box that stays hidden until the image loads, so it could not load anyway. /api/thumbnails/stream-<id>-live.jpg now answers with that stream's current live thumbnail (no-cache; a Media fallback frame is a 302; 404 when there is none so the preview stays hidden) and the image is eager. test/live-thumb-cadence.test.js pins the cadence end to end: captures replaced after 2 minutes and not before, writes under 15 s apart dropped, a new file name per capture and the old file removed, files deleted after an hour; the 60 s maintenance loop's 2-minute grabs for RTMP, JSMPEG and WebRTC/WHIP; the browser's 115 s post from a visible tab on its 30 s heartbeat; the home grid's once-a-minute re-read and crossfade; the preview's 10 s poll of the served name.

OP OpenVibers committed 26 Sept 2026 · cb70d3004b · 5 files, +172 −5


Changes

ddiff --git a/docs/broadcasting.md b/docs/broadcasting.md
iindex 0d68412a..424724c1 100644
---- a/docs/broadcasting.md
++++ b/docs/broadcasting.md
@@@ -56,8 +56,11 @@ The broadcast page includes an embedded chat sidebar with:
- Automatic reconnection with exponential backoff (3s → 30s max)
- Connection status indicator (protocol name when connected, status when not)
--### RTMP/JSMPEG Preview
--When using RTMP or JSMPEG, a live thumbnail preview refreshes every 10 seconds showing your stream output.
++### RTMP Preview
++When using RTMP, the page shows your stream's live thumbnail (`/api/thumbnails/stream-<id>-live.jpg`),
++checked every 10 seconds. Live thumbnails, the ones the live cards show too, are refreshed about every
++2 minutes: the server grabs a frame from RTMP, JSMPEG and WebRTC/WHIP streams, and a browser broadcaster
++with a visible tab posts its own.
## Multi-Stream
You can run multiple simultaneous streams (e.g., different cameras). Each gets its own tab in the broadcast page with independent controls and viewer counts.
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 5db97c49..5e66621f 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -138,9 +138,10 @@
</div>
</div>
-- <!-- Live Thumbnail Preview (RTMP/JSMPEG) -->
++ <!-- Live Thumbnail Preview (RTMP/JSMPEG). Not loading="lazy": the box stays hidden until
++ the image loads, and a lazy image in a hidden box never loads. -->
<div id="bc-rtmp-preview" class="bc-rtmp-preview" style="display:none">
-- <img id="bc-rtmp-preview-img" alt="Stream preview" loading="lazy">
++ <img id="bc-rtmp-preview-img" alt="Stream preview">
<div class="bc-live-badge" style="position:absolute;top:8px;left:8px;display:flex"><i class="fa-solid fa-circle"></i> LIVE</div>
</div>
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 7c724e90..891433ac 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -6570,8 +6570,10 @@ function startRtmpPreview(streamId) {
const el = document.getElementById('bc-rtmp-preview');
const img = document.getElementById('bc-rtmp-preview-img');
if (!el || !img) return;
++ // The stream's current live thumbnail (server/media-proxy/thumbnails.js; the server grabs a new
++ // frame about every 2 minutes). /thumbnails/… was never served, so this preview never showed.
const update = () => {
-- img.src = `/thumbnails/stream-${streamId}-live.jpg?t=${Date.now()}`;
++ img.src = `/api/thumbnails/stream-${streamId}-live.jpg?t=${Date.now()}`;
img.onerror = () => { el.style.display = 'none'; };
img.onload = () => { el.style.display = ''; };
};
ddiff --git a/server/media-proxy/thumbnails.js b/server/media-proxy/thumbnails.js
iindex 2e4fe685..1393dd9c 100644
---- a/server/media-proxy/thumbnails.js
++++ b/server/media-proxy/thumbnails.js
@@@ -87,6 +87,22 @@ router.get('/generate/clip/:id', _generateRedirect('clip'));
// Live thumbnails come from local disk; everything else 302s to openvibe.media.
router.get('/:filename', (req, res) => {
const filename = path.basename(req.params.filename);
++ // stream-<id>-live.jpg: whatever that stream's live thumbnail is now (each capture gets a new
++ // file name). The broadcaster's RTMP/JSMPEG preview polls it (public/js/broadcast.js
++ // startRtmpPreview). Never cached; 404 when there is none, so the preview stays hidden rather
++ // than showing the 1×1 placeholder.
++ const alias = /^stream-(\d+)-live\.jpg$/.exec(filename);
++ if (alias) {
++ const current = liveThumbs.getStreamThumbnailState(Number(alias[1]));
++ if (current.exists) {
++ res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Content-Length': fs.statSync(current.filePath).size, 'Cache-Control': 'no-cache' });
++ return fs.createReadStream(current.filePath).pipe(res);
++ }
++ res.set('Cache-Control', 'no-cache');
++ // The fallback frame for a WebRTC stream is a Media thumbnail URL (server/index.js).
++ if (/^https:\/\//.test(current.thumbUrl || '')) return res.redirect(302, current.thumbUrl);
++ return res.status(404).json({ error: 'No live thumbnail' });
++ }
const localPath = path.join(liveThumbs.THUMB_DIR, filename);
if (fs.existsSync(localPath)) {
const stat = fs.statSync(localPath);
ddiff --git a/test/live-thumb-cadence.test.js b/test/live-thumb-cadence.test.js
nnew file mode 100644
iindex 00000000..bee8fb2d
---- /dev/null
++++ b/test/live-thumb-cadence.test.js
@@@ -0,0 +1,145 @@
++/**
++ * Legacy parity (roadmap D20): live thumbnails refresh on a steady cadence.
++ *
++ * capture a browser broadcaster posts a frame about every 2 minutes (115 s, checked on its 30 s
++ * heartbeat, never from a hidden tab); the server's 60 s maintenance loop grabs one for
++ * RTMP, JSMPEG and WebRTC/WHIP streams whose thumbnail is 2 minutes old; writes closer
++ * than 15 s apart are dropped
++ * show the home live grid re-reads the list at least once a minute and crossfades to the new
++ * file (each capture gets a new name); the broadcaster's RTMP/JSMPEG preview polls every
++ * 10 s
++ * clean a live thumbnail file older than an hour is deleted
++ *
++ * The fix it came with: that broadcaster preview asked for /thumbnails/stream-<id>-live.jpg, which
++ * nothing served (live thumbnails are /api/thumbnails/stream-<id>-<ts>.jpg), and its <img> was
++ * loading="lazy" inside a box hidden until the image loads, so it never loaded either way. It now
++ * polls /api/thumbnails/stream-<id>-live.jpg, which answers with the stream's current thumbnail.
++ *
++ * node test/live-thumb-cadence.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-thumbs-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.DATA_DIR = tmp;
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++
++const root = path.join(__dirname, '..');
++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
++const db = require('../server/db/database');
++db.initDb();
++db.getDb().prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
++ VALUES (3, 'alice', 'alice', 'alice@x', 'x', 'streamer', '2025-01-01 00:00:00')`).run();
++const ch = db.ensureChannel(3);
++const sid = Number(db.createStream({ user_id: 3, channel_id: ch.id, title: 'A', protocol: 'rtmp' }).lastInsertRowid);
++const other = Number(db.createStream({ user_id: 3, channel_id: ch.id, title: 'B', protocol: 'webrtc' }).lastInsertRowid);
++
++const liveThumbs = require('../server/media-proxy/live-thumbs');
++assert.ok(liveThumbs.THUMB_DIR.startsWith(tmp), 'live thumbnails live under DATA_DIR (server/paths.js)');
++// A tiny JPEG (SOI marker is all the validator needs).
++const jpeg = (tag) => Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.from(tag)]);
++const age = (file, ms) => { const t = (Date.now() - ms) / 1000; fs.utimesSync(file, t, t); };
++const current = () => liveThumbs.getStreamThumbnailState(sid);
++
++const express = require('express');
++const app = express();
++app.use('/api/thumbnails', require('../server/media-proxy/thumbnails'));
++const server = http.createServer(app).listen(0);
++const get = (p) => new Promise((resolve, reject) => {
++ http.get({ port: server.address().port, path: p }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
++ }).on('error', reject);
++});
++
++(async () => {
++ await new Promise((r) => server.once('listening', r));
++
++ // ── capture cadence (server side) ──
++ assert.ok(liveThumbs.shouldRefreshLiveThumbnail(sid), 'no thumbnail yet: capture one');
++ const first = liveThumbs.saveLiveThumbnail(sid, jpeg('one'));
++ assert.match(first, /^\/api\/thumbnails\/stream-\d+-\d+\.jpg$/);
++ assert.ok(!liveThumbs.shouldRefreshLiveThumbnail(sid), 'a fresh thumbnail is not replaced');
++ age(current().filePath, 119_000);
++ assert.ok(!liveThumbs.shouldRefreshLiveThumbnail(sid), 'still kept just under 2 minutes');
++ age(current().filePath, 121_000);
++ assert.ok(liveThumbs.shouldRefreshLiveThumbnail(sid), 'replaced once 2 minutes old');
++ age(current().filePath, 5_000);
++ assert.strictEqual(liveThumbs.saveLiveThumbnail(sid, jpeg('two')), first, 'writes closer than 15 s apart are dropped');
++ age(current().filePath, 16_000);
++ const oldFile = current().filePath;
++ await new Promise((r) => setTimeout(r, 5));
++ const second = liveThumbs.saveLiveThumbnail(sid, jpeg('two'));
++ assert.notStrictEqual(second, first, 'a new capture gets a new file name (cards and caches see a new URL)');
++ assert.ok(!fs.existsSync(oldFile), 'and the previous file is removed');
++ console.log('OK capture: 2-minute refresh, 15 s write floor, new name per capture');
++
++ // ── the stable name the broadcaster preview polls ──
++ let r = await get(`/api/thumbnails/stream-${sid}-live.jpg?t=1`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers['cache-control'], 'no-cache');
++ assert.strictEqual(r.body.toString('latin1').slice(4), 'two', 'it is the current thumbnail');
++ age(current().filePath, 20_000);
++ liveThumbs.saveLiveThumbnail(sid, jpeg('three'));
++ r = await get(`/api/thumbnails/stream-${sid}-live.jpg?t=2`);
++ assert.strictEqual(r.body.toString('latin1').slice(4), 'three', 'and follows each new capture');
++ assert.strictEqual((await get(`/api/thumbnails/stream-${other}-live.jpg`)).status, 404, 'none yet: 404, so the preview stays hidden');
++ db.run('UPDATE streams SET thumbnail_url = ? WHERE id = ?', ['https://openvibe.media/t/vod-9-1.jpg', other]);
++ r = await get(`/api/thumbnails/stream-${other}-live.jpg`);
++ assert.deepStrictEqual([r.status, r.headers.location], [302, 'https://openvibe.media/t/vod-9-1.jpg'], 'a Media fallback frame is followed');
++ r = await get(`/api/thumbnails/${path.basename(current().thumbUrl)}`);
++ assert.strictEqual(r.body.toString('latin1').slice(4), 'three', 'the real file names are still served');
++ assert.strictEqual((await get('/api/thumbnails/stream-999-123.jpg')).headers['content-type'], 'image/jpeg', 'a missing card thumbnail is still the placeholder pixel');
++ console.log('OK /api/thumbnails/stream-<id>-live.jpg serves the current live thumbnail, uncached');
++
++ // ── clean ──
++ age(current().filePath, 3_700_000);
++ const stale = current().filePath;
++ liveThumbs.cleanupOldThumbnails();
++ assert.ok(!fs.existsSync(stale), 'files older than an hour are deleted');
++ console.log('OK clean-up after an hour');
++
++ // ── the loops that drive it ──
++ const index = read('server/index.js');
++ const loop = index.slice(index.indexOf('const maintenanceInterval = setInterval('), index.indexOf("if (typeof maintenanceInterval.unref === 'function')"));
++ assert.match(loop, /\}, 60000\);\s*$/, 'the maintenance loop runs every minute');
++ assert.match(loop, /liveThumbs\.cleanupOldThumbnails\(\);/);
++ assert.match(loop, /shouldRefreshLiveThumbnail\(rs\.id, 120000\)[\s\S]*generateLiveStreamThumbnail\(rs\.id, rs\.stream_key, \{ minAgeMs: 120000 \}\)/, 'RTMP: every 2 minutes');
++ assert.match(loop, /shouldRefreshLiveThumbnail\(js\.id, 120000\)[\s\S]*generateJSMPEGThumbnail/, 'JSMPEG: every 2 minutes');
++ assert.match(loop, /shouldRefreshLiveThumbnail\(wsStream\.id, 120000\)[\s\S]*generateWebrtcThumbnail\(wsStream\.id, \{ minAgeMs: 120000 \}\)/, 'WebRTC/WHIP: every 2 minutes, from the SFU');
++
++ const state = read('public/js/broadcast-state.js');
++ const bc = read('public/js/broadcast.js');
++ const interval = Number(/const BROADCAST_THUMBNAIL_INTERVAL_MS = (\d+);/.exec(state)[1]);
++ assert.ok(interval >= 15000 && interval < 120000, 'the browser posts before the server would grab a frame for it, never faster than the write floor');
++ const capture = /function captureLiveThumbnail\(streamId\) \{[\s\S]*?\n\}/.exec(bc)[0];
++ assert.match(capture, /if \(document\.hidden\) return;/, 'a hidden tab posts nothing (the server grabs instead)');
++ assert.match(capture, /BROADCAST_THUMBNAIL_INTERVAL_MS/);
++ assert.match(capture, /api\(`\/thumbnails\/live\/\$\{sid\}`, \{ method: 'POST'/);
++ assert.match(bc, /captureLiveThumbnail\(streamId\);\s*\}, 30000\);/, 'checked on the 30 s heartbeat');
++
++ const preview = /function startRtmpPreview\(streamId\) \{[\s\S]*?\n\}/.exec(bc)[0];
++ assert.match(preview, /img\.src = `\/api\/thumbnails\/stream-\$\{streamId\}-live\.jpg\?t=\$\{Date\.now\(\)\}`;/, 'the preview polls the served name');
++ assert.match(preview, /setInterval\(update, 10000\)/);
++ const fragment = read('public/fragments/broadcast.html');
++ const img = /<img id="bc-rtmp-preview-img"[^>]*>/.exec(fragment)[0];
++ assert.ok(!/loading="lazy"/.test(img), 'the preview image is not lazy: its box is hidden until it loads');
++
++ const home = read('public/js/app-home.js');
++ assert.match(home, /_homeLiveTimer = setInterval\(_homeLiveTick, 12000\);/);
++ assert.match(home, /OVLiveRealtime\.connected && Date\.now\(\) - _homeLastLiveRefresh < 60000\) return;/, 'live cards re-read at least once a minute');
++ assert.match(home, /_crossfadeThumb\(img, s\.thumbnail_url\);/, 'and crossfade to the new thumbnail');
++ console.log('OK the capture and display loops keep that cadence');
++
++ server.close();
++ console.log('\n✅ live thumbnail cadence checks passed');
++ process.exit(0);
++})().catch((e) => { quiet(e); process.exit(1); });