openvibers/openvibe.media Commits

Live frame API: GET /live/:msid/frame.jpg

OP OpenVibers committed 18 Aug 2026 · 1be94aee50 · 3 files, +141 −0

Public dev-facing endpoint — near-realtime JPEG frame of any actively-live
stream slot, cut from its in-progress recording (readable while growing).
5s per-slot cache doubles as the rate limit; concurrent requests share one
in-flight extraction; ?w= scales, ?app= selects tenant; CORS-open.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/README.md b/README.md
iindex dcba234..bdf1113 100644
---- a/README.md
++++ b/README.md
@@@ -200,6 +200,7 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
| `GET /f/screenshots/:name` | paste screenshot by filename — serves straight from `PASTES_PATH/screenshots` (migrated legacy files have no files-table rows), `noindex` |
++| `GET /live/:msid/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot (`msid` = managed stream id), extracted from its in-progress recording. Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`). Cached **5s per slot** (that cache is the rate limit), CORS-open (`Access-Control-Allow-Origin: *`) for external APIs/bots/dashboards. `404` when the slot isn't live, `503` when a frame can't be cut. |
Private items respond 403/404 unless the request bears the owning app's API
key or the owning user's JWT.
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex f20dbcc..7081c95 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -193,6 +193,38 @@ router.get('/t/:id', (req, res) => {
require('../thumbnails/thumbnail-service').serveThumbnail(req, res);
});
++// ── Live frame API (public, dev-facing) ──────────────────────
++// GET /live/:msid/frame.jpg[?w=640][&app=live] — a near-realtime JPEG frame of
++// an actively-live stream slot, extracted from its in-progress recording.
++// Cached 5s per slot (that cache IS the rate limit); CORS-open so external
++// APIs, bots, and dashboards can poll it directly.
++router.get('/live/:msid/frame.jpg', async (req, res) => {
++ try {
++ const msid = parseInt(req.params.msid, 10);
++ if (!Number.isFinite(msid) || msid <= 0) return res.status(400).json({ error: 'Bad slot id' });
++ const appId = String(req.query.app || 'live');
++ let w = parseInt(req.query.w, 10);
++ w = Number.isFinite(w) ? Math.min(1920, Math.max(64, w)) : null;
++
++ const out = await require('../thumbnails/live-frame-service').getLiveFrame(appId, msid, w);
++ res.set('Access-Control-Allow-Origin', '*');
++ if (!out.ok) {
++ return res.status(out.reason === 'not_live' ? 404 : 503)
++ .json({ error: out.reason === 'not_live' ? 'Slot is not live' : 'Frame unavailable' });
++ }
++ res.set({
++ 'Content-Type': 'image/jpeg',
++ 'Content-Length': out.buf.length,
++ 'Cache-Control': 'public, max-age=5',
++ 'X-Robots-Tag': 'noindex',
++ });
++ res.end(out.buf);
++ } catch (err) {
++ console.error('[Public] /live frame error:', err.message);
++ if (!res.headersSent) res.status(500).json({ error: 'Failed to grab frame' });
++ }
++});
++
// ── Legacy thumbnail URLs ────────────────────────────────────
// Old-stack thumbnails lived at /api/thumbnails/<basename>, and migrated Live
// rows still carry those absolute URLs. Basenames change when a thumbnail is
ddiff --git a/server/thumbnails/live-frame-service.js b/server/thumbnails/live-frame-service.js
nnew file mode 100644
iindex 0000000..5cc3cb6
---- /dev/null
++++ b/server/thumbnails/live-frame-service.js
@@@ -0,0 +1,108 @@
++/**
++ * OpenVibe.Media — live frame service
++ *
++ * Serves a near-realtime JPEG frame of any actively-live stream slot,
++ * extracted from the slot's in-progress recording (fragmented mp4 / webm —
++ * both readable while still growing). Public dev-facing feature: external
++ * APIs, bots, and dashboards can poll a plain URL for "what does this stream
++ * look like right now".
++ *
++ * Rate limiting IS the cache: one extraction per slot per CACHE_TTL_MS window
++ * (successes and failures both cached), with concurrent requests for the same
++ * slot sharing a single in-flight extraction.
++ */
++'use strict';
++
++const fs = require('fs');
++const { spawn } = require('child_process');
++const db = require('../db/database');
++
++const CACHE_TTL_MS = 5000;
++const EXTRACT_TIMEOUT_MS = 10000;
++const PROBE_TIMEOUT_MS = 6000;
++
++const _cache = new Map(); // key → { at, out }
++const _inFlight = new Map(); // key → Promise<out>
++
++function _probeDuration(file) {
++ return new Promise((resolve) => {
++ let out = '';
++ let p;
++ try {
++ p = spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration',
++ '-of', 'default=noprint_wrappers=1:nokey=1', file]);
++ } catch { return resolve(0); }
++ const to = setTimeout(() => { try { p.kill('SIGKILL'); } catch { /* */ } resolve(0); }, PROBE_TIMEOUT_MS);
++ p.stdout.on('data', (d) => { out += d; });
++ p.on('close', () => { clearTimeout(to); resolve(parseFloat(out) || 0); });
++ p.on('error', () => { clearTimeout(to); resolve(0); });
++ });
++}
++
++function _extractFrame(file, seekSeconds, width) {
++ return new Promise((resolve) => {
++ const args = ['-v', 'error', '-ss', String(Math.max(0, seekSeconds)), '-i', file, '-vframes', '1'];
++ if (width) args.push('-vf', `scale=${width}:-2`);
++ args.push('-q:v', '4', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1');
++ let ff;
++ try { ff = spawn('ffmpeg', args); } catch { return resolve(null); }
++ const chunks = [];
++ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } }, EXTRACT_TIMEOUT_MS);
++ ff.stdout.on('data', (d) => chunks.push(d));
++ ff.on('close', (code) => {
++ clearTimeout(to);
++ const buf = Buffer.concat(chunks);
++ resolve(code === 0 && buf.length > 100 ? buf : null);
++ });
++ ff.on('error', () => { clearTimeout(to); resolve(null); });
++ });
++}
++
++async function _grab(appId, managedStreamId, width) {
++ const vod = db.get(
++ `SELECT file_path FROM vods
++ WHERE app_id = ? AND managed_stream_id = ? AND is_recording = 1
++ ORDER BY id DESC LIMIT 1`,
++ [appId, managedStreamId]
++ );
++ if (!vod || !vod.file_path || !fs.existsSync(vod.file_path)) {
++ return { ok: false, reason: 'not_live' };
++ }
++ // Seek near the live edge; a short back-off keeps us inside written fragments.
++ const duration = await _probeDuration(vod.file_path);
++ let buf = await _extractFrame(vod.file_path, Math.max(0, duration - 2), width);
++ if (!buf && duration > 4) buf = await _extractFrame(vod.file_path, Math.max(0, duration / 2), width);
++ if (!buf) buf = await _extractFrame(vod.file_path, 0, width);
++ return buf ? { ok: true, buf } : { ok: false, reason: 'unavailable' };
++}
++
++/**
++ * Get a current frame for an actively-live slot.
++ * @returns {Promise<{ok:true, buf:Buffer} | {ok:false, reason:'not_live'|'unavailable'}>}
++ */
++function getLiveFrame(appId, managedStreamId, width = null) {
++ const key = `${appId}:${managedStreamId}:${width || 0}`;
++ const hit = _cache.get(key);
++ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return Promise.resolve(hit.out);
++ if (_inFlight.has(key)) return _inFlight.get(key);
++
++ const p = _grab(appId, managedStreamId, width)
++ .catch((err) => {
++ console.warn('[LiveFrame] grab failed:', err.message);
++ return { ok: false, reason: 'unavailable' };
++ })
++ .then((out) => {
++ _cache.set(key, { at: Date.now(), out });
++ _inFlight.delete(key);
++ // Opportunistic sweep so dead slots don't accumulate.
++ if (_cache.size > 500) {
++ const cutoff = Date.now() - CACHE_TTL_MS;
++ for (const [k, v] of _cache) { if (v.at < cutoff) _cache.delete(k); }
++ }
++ return out;
++ });
++ _inFlight.set(key, p);
++ return p;
++}
++
++module.exports = { getLiveFrame, CACHE_TTL_MS };