openvibers/openvibe.media Commits

Offline card as real JPEG bytes

OP OpenVibers committed 18 Aug 2026 · 970ebfa37f · 3 files, +30 −6

frame.jpg consumers decode the body as JPEG — rasterize the SVG card via
sharp (60s per-label cache), SVG only as a last-resort render fallback.

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 557c3ea..36db911 100644
---- a/README.md
++++ b/README.md
@@@ -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 API
key or the owning user's JWT.
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 3bcf5db..8cb8ffa 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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', '*');
ddiff --git a/server/thumbnails/live-frame-service.js b/server/thumbnails/live-frame-service.js
iindex 38896f3..f472fe0 100644
---- a/server/thumbnails/live-frame-service.js
++++ b/server/thumbnails/live-frame-service.js
@@@ -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 };