openvibers/openvibe.media Commits
Live frame API: GET /live/:msid/frame.jpg
Changes
@@@ -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 APIkey or the owning user's JWT.@@@ -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 isnnew file mode 100644@@@ -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 };
@@ -200,6 +200,7 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
@@ -200,6 +200,7 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `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 /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
Private items respond 403/404 unless the request bears the owning app's API
key or the owning user's JWT.
key or the owning user's JWT.
@@ -193,6 +193,38 @@ router.get('/t/:id', (req, res) => {
@@ -193,6 +193,38 @@ router.get('/t/:id', (req, res) => {
require('../thumbnails/thumbnail-service').serveThumbnail(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 ────────────────────────────────────
// ── Legacy thumbnail URLs ────────────────────────────────────
// Old-stack thumbnails lived at /api/thumbnails/<basename>, and migrated Live
// Old-stack thumbnails lived at /api/thumbnails/<basename>, and migrated Live
// rows still carry those absolute URLs. Basenames change when a thumbnail is
// rows still carry those absolute URLs. Basenames change when a thumbnail is
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -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 };