openvibers/openvibe.live Commits
A live picture within 15 s and every 30 s while watched, and a recording's last live frame (355) (#800)
Changes
@@@ -275,6 +275,8 @@ router.get('/featured', async (req, res) => {res.set('Cache-Control', 'public, max-age=10');try {const live = (await db.getLiveStreams() || []).filter((s) => !s.is_nsfw);++ require('../media-proxy/live-preview').attach(live);++ require('../streaming/live-thumb-cadence').markListed(live.map((x) => x.id));if (!live.length) return res.json({ stream: null, count: 0 });let idx = Math.floor(Date.now() / FEATURED_ROTATE_MS) % live.length;const not = parseInt(req.query.not, 10);@@@ -1670,7 +1670,6 @@ async function start() {webrtcSFU.closeRoom(`stream-${stream.id}`);}}-- const liveVodThumbGeneratedAt = new Map(); // streamId → last Media frame-grab timeconst maintenanceInterval = setInterval(async () => {if (heartbeatCleanupRunning) return;heartbeatCleanupRunning = true;@@@ -1723,66 +1722,9 @@ async function start() {// Also clean up old live-stream thumbnails (>1 hour)liveThumbs.cleanupOldThumbnails();-- // Generate server-side thumbnails for RTMP streams (no client capture available).-- // A managed-slot "Go Live" publishes under the SLOT's stream key, so prefer-- // managed_streams.stream_key over the personal users.stream_key — the RTMP-- // server's activeStreams map (and the HTTP-FLV URL) are keyed by the publish key.-- const rtmpStreams = await db.all(-- `SELECT s.id, COALESCE(ms.stream_key, u.stream_key) AS stream_key FROM streams s-- JOIN users u ON s.user_id = u.id-- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id-- WHERE s.is_live = 1 AND s.protocol = 'rtmp'`-- );-- for (const rs of rtmpStreams) {-- if (!rs.stream_key || !rtmpServer.isReceiving(rs.stream_key)) continue;-- if (!await liveThumbs.shouldRefreshLiveThumbnail(rs.id, 120000)) continue;-- liveThumbs.generateLiveStreamThumbnail(rs.id, rs.stream_key, { minAgeMs: 120000 }).catch(() => {});-- }---- // Generate server-side thumbnails for JSMPEG streams (broadcaster uses FFmpeg, no browser preview)-- const jsmpegStreams = await db.all(-- `SELECT s.id, u.stream_key FROM streams s-- JOIN users u ON s.user_id = u.id-- WHERE s.is_live = 1 AND s.protocol = 'jsmpeg'`-- );-- for (const js of jsmpegStreams) {-- if (!await liveThumbs.shouldRefreshLiveThumbnail(js.id, 120000)) continue;-- const channelInfo = jsmpegRelay.getChannelInfo(js.stream_key);-- if (channelInfo && channelInfo.videoPort) {-- liveThumbs.generateJSMPEGThumbnail(js.id, channelInfo.videoPort).catch(() => {});-- }-- }---- // WebRTC/WHIP/browser live thumbnails: the broadcaster client's periodic-- // canvas-capture POST covers browser publishers with a visible tab, but WHIP-- // publishers (OBS) and hidden tabs send nothing. Fallback: have Media extract-- // a frame from the in-progress recording (fragmented mp4 — readable while-- // growing) and use that as both the live card and RECORDING-card thumbnail.-- const webrtcStreams = await db.all(-- `SELECT id FROM streams WHERE is_live = 1 AND protocol NOT IN ('rtmp', 'jsmpeg')`-- );-- for (const wsStream of webrtcStreams) {-- if (await liveThumbs.shouldRefreshLiveThumbnail(wsStream.id, 120000)) {-- const lastGen = liveVodThumbGeneratedAt.get(wsStream.id) || 0;-- if (Date.now() - lastGen < 120000) continue;-- liveVodThumbGeneratedAt.set(wsStream.id, Date.now());-- // First choice: a frame straight from the SFU (works for OBS/WHIP and hidden-- // tabs, and needs no recording). The Media in-progress-VOD frame is the backup.-- const grabbed = await liveThumbs.generateWebrtcThumbnail(wsStream.id, { minAgeMs: 120000 }).catch(() => null);-- if (grabbed) continue;-- const rec = recorder.activeRecordings.get(wsStream.id);-- if (!rec || !rec.vodId) continue;-- mediaClient.generateThumbnail('vod', rec.vodId)-- .then(async (out) => {-- const url = mediaClient.publicUrl(out?.url);-- if (url) await db.run('UPDATE streams SET thumbnail_url = ? WHERE id = ?', [url, wsStream.id]);-- })-- .catch(() => {});-- }-- }-- for (const [sid] of liveVodThumbGeneratedAt) {-- if (!webrtcStreams.some(s => s.id === sid)) liveVodThumbGeneratedAt.delete(sid);-- }++ // Live pictures are their own 5 s timer below (server/streaming/live-thumb-cadence.js): a first++ // picture within 15 s of go-live and a fresh one about every 30 s while someone is watching. This++ // one-minute maintenance pass used to grab every 2 minutes for every live stream, watched or not.} catch (err) {console.error('[Heartbeat] Cleanup error:', err.message);} finally {@@@ -1791,6 +1733,14 @@ async function start() {}, 60000);if (typeof maintenanceInterval.unref === 'function') maintenanceInterval.unref();++ // 8a2. Live pictures (plan 355): a first frame within 15 s of going live and a fresh one about every 30 s while++ // someone watches the stream's page or home. One ffmpeg grab per stream per interval; skipped when unwatched.++ const livePictureInterval = setInterval(() => {++ require('./streaming/live-thumb-cadence').tick()++ .catch((err) => console.warn('[Thumbnails] live picture tick:', err.message));++ }, require('./streaming/live-thumb-cadence').TICK_MS);++ if (typeof livePictureInterval.unref === 'function') livePictureInterval.unref();++// 8b. Canonical identity: report every Live<->Network link to Network's identity_legacy_map// (roadmap Wave 1). Idempotent on Network's side; daily, first run a few minutes after boot.// Then fill in the subject of every linked account no token has told Live about yet.@@@ -342,6 +342,17 @@ async function generateThumbnail(kind, id, opts = {}) {return await request('POST', `/thumbnails/${kind}/${id}`, opts);}++/**++ * Upload an image as a VOD/clip's thumbnail (Media's POST /thumbnails/:kind/:id with a multipart `thumbnail`).++ * Live uses it for the one picture Media cannot take: the last frame of the live stream a recording came from++ * (server/media-proxy/live-picture-fallback.js), still on disk when the recording finalizes.++ */++async function uploadThumbnail(kind, id, buffer, { contentType = 'image/jpeg' } = {}) {++ const form = new FormData();++ form.append('thumbnail', new Blob([buffer], { type: contentType }), 'thumbnail.jpg');++ return await request('POST', `/thumbnails/${kind}/${id}`, { body: form, timeoutMs: 30000 });++}++// ── Public URL builders (MEDIA_PUBLIC_URL) ───────────────────────────────────function vodPlaybackUrl(id) { return `${MEDIA_PUBLIC_URL}/v/${id}`; }@@@ -371,6 +382,9 @@ function setPlaybackUrls(row, kind) {const file = kind === 'clip' ? clipUrl(row.id) : vodPlaybackUrl(row.id);row.file_url = row.file_url || publicUrl(row.playback_url) || file;if (row.hls_url) row.hls_url = publicUrl(row.hls_url);++ // The hover preview (preview.make): Media-relative /p/<name> on the same public origin as the thumbnails.++ if (row.preview_url) row.preview_url = publicUrl(row.preview_url);++ if (row.preview_webm_url) row.preview_webm_url = publicUrl(row.preview_webm_url);row.playback_url = row.hls_url || row.file_url;return row;}nnew file mode 100644@@@ -0,0 +1,55 @@++/**++ * OpenVibe.Live — the live stream's last picture as a recording's thumbnail++ *++ * A recording can finish without a picture: the frame grab failed, the file is too short or damaged at the seek point,++ * or the recording started before the source had video. When the recording came from a live stream, Live already has a++ * picture of it — the last frame its live grab captured, still on disk for an hour after the stream ends. Media's own++ * thumbnail wins whenever it has one; this only fills a hole, so a recording made from a live stream never shows the++ * grey placeholder when a real frame of that stream exists.++ *++ * Called from the vod.ready outcome (server/media-proxy/outcomes.js), after the recording's row is committed.++ */++'use strict';++const fs = require('fs');++const path = require('path');++const db = require('../db/database');++const media = require('../media-client');++const liveThumbs = require('./live-thumbs');++++/** A local live-picture file from a streams.thumbnail_url value, or null. */++function livePictureFile(url) {++ const m = /^\/api\/thumbnails\/(stream-[A-Za-z0-9_.-]+\.(?:jpg|png))$/.exec(String(url || ''));++ if (!m) return null;++ const file = path.join(liveThumbs.THUMB_DIR, m[1]);++ try { return fs.statSync(file).isFile() ? file : null; } catch { return null; }++}++++/**++ * Give a finished recording the live stream's last picture when it has no thumbnail of its own.++ * → { adopted: boolean, reason? }++ */++async function adoptForVod(vodId, data = {}) {++ const id = Number(vodId);++ if (!Number.isSafeInteger(id) || id <= 0) return { adopted: false, reason: 'no_id' };++ let vod;++ try { vod = await media.getVod(id); } catch { return { adopted: false, reason: 'media_unreachable' }; }++ if (!vod) return { adopted: false, reason: 'no_row' };++ if (vod.thumbnail_url) return { adopted: false, reason: 'has_picture' };++ if (Number(vod.audio_only)) return { adopted: false, reason: 'audio_only' };++ const streamId = Number(vod.stream_id || data.stream_id || data.streamId);++ if (!Number.isSafeInteger(streamId) || streamId <= 0) return { adopted: false, reason: 'no_stream' };++ let stream = null;++ try { stream = await db.getStreamById(streamId); } catch { /* no row */ }++ const file = livePictureFile(stream && stream.thumbnail_url);++ if (!file) return { adopted: false, reason: 'no_live_picture' };++ try {++ const out = await media.uploadThumbnail('vod', id, fs.readFileSync(file));++ if (!out || !out.url) return { adopted: false, reason: 'media_refused' };++ console.log(`[VOD] Recording ${id} took the live picture of stream ${streamId} as its thumbnail`);++ return { adopted: true };++ } catch (err) {++ return { adopted: false, reason: err.message };++ }++}++++module.exports = { adoptForVod, livePictureFile };nnew file mode 100644@@@ -0,0 +1,34 @@++/**++ * OpenVibe.Live — a live stream's cheap preview_url++ *++ * A live card's hover preview does not need a second encode: while a stream is being recorded, Media already serves the++ * recording's DVR as an EVENT HLS playlist at the live edge (GET /v/<vod>/dvr.m3u8, server/public/routes.js in++ * OpenVibe.Media). It is full-resolution, not a low-bitrate ladder, but it costs one request and no extra copy — the++ * closest thing Live already has to a live preview, so that is what a live stream's preview_url names.++ *++ * The playlist exists only for an MP4 recording in progress (a browser MediaRecorder stream may be WebM), and never++ * for a private one: Media answers 404 then and the player keeps the thumbnail/recording URL. Null when there is no++ * active recording to preview.++ */++'use strict';++const media = require('../media-client');++++/** The preview URL for one live stream right now, or null. */++function previewUrlFor(streamId, { recorder = null } = {}) {++ try {++ const rec = (recorder || require('../streaming/recorder')).activeRecordings.get(Number(streamId));++ if (!rec || !rec.vodId) return null;++ if (String(rec.protocol || '').toLowerCase() === 'jsmpeg') return null;++ return media.vodDvrPlaylistUrl(rec.vodId);++ } catch { return null; }++}++++/** Set preview_url on each stream row (null when there is nothing to preview). */++function attach(streams, opts = {}) {++ for (const s of streams || []) {++ if (s && s.id != null) s.preview_url = previewUrlFor(s.id, opts);++ }++ return streams;++}++++module.exports = { previewUrlFor, attach };@@@ -2,8 +2,8 @@* OpenVibe.Live — live-stream thumbnails (Live-local, ephemeral)** VOD/clip/paste thumbnails moved to OpenVibe.Media, but LIVE stream thumbnails-- * are transient live-state (refreshed every ~2 min, deleted an hour after the-- * stream ends), so they stay local: captured here (client canvas POST, RTMP FLV++ * are transient live-state (refreshed every ~30 s while watched, deleted an hour++ * after the stream ends), so they stay local: captured here (client canvas POST, RTMP FLV* grab, or JSMPEG relay tap), written to data/live-thumbs/, and served from* /api/thumbnails/stream-<id>-<ts>.jpg by the thumbnails proxy router.*@@@ -21,7 +21,7 @@ const db = require('../db/database');const THUMB_DIR = require('../paths').dir('LIVE_THUMBS_PATH', 'live-thumbs');const THUMB_WIDTH = 640;const THUMB_QUALITY = 6;--const LIVE_THUMB_MIN_INTERVAL_MS = 120000;++const LIVE_THUMB_MIN_INTERVAL_MS = 30000;const CLIENT_THUMB_WRITE_MIN_INTERVAL_MS = 15000;const activeLiveThumbnailJobs = new Set();@@@ -127,6 +127,9 @@ async function apply(event, data) {outcome: 'vod_ready',after: async () => {try { require('../streaming/recorder').onVodSettled(vodId); } catch { /* */ }++ // No picture of its own? A recording made from a live stream takes that stream's last frame++ // (media-proxy/live-picture-fallback.js), still on disk for an hour after the stream ended.++ try { await require('./live-picture-fallback').adoptForVod(vodId, data); } catch { /* best effort */ }// Kick the on-finalize AI pass right away (both budget-gated).try {const ai = require('../ai/ai-analysis');nnew file mode 100644@@@ -0,0 +1,120 @@++/**++ * OpenVibe.Live — live pictures (plan 355)++ *++ * Every live stream has a picture within 15 s of going live, and a fresh one about every 30 s while someone is++ * watching the channel page or the home/list pages. The browser/WHIP publisher posts its own frame every 15 s; this++ * server-side tick is the fallback for RTMP/JSMPEG and for a browser whose tab is hidden or whose publisher is OBS.++ *++ * Cost: one ffmpeg grab per stream per interval, never one per page. The grabbers already dedupe concurrent work++ * (live-thumbs.js activeLiveThumbnailJobs) and only a frame taken from a keyframe becomes the picture.++ *++ * - no picture yet → grab on the first tick after go-live (watched or not: a card must never be blank)++ * - picture, watched → grab when it is 30 s old++ * - picture, nobody watching → leave it alone (the live thumbnail of an unwatched stream is not being looked at)++ * - a grab in the last 15 s → wait (a failing source is not hammered every 5 s)++ *++ * "Watching" is the player sockets in the stream's room (broadcast-server), the viewer count Chat reports, or the++ * stream appearing in a public live list in the last 90 s (the home page and the channel lists call those).++ */++'use strict';++const db = require('../db/database');++++const TICK_MS = 5000;++const PICTURE_INTERVAL_MS = 30000;++const ATTEMPT_GAP_MS = 15000;++const LISTED_FRESH_MS = 90000;++++const lastAttempt = new Map(); // streamId → when the last grab started++const listed = new Map(); // streamId → when a public live list last named it++++/** A public live list just named these streams: someone may be looking at their cards. */++function markListed(ids, now = Date.now()) {++ for (const id of ids || []) {++ const n = Number(id);++ if (Number.isSafeInteger(n) && n > 0) listed.set(n, now);++ }++ if (listed.size > 5000) {++ for (const [id, at] of listed) if (now - at > 10 * 60 * 1000) listed.delete(id);++ }++}++++/** Pure: is anyone watching this stream, by any of the cheap signals? */++function isWatched({ viewerCount = 0, socketCount = 0, listedAt = 0, now = Date.now() } = {}) {++ return Number(viewerCount) > 0 || Number(socketCount) > 0 || (Number(listedAt) > 0 && now - Number(listedAt) <= LISTED_FRESH_MS);++}++++/** Pure: should this stream be grabbed now? */++function shouldGrab({ hasPicture, ageMs = Infinity, watched = false, sinceAttemptMs = Infinity } = {}) {++ if (sinceAttemptMs < ATTEMPT_GAP_MS) return false;++ if (!hasPicture) return true; // first picture: always++ if (!watched) return false; // fresh enough and nobody is looking++ return !(Number(ageMs) >= 0 && Number(ageMs) < PICTURE_INTERVAL_MS);++}++++/** The video grab for one stream, by protocol; the Media in-progress recording is the WebRTC/WHIP backup. */++async function grab(stream) {++ const liveThumbs = require('../media-proxy/live-thumbs');++ const protocol = String(stream.protocol || '').toLowerCase();++ if (protocol === 'rtmp') {++ const rtmpServer = require('./rtmp-server');++ if (!stream.stream_key || !rtmpServer.isReceiving(stream.stream_key)) return null;++ return await liveThumbs.generateLiveStreamThumbnail(stream.id, stream.stream_key, { minAgeMs: PICTURE_INTERVAL_MS });++ }++ if (protocol === 'jsmpeg') {++ const relay = require('./jsmpeg-relay');++ const info = stream.stream_key ? relay.getChannelInfo(stream.stream_key) : null;++ if (!info || !info.videoPort) return null;++ return await liveThumbs.generateJSMPEGThumbnail(stream.id, info.videoPort);++ }++ const grabbed = await liveThumbs.generateWebrtcThumbnail(stream.id, { minAgeMs: PICTURE_INTERVAL_MS }).catch(() => null);++ if (grabbed) return grabbed;++ // No SFU producer (OBS/WHIP in a shape the grabber cannot tap): a frame from Media's in-progress recording.++ try {++ const rec = require('./recorder').activeRecordings.get(Number(stream.id));++ if (!rec || !rec.vodId) return null;++ const mediaClient = require('../media-client');++ const out = await mediaClient.generateThumbnail('vod', rec.vodId);++ const url = mediaClient.publicUrl(out && out.url);++ if (url) await db.run('UPDATE streams SET thumbnail_url = ? WHERE id = ?', [url, stream.id]);++ return url;++ } catch { return null; }++}++++/** One pass over the live streams. Never throws (the timer logs). */++async function tick({ now = Date.now(), deps = {} } = {}) {++ if (require('../drill').enabled) return { checked: 0, grabbed: 0 };++ const liveThumbs = deps.liveThumbs || require('../media-proxy/live-thumbs');++ const broadcast = deps.broadcast || require('./broadcast-server');++ const rows = await (deps.liveStreams ? deps.liveStreams()++ : db.all(`SELECT s.id, s.protocol, s.thumbnail_url, s.viewer_count, COALESCE(ms.stream_key, u.stream_key) AS stream_key++ FROM streams s JOIN users u ON s.user_id = u.id++ LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id++ WHERE s.is_live = 1`));++ let grabbed = 0;++ for (const stream of rows || []) {++ let socketCount = 0;++ try { socketCount = broadcast.getViewerCount(stream.id) || 0; } catch { /* no signaling */ }++ const watched = isWatched({ viewerCount: stream.viewer_count, socketCount, listedAt: listed.get(Number(stream.id)) || 0, now });++ let ageMs = Infinity;++ try { const state = await liveThumbs.getStreamThumbnailState(stream.id); if (state.exists) ageMs = state.ageMs; } catch { /* no file */ }++ const hasPicture = !!stream.thumbnail_url;++ const sinceAttemptMs = now - (lastAttempt.get(Number(stream.id)) || 0);++ if (!shouldGrab({ hasPicture, ageMs, watched, sinceAttemptMs })) continue;++ lastAttempt.set(Number(stream.id), now);++ grabbed++;++ grab(stream).catch((err) => console.warn(`[Thumbnails] live picture for stream ${stream.id}:`, err.message));++ }++ // Forget ended streams: their timers must not keep a decision for a later session.++ const liveIds = new Set((rows || []).map((r) => Number(r.id)));++ for (const id of lastAttempt.keys()) if (!liveIds.has(id)) lastAttempt.delete(id);++ for (const id of listed.keys()) if (!liveIds.has(id)) listed.delete(id);++ return { checked: (rows || []).length, grabbed };++}++++/** Test seam: forget the in-memory decisions. */++function reset() { lastAttempt.clear(); listed.clear(); }++++module.exports = {++ TICK_MS, PICTURE_INTERVAL_MS, ATTEMPT_GAP_MS, LISTED_FRESH_MS,++ markListed, isWatched, shouldGrab, tick, reset,++};@@@ -273,6 +273,9 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {// Get live streams (may be multiple with different protocols)const liveStreams = await db.getLiveStreamsByUserId(channel.user_id) || [];++ // The live card's hover preview: Media's DVR playlist of the recording in progress (media-proxy/live-preview.js).++ require('../media-proxy/live-preview').attach(liveStreams);++ require('./live-thumb-cadence').markListed(liveStreams.map((x) => x.id));for (const liveStream of liveStreams) {// Use managed stream key from the JOIN, else fall back to user keyconst lsKey = liveStream.managed_stream_key@@@ -735,6 +738,9 @@ router.get('/channel/:username/live', async (req, res) => {if (!channel) return res.status(404).json({ error: 'Channel not found' });const liveStreams = await db.getLiveStreamsByUserId(channel.user_id) || [];++ // The live card's hover preview: Media's DVR playlist of the recording in progress (media-proxy/live-preview.js).++ require('../media-proxy/live-preview').attach(liveStreams);++ require('./live-thumb-cadence').markListed(liveStreams.map((x) => x.id));for (const liveStream of liveStreams) {// Use managed stream key from the JOIN, else fall back to user keyconst lsKey = liveStream.managed_stream_key@@@ -1198,6 +1204,8 @@ router.get('/', optionalAuth, async (req, res) => {// viewer's prefs say. Every other surface shows it with its chips; this is the only place a label hides.let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id)))&& !contentLabels.labelsOf(s).labels.includes('18+'));++ require('../media-proxy/live-preview').attach(streams);++ require('./live-thumb-cadence').markListed(streams.map((x) => x.id));// Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.// A stream matches on its own tags or the AI's, without carrying either into the response.if (wantedTags.length) {@@@ -1,10 +1,12 @@/*** 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++ * capture a browser broadcaster posts a frame on its 30 s heartbeat (never from a hidden tab);++ * the server's 5 s live-picture tick grabs one for RTMP, JSMPEG and WebRTC/WHIP streams++ * whose thumbnail is 30 s old, always for a stream with no picture yet (a first picture++ * within 15 s of going live) and only while somebody watches (a viewer socket, Chat's++ * viewer count, or the stream named in a public live list in the last 90 s); 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@@@ -69,10 +71,10 @@ const get = (p) => new Promise((resolve, reject) => {const first = await liveThumbs.saveLiveThumbnail(sid, jpeg('one'));assert.match(first, /^\/api\/thumbnails\/stream-\d+-\d+\.jpg$/);assert.ok(!await liveThumbs.shouldRefreshLiveThumbnail(sid), 'a fresh thumbnail is not replaced');-- age((await current()).filePath, 119_000);-- assert.ok(!await liveThumbs.shouldRefreshLiveThumbnail(sid), 'still kept just under 2 minutes');-- age((await current()).filePath, 121_000);-- assert.ok(await liveThumbs.shouldRefreshLiveThumbnail(sid), 'replaced once 2 minutes old');++ age((await current()).filePath, 25_000);++ assert.ok(!await liveThumbs.shouldRefreshLiveThumbnail(sid), 'still kept just under 30 s');++ age((await current()).filePath, 31_000);++ assert.ok(await liveThumbs.shouldRefreshLiveThumbnail(sid), 'replaced once 30 s old');age((await current()).filePath, 5_000);assert.strictEqual(await liveThumbs.saveLiveThumbnail(sid, jpeg('two')), first, 'writes closer than 15 s apart are dropped');age((await current()).filePath, 16_000);@@@ -81,7 +83,7 @@ const get = (p) => new Promise((resolve, reject) => {const second = await 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');++ console.log('OK capture: 30 s 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`);@@@ -119,9 +121,23 @@ const get = (p) => new Promise((resolve, reject) => {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');++ assert.ok(!/generateWebrtcThumbnail|generateLiveStreamThumbnail|generateJSMPEGThumbnail/.test(loop), 'the 2-minute grabs are gone from the maintenance loop');++ assert.match(index, /require\('\.\/streaming\/live-thumb-cadence'\)\.tick\(\)/, 'the live-picture timer calls the cadence tick');++ assert.match(index, /\}, require\('\.\/streaming\/live-thumb-cadence'\)\.TICK_MS\)/, 'on its own 5 s interval');++++ // The cadence itself: first picture at once, 30 s while watched, nothing while nobody looks, 15 s between attempts.++ const cadence = require('../server/streaming/live-thumb-cadence');++ assert.strictEqual(cadence.TICK_MS, 5000);++ assert.strictEqual(cadence.PICTURE_INTERVAL_MS, 30000);++ assert.strictEqual(cadence.shouldGrab({ hasPicture: false, ageMs: Infinity, watched: false, sinceAttemptMs: Infinity }), true, 'a first picture does not wait for a viewer');++ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 10_000, watched: true, sinceAttemptMs: Infinity }), false, 'a fresh picture is kept');++ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 31_000, watched: true, sinceAttemptMs: Infinity }), true, 'a 30 s old picture is refreshed for a viewer');++ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 31_000, watched: false, sinceAttemptMs: Infinity }), false, 'and left alone for nobody');++ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 31_000, watched: true, sinceAttemptMs: 5_000 }), false, 'a grab that just started is not doubled');++ assert.strictEqual(cadence.isWatched({ viewerCount: 2 }), true, 'Chat’s viewer count is a watcher');++ assert.strictEqual(cadence.isWatched({ socketCount: 1 }), true, 'so is a player socket');++ assert.strictEqual(cadence.isWatched({ listedAt: Date.now() - 30_000 }), true, 'and a stream on the home/list pages a moment ago');++ assert.strictEqual(cadence.isWatched({ listedAt: Date.now() - 120_000 }), false, 'but not an old list');const state = read('public/js/broadcast-state.js');const bc = read('public/js/broadcast.js');@@@ -146,6 +162,23 @@ const get = (p) => new Promise((resolve, reject) => {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');++ // ── the live preview and the live-picture fallback (plan 355) ──++ const livePreview = require('../server/media-proxy/live-preview');++ const empty = { activeRecordings: new Map() };++ assert.strictEqual(livePreview.previewUrlFor(sid, { recorder: empty }), null, 'no recording: no live preview');++ const recording = { activeRecordings: new Map([[sid, { vodId: 42, protocol: 'webrtc' }]]) };++ assert.match(livePreview.previewUrlFor(sid, { recorder: recording }), /\/v\/42\/dvr\.m3u8$/, 'a recording in progress: the DVR playlist');++ assert.strictEqual(livePreview.previewUrlFor(sid, { recorder: { activeRecordings: new Map([[sid, { vodId: 42, protocol: 'jsmpeg' }]]) } }), null, 'JSMPEG has no DVR');++ const attached = livePreview.attach([{ id: sid }], { recorder: recording });++ assert.match(attached[0].preview_url, /\/v\/42\/dvr\.m3u8$/, 'the stream row carries it');++++ const fallback = require('../server/media-proxy/live-picture-fallback');++ await liveThumbs.saveLiveThumbnail(sid, jpeg('four')); // the clean-up above removed the last one++ const currentUrl = (await db.get('SELECT thumbnail_url FROM streams WHERE id = ?', [sid])).thumbnail_url;++ assert.strictEqual(fallback.livePictureFile(currentUrl), path.join(liveThumbs.THUMB_DIR, path.basename(currentUrl)),++ 'the live stream’s current picture is a local file');++ assert.strictEqual(fallback.livePictureFile('https://openvibe.media/t/vod-9-1.jpg'), null, 'a Media URL is not a live picture');++server.close();console.log('\n✅ live thumbnail cadence checks passed');process.exit(0);
@@ -275,6 +275,8 @@ router.get('/featured', async (req, res) => {
@@ -275,6 +275,8 @@ router.get('/featured', async (req, res) => {
res.set('Cache-Control', 'public, max-age=10');
res.set('Cache-Control', 'public, max-age=10');
try {
try {
const live = (await db.getLiveStreams() || []).filter((s) => !s.is_nsfw);
const live = (await db.getLiveStreams() || []).filter((s) => !s.is_nsfw);
+ require('../media-proxy/live-preview').attach(live);
+ require('../streaming/live-thumb-cadence').markListed(live.map((x) => x.id));
if (!live.length) return res.json({ stream: null, count: 0 });
if (!live.length) return res.json({ stream: null, count: 0 });
let idx = Math.floor(Date.now() / FEATURED_ROTATE_MS) % live.length;
let idx = Math.floor(Date.now() / FEATURED_ROTATE_MS) % live.length;
const not = parseInt(req.query.not, 10);
const not = parseInt(req.query.not, 10);
@@ -1670,7 +1670,6 @@ async function start() {
@@ -1670,7 +1670,6 @@ async function start() {
webrtcSFU.closeRoom(`stream-${stream.id}`);
webrtcSFU.closeRoom(`stream-${stream.id}`);
}
}
}
}
- const liveVodThumbGeneratedAt = new Map(); // streamId → last Media frame-grab time
const maintenanceInterval = setInterval(async () => {
const maintenanceInterval = setInterval(async () => {
if (heartbeatCleanupRunning) return;
if (heartbeatCleanupRunning) return;
heartbeatCleanupRunning = true;
heartbeatCleanupRunning = true;
@@ -1723,66 +1722,9 @@ async function start() {
@@ -1723,66 +1722,9 @@ async function start() {
// Also clean up old live-stream thumbnails (>1 hour)
// Also clean up old live-stream thumbnails (>1 hour)
liveThumbs.cleanupOldThumbnails();
liveThumbs.cleanupOldThumbnails();
- // Generate server-side thumbnails for RTMP streams (no client capture available).
- // A managed-slot "Go Live" publishes under the SLOT's stream key, so prefer
- // managed_streams.stream_key over the personal users.stream_key — the RTMP
- // server's activeStreams map (and the HTTP-FLV URL) are keyed by the publish key.
- const rtmpStreams = await db.all(
- `SELECT s.id, COALESCE(ms.stream_key, u.stream_key) AS stream_key FROM streams s
- JOIN users u ON s.user_id = u.id
- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
- WHERE s.is_live = 1 AND s.protocol = 'rtmp'`
- );
- for (const rs of rtmpStreams) {
- if (!rs.stream_key || !rtmpServer.isReceiving(rs.stream_key)) continue;
- if (!await liveThumbs.shouldRefreshLiveThumbnail(rs.id, 120000)) continue;
- liveThumbs.generateLiveStreamThumbnail(rs.id, rs.stream_key, { minAgeMs: 120000 }).catch(() => {});
- }
-
- // Generate server-side thumbnails for JSMPEG streams (broadcaster uses FFmpeg, no browser preview)
- const jsmpegStreams = await db.all(
- `SELECT s.id, u.stream_key FROM streams s
- JOIN users u ON s.user_id = u.id
- WHERE s.is_live = 1 AND s.protocol = 'jsmpeg'`
- );
- for (const js of jsmpegStreams) {
- if (!await liveThumbs.shouldRefreshLiveThumbnail(js.id, 120000)) continue;
- const channelInfo = jsmpegRelay.getChannelInfo(js.stream_key);
- if (channelInfo && channelInfo.videoPort) {
- liveThumbs.generateJSMPEGThumbnail(js.id, channelInfo.videoPort).catch(() => {});
- }
- }
-
- // WebRTC/WHIP/browser live thumbnails: the broadcaster client's periodic
- // canvas-capture POST covers browser publishers with a visible tab, but WHIP
- // publishers (OBS) and hidden tabs send nothing. Fallback: have Media extract
- // a frame from the in-progress recording (fragmented mp4 — readable while
- // growing) and use that as both the live card and RECORDING-card thumbnail.
- const webrtcStreams = await db.all(
- `SELECT id FROM streams WHERE is_live = 1 AND protocol NOT IN ('rtmp', 'jsmpeg')`
- );
- for (const wsStream of webrtcStreams) {
- if (await liveThumbs.shouldRefreshLiveThumbnail(wsStream.id, 120000)) {
- const lastGen = liveVodThumbGeneratedAt.get(wsStream.id) || 0;
- if (Date.now() - lastGen < 120000) continue;
- liveVodThumbGeneratedAt.set(wsStream.id, Date.now());
- // First choice: a frame straight from the SFU (works for OBS/WHIP and hidden
- // tabs, and needs no recording). The Media in-progress-VOD frame is the backup.
- const grabbed = await liveThumbs.generateWebrtcThumbnail(wsStream.id, { minAgeMs: 120000 }).catch(() => null);
- if (grabbed) continue;
- const rec = recorder.activeRecordings.get(wsStream.id);
- if (!rec || !rec.vodId) continue;
- mediaClient.generateThumbnail('vod', rec.vodId)
- .then(async (out) => {
- const url = mediaClient.publicUrl(out?.url);
- if (url) await db.run('UPDATE streams SET thumbnail_url = ? WHERE id = ?', [url, wsStream.id]);
- })
- .catch(() => {});
- }
- }
- for (const [sid] of liveVodThumbGeneratedAt) {
- if (!webrtcStreams.some(s => s.id === sid)) liveVodThumbGeneratedAt.delete(sid);
- }
+ // Live pictures are their own 5 s timer below (server/streaming/live-thumb-cadence.js): a first
+ // picture within 15 s of go-live and a fresh one about every 30 s while someone is watching. This
+ // one-minute maintenance pass used to grab every 2 minutes for every live stream, watched or not.
} catch (err) {
} catch (err) {
console.error('[Heartbeat] Cleanup error:', err.message);
console.error('[Heartbeat] Cleanup error:', err.message);
} finally {
} finally {
@@ -1791,6 +1733,14 @@ async function start() {
@@ -1791,6 +1733,14 @@ async function start() {
}, 60000);
}, 60000);
if (typeof maintenanceInterval.unref === 'function') maintenanceInterval.unref();
if (typeof maintenanceInterval.unref === 'function') maintenanceInterval.unref();
+ // 8a2. Live pictures (plan 355): a first frame within 15 s of going live and a fresh one about every 30 s while
+ // someone watches the stream's page or home. One ffmpeg grab per stream per interval; skipped when unwatched.
+ const livePictureInterval = setInterval(() => {
+ require('./streaming/live-thumb-cadence').tick()
+ .catch((err) => console.warn('[Thumbnails] live picture tick:', err.message));
+ }, require('./streaming/live-thumb-cadence').TICK_MS);
+ if (typeof livePictureInterval.unref === 'function') livePictureInterval.unref();
+
// 8b. Canonical identity: report every Live<->Network link to Network's identity_legacy_map
// 8b. Canonical identity: report every Live<->Network link to Network's identity_legacy_map
// (roadmap Wave 1). Idempotent on Network's side; daily, first run a few minutes after boot.
// (roadmap Wave 1). Idempotent on Network's side; daily, first run a few minutes after boot.
// Then fill in the subject of every linked account no token has told Live about yet.
// Then fill in the subject of every linked account no token has told Live about yet.
@@ -342,6 +342,17 @@ async function generateThumbnail(kind, id, opts = {}) {
@@ -342,6 +342,17 @@ async function generateThumbnail(kind, id, opts = {}) {
return await request('POST', `/thumbnails/${kind}/${id}`, opts);
return await request('POST', `/thumbnails/${kind}/${id}`, opts);
}
}
+/**
+ * Upload an image as a VOD/clip's thumbnail (Media's POST /thumbnails/:kind/:id with a multipart `thumbnail`).
+ * Live uses it for the one picture Media cannot take: the last frame of the live stream a recording came from
+ * (server/media-proxy/live-picture-fallback.js), still on disk when the recording finalizes.
+ */
+async function uploadThumbnail(kind, id, buffer, { contentType = 'image/jpeg' } = {}) {
+ const form = new FormData();
+ form.append('thumbnail', new Blob([buffer], { type: contentType }), 'thumbnail.jpg');
+ return await request('POST', `/thumbnails/${kind}/${id}`, { body: form, timeoutMs: 30000 });
+}
+
// ── Public URL builders (MEDIA_PUBLIC_URL) ───────────────────────────────────
// ── Public URL builders (MEDIA_PUBLIC_URL) ───────────────────────────────────
function vodPlaybackUrl(id) { return `${MEDIA_PUBLIC_URL}/v/${id}`; }
function vodPlaybackUrl(id) { return `${MEDIA_PUBLIC_URL}/v/${id}`; }
@@ -371,6 +382,9 @@ function setPlaybackUrls(row, kind) {
@@ -371,6 +382,9 @@ function setPlaybackUrls(row, kind) {
const file = kind === 'clip' ? clipUrl(row.id) : vodPlaybackUrl(row.id);
const file = kind === 'clip' ? clipUrl(row.id) : vodPlaybackUrl(row.id);
row.file_url = row.file_url || publicUrl(row.playback_url) || file;
row.file_url = row.file_url || publicUrl(row.playback_url) || file;
if (row.hls_url) row.hls_url = publicUrl(row.hls_url);
if (row.hls_url) row.hls_url = publicUrl(row.hls_url);
+ // The hover preview (preview.make): Media-relative /p/<name> on the same public origin as the thumbnails.
+ if (row.preview_url) row.preview_url = publicUrl(row.preview_url);
+ if (row.preview_webm_url) row.preview_webm_url = publicUrl(row.preview_webm_url);
row.playback_url = row.hls_url || row.file_url;
row.playback_url = row.hls_url || row.file_url;
return row;
return row;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+/**
+ * OpenVibe.Live — the live stream's last picture as a recording's thumbnail
+ *
+ * A recording can finish without a picture: the frame grab failed, the file is too short or damaged at the seek point,
+ * or the recording started before the source had video. When the recording came from a live stream, Live already has a
+ * picture of it — the last frame its live grab captured, still on disk for an hour after the stream ends. Media's own
+ * thumbnail wins whenever it has one; this only fills a hole, so a recording made from a live stream never shows the
+ * grey placeholder when a real frame of that stream exists.
+ *
+ * Called from the vod.ready outcome (server/media-proxy/outcomes.js), after the recording's row is committed.
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const db = require('../db/database');
+const media = require('../media-client');
+const liveThumbs = require('./live-thumbs');
+
+/** A local live-picture file from a streams.thumbnail_url value, or null. */
+function livePictureFile(url) {
+ const m = /^\/api\/thumbnails\/(stream-[A-Za-z0-9_.-]+\.(?:jpg|png))$/.exec(String(url || ''));
+ if (!m) return null;
+ const file = path.join(liveThumbs.THUMB_DIR, m[1]);
+ try { return fs.statSync(file).isFile() ? file : null; } catch { return null; }
+}
+
+/**
+ * Give a finished recording the live stream's last picture when it has no thumbnail of its own.
+ * → { adopted: boolean, reason? }
+ */
+async function adoptForVod(vodId, data = {}) {
+ const id = Number(vodId);
+ if (!Number.isSafeInteger(id) || id <= 0) return { adopted: false, reason: 'no_id' };
+ let vod;
+ try { vod = await media.getVod(id); } catch { return { adopted: false, reason: 'media_unreachable' }; }
+ if (!vod) return { adopted: false, reason: 'no_row' };
+ if (vod.thumbnail_url) return { adopted: false, reason: 'has_picture' };
+ if (Number(vod.audio_only)) return { adopted: false, reason: 'audio_only' };
+ const streamId = Number(vod.stream_id || data.stream_id || data.streamId);
+ if (!Number.isSafeInteger(streamId) || streamId <= 0) return { adopted: false, reason: 'no_stream' };
+ let stream = null;
+ try { stream = await db.getStreamById(streamId); } catch { /* no row */ }
+ const file = livePictureFile(stream && stream.thumbnail_url);
+ if (!file) return { adopted: false, reason: 'no_live_picture' };
+ try {
+ const out = await media.uploadThumbnail('vod', id, fs.readFileSync(file));
+ if (!out || !out.url) return { adopted: false, reason: 'media_refused' };
+ console.log(`[VOD] Recording ${id} took the live picture of stream ${streamId} as its thumbnail`);
+ return { adopted: true };
+ } catch (err) {
+ return { adopted: false, reason: err.message };
+ }
+}
+
+module.exports = { adoptForVod, livePictureFile };
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+/**
+ * OpenVibe.Live — a live stream's cheap preview_url
+ *
+ * A live card's hover preview does not need a second encode: while a stream is being recorded, Media already serves the
+ * recording's DVR as an EVENT HLS playlist at the live edge (GET /v/<vod>/dvr.m3u8, server/public/routes.js in
+ * OpenVibe.Media). It is full-resolution, not a low-bitrate ladder, but it costs one request and no extra copy — the
+ * closest thing Live already has to a live preview, so that is what a live stream's preview_url names.
+ *
+ * The playlist exists only for an MP4 recording in progress (a browser MediaRecorder stream may be WebM), and never
+ * for a private one: Media answers 404 then and the player keeps the thumbnail/recording URL. Null when there is no
+ * active recording to preview.
+ */
+'use strict';
+const media = require('../media-client');
+
+/** The preview URL for one live stream right now, or null. */
+function previewUrlFor(streamId, { recorder = null } = {}) {
+ try {
+ const rec = (recorder || require('../streaming/recorder')).activeRecordings.get(Number(streamId));
+ if (!rec || !rec.vodId) return null;
+ if (String(rec.protocol || '').toLowerCase() === 'jsmpeg') return null;
+ return media.vodDvrPlaylistUrl(rec.vodId);
+ } catch { return null; }
+}
+
+/** Set preview_url on each stream row (null when there is nothing to preview). */
+function attach(streams, opts = {}) {
+ for (const s of streams || []) {
+ if (s && s.id != null) s.preview_url = previewUrlFor(s.id, opts);
+ }
+ return streams;
+}
+
+module.exports = { previewUrlFor, attach };
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
* OpenVibe.Live — live-stream thumbnails (Live-local, ephemeral)
* OpenVibe.Live — live-stream thumbnails (Live-local, ephemeral)
*
*
* VOD/clip/paste thumbnails moved to OpenVibe.Media, but LIVE stream thumbnails
* VOD/clip/paste thumbnails moved to OpenVibe.Media, but LIVE stream thumbnails
- * are transient live-state (refreshed every ~2 min, deleted an hour after the
- * stream ends), so they stay local: captured here (client canvas POST, RTMP FLV
+ * are transient live-state (refreshed every ~30 s while watched, deleted an hour
+ * after the stream ends), so they stay local: captured here (client canvas POST, RTMP FLV
* grab, or JSMPEG relay tap), written to data/live-thumbs/, and served from
* grab, or JSMPEG relay tap), written to data/live-thumbs/, and served from
* /api/thumbnails/stream-<id>-<ts>.jpg by the thumbnails proxy router.
* /api/thumbnails/stream-<id>-<ts>.jpg by the thumbnails proxy router.
*
*
@@ -21,7 +21,7 @@ const db = require('../db/database');
@@ -21,7 +21,7 @@ const db = require('../db/database');
const THUMB_DIR = require('../paths').dir('LIVE_THUMBS_PATH', 'live-thumbs');
const THUMB_DIR = require('../paths').dir('LIVE_THUMBS_PATH', 'live-thumbs');
const THUMB_WIDTH = 640;
const THUMB_WIDTH = 640;
const THUMB_QUALITY = 6;
const THUMB_QUALITY = 6;
-const LIVE_THUMB_MIN_INTERVAL_MS = 120000;
+const LIVE_THUMB_MIN_INTERVAL_MS = 30000;
const CLIENT_THUMB_WRITE_MIN_INTERVAL_MS = 15000;
const CLIENT_THUMB_WRITE_MIN_INTERVAL_MS = 15000;
const activeLiveThumbnailJobs = new Set();
const activeLiveThumbnailJobs = new Set();
@@ -127,6 +127,9 @@ async function apply(event, data) {
@@ -127,6 +127,9 @@ async function apply(event, data) {
outcome: 'vod_ready',
outcome: 'vod_ready',
after: async () => {
after: async () => {
try { require('../streaming/recorder').onVodSettled(vodId); } catch { /* */ }
try { require('../streaming/recorder').onVodSettled(vodId); } catch { /* */ }
+ // No picture of its own? A recording made from a live stream takes that stream's last frame
+ // (media-proxy/live-picture-fallback.js), still on disk for an hour after the stream ended.
+ try { await require('./live-picture-fallback').adoptForVod(vodId, data); } catch { /* best effort */ }
// Kick the on-finalize AI pass right away (both budget-gated).
// Kick the on-finalize AI pass right away (both budget-gated).
try {
try {
const ai = require('../ai/ai-analysis');
const ai = require('../ai/ai-analysis');
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+/**
+ * OpenVibe.Live — live pictures (plan 355)
+ *
+ * Every live stream has a picture within 15 s of going live, and a fresh one about every 30 s while someone is
+ * watching the channel page or the home/list pages. The browser/WHIP publisher posts its own frame every 15 s; this
+ * server-side tick is the fallback for RTMP/JSMPEG and for a browser whose tab is hidden or whose publisher is OBS.
+ *
+ * Cost: one ffmpeg grab per stream per interval, never one per page. The grabbers already dedupe concurrent work
+ * (live-thumbs.js activeLiveThumbnailJobs) and only a frame taken from a keyframe becomes the picture.
+ *
+ * - no picture yet → grab on the first tick after go-live (watched or not: a card must never be blank)
+ * - picture, watched → grab when it is 30 s old
+ * - picture, nobody watching → leave it alone (the live thumbnail of an unwatched stream is not being looked at)
+ * - a grab in the last 15 s → wait (a failing source is not hammered every 5 s)
+ *
+ * "Watching" is the player sockets in the stream's room (broadcast-server), the viewer count Chat reports, or the
+ * stream appearing in a public live list in the last 90 s (the home page and the channel lists call those).
+ */
+'use strict';
+const db = require('../db/database');
+
+const TICK_MS = 5000;
+const PICTURE_INTERVAL_MS = 30000;
+const ATTEMPT_GAP_MS = 15000;
+const LISTED_FRESH_MS = 90000;
+
+const lastAttempt = new Map(); // streamId → when the last grab started
+const listed = new Map(); // streamId → when a public live list last named it
+
+/** A public live list just named these streams: someone may be looking at their cards. */
+function markListed(ids, now = Date.now()) {
+ for (const id of ids || []) {
+ const n = Number(id);
+ if (Number.isSafeInteger(n) && n > 0) listed.set(n, now);
+ }
+ if (listed.size > 5000) {
+ for (const [id, at] of listed) if (now - at > 10 * 60 * 1000) listed.delete(id);
+ }
+}
+
+/** Pure: is anyone watching this stream, by any of the cheap signals? */
+function isWatched({ viewerCount = 0, socketCount = 0, listedAt = 0, now = Date.now() } = {}) {
+ return Number(viewerCount) > 0 || Number(socketCount) > 0 || (Number(listedAt) > 0 && now - Number(listedAt) <= LISTED_FRESH_MS);
+}
+
+/** Pure: should this stream be grabbed now? */
+function shouldGrab({ hasPicture, ageMs = Infinity, watched = false, sinceAttemptMs = Infinity } = {}) {
+ if (sinceAttemptMs < ATTEMPT_GAP_MS) return false;
+ if (!hasPicture) return true; // first picture: always
+ if (!watched) return false; // fresh enough and nobody is looking
+ return !(Number(ageMs) >= 0 && Number(ageMs) < PICTURE_INTERVAL_MS);
+}
+
+/** The video grab for one stream, by protocol; the Media in-progress recording is the WebRTC/WHIP backup. */
+async function grab(stream) {
+ const liveThumbs = require('../media-proxy/live-thumbs');
+ const protocol = String(stream.protocol || '').toLowerCase();
+ if (protocol === 'rtmp') {
+ const rtmpServer = require('./rtmp-server');
+ if (!stream.stream_key || !rtmpServer.isReceiving(stream.stream_key)) return null;
+ return await liveThumbs.generateLiveStreamThumbnail(stream.id, stream.stream_key, { minAgeMs: PICTURE_INTERVAL_MS });
+ }
+ if (protocol === 'jsmpeg') {
+ const relay = require('./jsmpeg-relay');
+ const info = stream.stream_key ? relay.getChannelInfo(stream.stream_key) : null;
+ if (!info || !info.videoPort) return null;
+ return await liveThumbs.generateJSMPEGThumbnail(stream.id, info.videoPort);
+ }
+ const grabbed = await liveThumbs.generateWebrtcThumbnail(stream.id, { minAgeMs: PICTURE_INTERVAL_MS }).catch(() => null);
+ if (grabbed) return grabbed;
+ // No SFU producer (OBS/WHIP in a shape the grabber cannot tap): a frame from Media's in-progress recording.
+ try {
+ const rec = require('./recorder').activeRecordings.get(Number(stream.id));
+ if (!rec || !rec.vodId) return null;
+ const mediaClient = require('../media-client');
+ const out = await mediaClient.generateThumbnail('vod', rec.vodId);
+ const url = mediaClient.publicUrl(out && out.url);
+ if (url) await db.run('UPDATE streams SET thumbnail_url = ? WHERE id = ?', [url, stream.id]);
+ return url;
+ } catch { return null; }
+}
+
+/** One pass over the live streams. Never throws (the timer logs). */
+async function tick({ now = Date.now(), deps = {} } = {}) {
+ if (require('../drill').enabled) return { checked: 0, grabbed: 0 };
+ const liveThumbs = deps.liveThumbs || require('../media-proxy/live-thumbs');
+ const broadcast = deps.broadcast || require('./broadcast-server');
+ const rows = await (deps.liveStreams ? deps.liveStreams()
+ : db.all(`SELECT s.id, s.protocol, s.thumbnail_url, s.viewer_count, COALESCE(ms.stream_key, u.stream_key) AS stream_key
+ FROM streams s JOIN users u ON s.user_id = u.id
+ LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
+ WHERE s.is_live = 1`));
+ let grabbed = 0;
+ for (const stream of rows || []) {
+ let socketCount = 0;
+ try { socketCount = broadcast.getViewerCount(stream.id) || 0; } catch { /* no signaling */ }
+ const watched = isWatched({ viewerCount: stream.viewer_count, socketCount, listedAt: listed.get(Number(stream.id)) || 0, now });
+ let ageMs = Infinity;
+ try { const state = await liveThumbs.getStreamThumbnailState(stream.id); if (state.exists) ageMs = state.ageMs; } catch { /* no file */ }
+ const hasPicture = !!stream.thumbnail_url;
+ const sinceAttemptMs = now - (lastAttempt.get(Number(stream.id)) || 0);
+ if (!shouldGrab({ hasPicture, ageMs, watched, sinceAttemptMs })) continue;
+ lastAttempt.set(Number(stream.id), now);
+ grabbed++;
+ grab(stream).catch((err) => console.warn(`[Thumbnails] live picture for stream ${stream.id}:`, err.message));
+ }
+ // Forget ended streams: their timers must not keep a decision for a later session.
+ const liveIds = new Set((rows || []).map((r) => Number(r.id)));
+ for (const id of lastAttempt.keys()) if (!liveIds.has(id)) lastAttempt.delete(id);
+ for (const id of listed.keys()) if (!liveIds.has(id)) listed.delete(id);
+ return { checked: (rows || []).length, grabbed };
+}
+
+/** Test seam: forget the in-memory decisions. */
+function reset() { lastAttempt.clear(); listed.clear(); }
+
+module.exports = {
+ TICK_MS, PICTURE_INTERVAL_MS, ATTEMPT_GAP_MS, LISTED_FRESH_MS,
+ markListed, isWatched, shouldGrab, tick, reset,
+};
@@ -273,6 +273,9 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -273,6 +273,9 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
// Get live streams (may be multiple with different protocols)
// Get live streams (may be multiple with different protocols)
const liveStreams = await db.getLiveStreamsByUserId(channel.user_id) || [];
const liveStreams = await db.getLiveStreamsByUserId(channel.user_id) || [];
+ // The live card's hover preview: Media's DVR playlist of the recording in progress (media-proxy/live-preview.js).
+ require('../media-proxy/live-preview').attach(liveStreams);
+ require('./live-thumb-cadence').markListed(liveStreams.map((x) => x.id));
for (const liveStream of liveStreams) {
for (const liveStream of liveStreams) {
// Use managed stream key from the JOIN, else fall back to user key
// Use managed stream key from the JOIN, else fall back to user key
const lsKey = liveStream.managed_stream_key
const lsKey = liveStream.managed_stream_key
@@ -735,6 +738,9 @@ router.get('/channel/:username/live', async (req, res) => {
@@ -735,6 +738,9 @@ router.get('/channel/:username/live', async (req, res) => {
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
const liveStreams = await db.getLiveStreamsByUserId(channel.user_id) || [];
const liveStreams = await db.getLiveStreamsByUserId(channel.user_id) || [];
+ // The live card's hover preview: Media's DVR playlist of the recording in progress (media-proxy/live-preview.js).
+ require('../media-proxy/live-preview').attach(liveStreams);
+ require('./live-thumb-cadence').markListed(liveStreams.map((x) => x.id));
for (const liveStream of liveStreams) {
for (const liveStream of liveStreams) {
// Use managed stream key from the JOIN, else fall back to user key
// Use managed stream key from the JOIN, else fall back to user key
const lsKey = liveStream.managed_stream_key
const lsKey = liveStream.managed_stream_key
@@ -1198,6 +1204,8 @@ router.get('/', optionalAuth, async (req, res) => {
@@ -1198,6 +1204,8 @@ router.get('/', optionalAuth, async (req, res) => {
// viewer's prefs say. Every other surface shows it with its chips; this is the only place a label hides.
// viewer's prefs say. Every other surface shows it with its chips; this is the only place a label hides.
let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id)))
let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id)))
&& !contentLabels.labelsOf(s).labels.includes('18+'));
&& !contentLabels.labelsOf(s).labels.includes('18+'));
+ require('../media-proxy/live-preview').attach(streams);
+ require('./live-thumb-cadence').markListed(streams.map((x) => x.id));
// Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.
// Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.
// A stream matches on its own tags or the AI's, without carrying either into the response.
// A stream matches on its own tags or the AI's, without carrying either into the response.
if (wantedTags.length) {
if (wantedTags.length) {
@@ -1,10 +1,12 @@
@@ -1,10 +1,12 @@
/**
/**
* Legacy parity (roadmap D20): live thumbnails refresh on a steady cadence.
* 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
+ * capture a browser broadcaster posts a frame on its 30 s heartbeat (never from a hidden tab);
+ * the server's 5 s live-picture tick grabs one for RTMP, JSMPEG and WebRTC/WHIP streams
+ * whose thumbnail is 30 s old, always for a stream with no picture yet (a first picture
+ * within 15 s of going live) and only while somebody watches (a viewer socket, Chat's
+ * viewer count, or the stream named in a public live list in the last 90 s); 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
* 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
* file (each capture gets a new name); the broadcaster's RTMP/JSMPEG preview polls every
* 10 s
* 10 s
@@ -69,10 +71,10 @@ const get = (p) => new Promise((resolve, reject) => {
@@ -69,10 +71,10 @@ const get = (p) => new Promise((resolve, reject) => {
const first = await liveThumbs.saveLiveThumbnail(sid, jpeg('one'));
const first = await liveThumbs.saveLiveThumbnail(sid, jpeg('one'));
assert.match(first, /^\/api\/thumbnails\/stream-\d+-\d+\.jpg$/);
assert.match(first, /^\/api\/thumbnails\/stream-\d+-\d+\.jpg$/);
assert.ok(!await liveThumbs.shouldRefreshLiveThumbnail(sid), 'a fresh thumbnail is not replaced');
assert.ok(!await liveThumbs.shouldRefreshLiveThumbnail(sid), 'a fresh thumbnail is not replaced');
- age((await current()).filePath, 119_000);
- assert.ok(!await liveThumbs.shouldRefreshLiveThumbnail(sid), 'still kept just under 2 minutes');
- age((await current()).filePath, 121_000);
- assert.ok(await liveThumbs.shouldRefreshLiveThumbnail(sid), 'replaced once 2 minutes old');
+ age((await current()).filePath, 25_000);
+ assert.ok(!await liveThumbs.shouldRefreshLiveThumbnail(sid), 'still kept just under 30 s');
+ age((await current()).filePath, 31_000);
+ assert.ok(await liveThumbs.shouldRefreshLiveThumbnail(sid), 'replaced once 30 s old');
age((await current()).filePath, 5_000);
age((await current()).filePath, 5_000);
assert.strictEqual(await liveThumbs.saveLiveThumbnail(sid, jpeg('two')), first, 'writes closer than 15 s apart are dropped');
assert.strictEqual(await liveThumbs.saveLiveThumbnail(sid, jpeg('two')), first, 'writes closer than 15 s apart are dropped');
age((await current()).filePath, 16_000);
age((await current()).filePath, 16_000);
@@ -81,7 +83,7 @@ const get = (p) => new Promise((resolve, reject) => {
@@ -81,7 +83,7 @@ const get = (p) => new Promise((resolve, reject) => {
const second = await liveThumbs.saveLiveThumbnail(sid, jpeg('two'));
const second = await 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.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');
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');
+ console.log('OK capture: 30 s refresh, 15 s write floor, new name per capture');
// ── the stable name the broadcaster preview polls ──
// ── the stable name the broadcaster preview polls ──
let r = await get(`/api/thumbnails/stream-${sid}-live.jpg?t=1`);
let r = await get(`/api/thumbnails/stream-${sid}-live.jpg?t=1`);
@@ -119,9 +121,23 @@ const get = (p) => new Promise((resolve, reject) => {
@@ -119,9 +121,23 @@ const get = (p) => new Promise((resolve, reject) => {
const loop = index.slice(index.indexOf('const maintenanceInterval = setInterval('), index.indexOf("if (typeof maintenanceInterval.unref === 'function')"));
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, /\}, 60000\);\s*$/, 'the maintenance loop runs every minute');
assert.match(loop, /liveThumbs\.cleanupOldThumbnails\(\);/);
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');
+ assert.ok(!/generateWebrtcThumbnail|generateLiveStreamThumbnail|generateJSMPEGThumbnail/.test(loop), 'the 2-minute grabs are gone from the maintenance loop');
+ assert.match(index, /require\('\.\/streaming\/live-thumb-cadence'\)\.tick\(\)/, 'the live-picture timer calls the cadence tick');
+ assert.match(index, /\}, require\('\.\/streaming\/live-thumb-cadence'\)\.TICK_MS\)/, 'on its own 5 s interval');
+
+ // The cadence itself: first picture at once, 30 s while watched, nothing while nobody looks, 15 s between attempts.
+ const cadence = require('../server/streaming/live-thumb-cadence');
+ assert.strictEqual(cadence.TICK_MS, 5000);
+ assert.strictEqual(cadence.PICTURE_INTERVAL_MS, 30000);
+ assert.strictEqual(cadence.shouldGrab({ hasPicture: false, ageMs: Infinity, watched: false, sinceAttemptMs: Infinity }), true, 'a first picture does not wait for a viewer');
+ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 10_000, watched: true, sinceAttemptMs: Infinity }), false, 'a fresh picture is kept');
+ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 31_000, watched: true, sinceAttemptMs: Infinity }), true, 'a 30 s old picture is refreshed for a viewer');
+ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 31_000, watched: false, sinceAttemptMs: Infinity }), false, 'and left alone for nobody');
+ assert.strictEqual(cadence.shouldGrab({ hasPicture: true, ageMs: 31_000, watched: true, sinceAttemptMs: 5_000 }), false, 'a grab that just started is not doubled');
+ assert.strictEqual(cadence.isWatched({ viewerCount: 2 }), true, 'Chat’s viewer count is a watcher');
+ assert.strictEqual(cadence.isWatched({ socketCount: 1 }), true, 'so is a player socket');
+ assert.strictEqual(cadence.isWatched({ listedAt: Date.now() - 30_000 }), true, 'and a stream on the home/list pages a moment ago');
+ assert.strictEqual(cadence.isWatched({ listedAt: Date.now() - 120_000 }), false, 'but not an old list');
const state = read('public/js/broadcast-state.js');
const state = read('public/js/broadcast-state.js');
const bc = read('public/js/broadcast.js');
const bc = read('public/js/broadcast.js');
@@ -146,6 +162,23 @@ const get = (p) => new Promise((resolve, reject) => {
@@ -146,6 +162,23 @@ const get = (p) => new Promise((resolve, reject) => {
assert.match(home, /_crossfadeThumb\(img, s\.thumbnail_url\);/, 'and crossfade to the new thumbnail');
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');
console.log('OK the capture and display loops keep that cadence');
+ // ── the live preview and the live-picture fallback (plan 355) ──
+ const livePreview = require('../server/media-proxy/live-preview');
+ const empty = { activeRecordings: new Map() };
+ assert.strictEqual(livePreview.previewUrlFor(sid, { recorder: empty }), null, 'no recording: no live preview');
+ const recording = { activeRecordings: new Map([[sid, { vodId: 42, protocol: 'webrtc' }]]) };
+ assert.match(livePreview.previewUrlFor(sid, { recorder: recording }), /\/v\/42\/dvr\.m3u8$/, 'a recording in progress: the DVR playlist');
+ assert.strictEqual(livePreview.previewUrlFor(sid, { recorder: { activeRecordings: new Map([[sid, { vodId: 42, protocol: 'jsmpeg' }]]) } }), null, 'JSMPEG has no DVR');
+ const attached = livePreview.attach([{ id: sid }], { recorder: recording });
+ assert.match(attached[0].preview_url, /\/v\/42\/dvr\.m3u8$/, 'the stream row carries it');
+
+ const fallback = require('../server/media-proxy/live-picture-fallback');
+ await liveThumbs.saveLiveThumbnail(sid, jpeg('four')); // the clean-up above removed the last one
+ const currentUrl = (await db.get('SELECT thumbnail_url FROM streams WHERE id = ?', [sid])).thumbnail_url;
+ assert.strictEqual(fallback.livePictureFile(currentUrl), path.join(liveThumbs.THUMB_DIR, path.basename(currentUrl)),
+ 'the live stream’s current picture is a local file');
+ assert.strictEqual(fallback.livePictureFile('https://openvibe.media/t/vod-9-1.jpg'), null, 'a Media URL is not a live picture');
+
server.close();
server.close();
console.log('\n✅ live thumbnail cadence checks passed');
console.log('\n✅ live thumbnail cadence checks passed');
process.exit(0);
process.exit(0);