openvibers/openvibe.media Commits
Offline card as real JPEG bytes
Changes
@@@ -200,7 +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/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (SVG) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |++| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but 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.@@@ -204,15 +204,23 @@ router.get('/t/:id', (req, res) => {router.get('/live/:sel/frame.jpg', async (req, res) => {const frames = require('../thumbnails/live-frame-service');const wantJson = String(req.query.format || '') === 'json';-- const sendCard = (status, label, subtitle) => {++ const sendCard = async (status, label, subtitle) => {if (wantJson) return res.status(status).json({ error: subtitle ? `${label} ${subtitle}` : `${label} is offline` });-- const svg = frames.offlineCardSvg(label, subtitle);++ // Always JPEG bytes — the URL says .jpg and dev pipelines decode accordingly.++ let buf = null, type = 'image/jpeg';++ try { buf = await frames.offlineCardJpeg(label, subtitle); }++ catch (err) {++ console.warn('[Public] offline card render failed:', err.message);++ buf = Buffer.from(frames.offlineCardSvg(label, subtitle));++ type = 'image/svg+xml; charset=utf-8';++ }res.status(status).set({-- 'Content-Type': 'image/svg+xml; charset=utf-8',++ 'Content-Type': type,++ 'Content-Length': buf.length,'Cache-Control': 'public, max-age=5','X-Robots-Tag': 'noindex',});-- res.end(svg);++ res.end(buf);};try {res.set('Access-Control-Allow-Origin', '*');@@@ -180,4 +180,20 @@ function getLiveFrame(appId, managedStreamId, width = null) {return p;}--module.exports = { getLiveFrame, resolveSelector, offlineCardSvg, CACHE_TTL_MS };++// The endpoint is frame.jpg — dev pipelines feed the bytes straight into image++// decoders, so the placeholder must be a real JPEG too. Rasterize the SVG card++// via sharp, cached per label (the card for a given name rarely changes).++const _cardCache = new Map(); // `${label}|${subtitle}` → { at, buf }++const CARD_CACHE_TTL_MS = 60_000;++async function offlineCardJpeg(label, subtitle) {++ const key = `${label}|${subtitle}`;++ const hit = _cardCache.get(key);++ if (hit && Date.now() - hit.at < CARD_CACHE_TTL_MS) return hit.buf;++ const buf = await require('sharp')(Buffer.from(offlineCardSvg(label, subtitle)))++ .jpeg({ quality: 88 }).toBuffer();++ if (_cardCache.size > 200) _cardCache.clear();++ _cardCache.set(key, { at: Date.now(), buf });++ return buf;++}++++module.exports = { getLiveFrame, resolveSelector, offlineCardSvg, offlineCardJpeg, CACHE_TTL_MS };
@@ -200,7 +200,7 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
@@ -200,7 +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/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (SVG) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |
+| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but 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.
@@ -204,15 +204,23 @@ router.get('/t/:id', (req, res) => {
@@ -204,15 +204,23 @@ router.get('/t/:id', (req, res) => {
router.get('/live/:sel/frame.jpg', async (req, res) => {
router.get('/live/:sel/frame.jpg', async (req, res) => {
const frames = require('../thumbnails/live-frame-service');
const frames = require('../thumbnails/live-frame-service');
const wantJson = String(req.query.format || '') === 'json';
const wantJson = String(req.query.format || '') === 'json';
- const sendCard = (status, label, subtitle) => {
+ const sendCard = async (status, label, subtitle) => {
if (wantJson) return res.status(status).json({ error: subtitle ? `${label} ${subtitle}` : `${label} is offline` });
if (wantJson) return res.status(status).json({ error: subtitle ? `${label} ${subtitle}` : `${label} is offline` });
- const svg = frames.offlineCardSvg(label, subtitle);
+ // Always JPEG bytes — the URL says .jpg and dev pipelines decode accordingly.
+ let buf = null, type = 'image/jpeg';
+ try { buf = await frames.offlineCardJpeg(label, subtitle); }
+ catch (err) {
+ console.warn('[Public] offline card render failed:', err.message);
+ buf = Buffer.from(frames.offlineCardSvg(label, subtitle));
+ type = 'image/svg+xml; charset=utf-8';
+ }
res.status(status).set({
res.status(status).set({
- 'Content-Type': 'image/svg+xml; charset=utf-8',
+ 'Content-Type': type,
+ 'Content-Length': buf.length,
'Cache-Control': 'public, max-age=5',
'Cache-Control': 'public, max-age=5',
'X-Robots-Tag': 'noindex',
'X-Robots-Tag': 'noindex',
});
});
- res.end(svg);
+ res.end(buf);
};
};
try {
try {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Origin', '*');
@@ -180,4 +180,20 @@ function getLiveFrame(appId, managedStreamId, width = null) {
@@ -180,4 +180,20 @@ function getLiveFrame(appId, managedStreamId, width = null) {
return p;
return p;
}
}
-module.exports = { getLiveFrame, resolveSelector, offlineCardSvg, CACHE_TTL_MS };
+// The endpoint is frame.jpg — dev pipelines feed the bytes straight into image
+// decoders, so the placeholder must be a real JPEG too. Rasterize the SVG card
+// via sharp, cached per label (the card for a given name rarely changes).
+const _cardCache = new Map(); // `${label}|${subtitle}` → { at, buf }
+const CARD_CACHE_TTL_MS = 60_000;
+async function offlineCardJpeg(label, subtitle) {
+ const key = `${label}|${subtitle}`;
+ const hit = _cardCache.get(key);
+ if (hit && Date.now() - hit.at < CARD_CACHE_TTL_MS) return hit.buf;
+ const buf = await require('sharp')(Buffer.from(offlineCardSvg(label, subtitle)))
+ .jpeg({ quality: 88 }).toBuffer();
+ if (_cardCache.size > 200) _cardCache.clear();
+ _cardCache.set(key, { at: Date.now(), buf });
+ return buf;
+}
+
+module.exports = { getLiveFrame, resolveSelector, offlineCardSvg, offlineCardJpeg, CACHE_TTL_MS };