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.
Changes
@@@ -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-StreamYou can run multiple simultaneous streams (e.g., different cameras). Each gets its own tab in the broadcast page with independent controls and viewer counts.@@@ -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>@@@ -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 = ''; };};@@@ -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);nnew file mode 100644@@@ -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); });
@@ -56,8 +56,11 @@ The broadcast page includes an embedded chat sidebar with:
@@ -56,8 +56,11 @@ The broadcast page includes an embedded chat sidebar with:
- Automatic reconnection with exponential backoff (3s → 30s max)
- Automatic reconnection with exponential backoff (3s → 30s max)
- Connection status indicator (protocol name when connected, status when not)
- 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
## 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.
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.
@@ -138,9 +138,10 @@
@@ -138,9 +138,10 @@
</div>
</div>
</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">
<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 class="bc-live-badge" style="position:absolute;top:8px;left:8px;display:flex"><i class="fa-solid fa-circle"></i> LIVE</div>
</div>
</div>
@@ -6570,8 +6570,10 @@ function startRtmpPreview(streamId) {
@@ -6570,8 +6570,10 @@ function startRtmpPreview(streamId) {
const el = document.getElementById('bc-rtmp-preview');
const el = document.getElementById('bc-rtmp-preview');
const img = document.getElementById('bc-rtmp-preview-img');
const img = document.getElementById('bc-rtmp-preview-img');
if (!el || !img) return;
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 = () => {
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.onerror = () => { el.style.display = 'none'; };
img.onload = () => { el.style.display = ''; };
img.onload = () => { el.style.display = ''; };
};
};
@@ -87,6 +87,22 @@ router.get('/generate/clip/:id', _generateRedirect('clip'));
@@ -87,6 +87,22 @@ router.get('/generate/clip/:id', _generateRedirect('clip'));
// Live thumbnails come from local disk; everything else 302s to openvibe.media.
// Live thumbnails come from local disk; everything else 302s to openvibe.media.
router.get('/:filename', (req, res) => {
router.get('/:filename', (req, res) => {
const filename = path.basename(req.params.filename);
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);
const localPath = path.join(liveThumbs.THUMB_DIR, filename);
if (fs.existsSync(localPath)) {
if (fs.existsSync(localPath)) {
const stat = fs.statSync(localPath);
const stat = fs.statSync(localPath);
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -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); });