openvibers/openvibe.media Commits
Media: thumbnail variants (?w=&fmt=, WebP/AVIF/JPEG on a width ladder), made once and cached (Fabric F4) (#118)
Changes
@@@ -552,7 +552,7 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.| `GET /p/:slug` | **301** to OpenVibe.Community, the canonical home for pastes since 2026-09-22 (every slug, found or not) || `GET /p/:slug/raw` | **301** to Community's raw text || `GET /p/:slug/screenshot` | 301 to a ready, nonprivate legacy screenshot or avatar object; otherwise 301 to Community |--| `GET /t/:id` | thumbnail (id = filename), `noindex` |++| `GET /t/:id` | thumbnail (id = filename), `noindex`. **`?w=` and/or `?fmt=`** answer a variant (Media Fabric F4, `server/thumbnails/variants.js`): the width snaps up to 160, 320, 480 or 640 (never wider than the source), the format is `webp` (the default), `avif` or `jpeg`, named in the URL and never negotiated (Cloudflare ignores `Vary`). Made on the first request and kept in a bounded cache (`THUMBNAIL_VARIANTS_PATH`, default `thumbnail-variants` beside the thumbnails; `MEDIA_THUMB_VARIANTS_MAX_BYTES` 512 MiB, least recently used dropped; `MEDIA_THUMB_VARIANTS_CONCURRENCY` 2). The key holds the source's modification time, so a rewritten live thumbnail gets a fresh variant. Same caching as the original; a busy cache answers the original with `max-age=60`; a bad `w` or `fmt` is a 400. A 640 px VOD thumbnail of about 24 KB is about 13 KB as a 480 px WebP || `GET /f/:key` | file with stored Content-Type + Range, `noindex` || `GET /live/:sel/transcript.json` | **transcript + AI timeline API** — full audio-transcription log and AI overview timeline. `:sel` = slot id / slot slug → slot-scoped sessions; **`@username`** → user-scoped (all their slots, works while offline). Returns `{ live, current, sessions[] (each: ai_overview, transcript, duration…), streamer (overview + stream memories), user }`. `?limit=1..50` sessions (default 10), `?app=`. Cached **30s** (that's the rate limit), CORS-open. || `GET /live/:sel/chat-insight.json` | **chat insight API** — a user's chat-related AI insight/timeline (`:sel` = `@username` or numeric user id): today-vs-alltime chat overviews, condensed memory, event timeline, plus their streamer overview + stream memories when they stream. Proxied from the app's public chat-AI API over loopback; cached **30s**, CORS-open. |@@@ -50,6 +50,13 @@ const config = {},thumbnails: {path: process.env.THUMBNAILS_PATH || './data/thumbnails',++ // Resized and re-encoded thumbnails (GET /t/:id?w=&fmt=, server/thumbnails/variants.js): a rebuildable cache,++ // least recently used dropped over its budget.++ variants: {++ path: process.env.THUMBNAIL_VARIANTS_PATH || '',++ maxBytes: intEnv('MEDIA_THUMB_VARIANTS_MAX_BYTES', 512 * 1024 * 1024),++ concurrency: Math.max(1, intEnv('MEDIA_THUMB_VARIANTS_CONCURRENCY', 2)),++ },},files: {path: process.env.FILES_PATH || './data/files',@@@ -192,6 +199,7 @@ config.vod.clipsPath = path.resolve(config.vod.clipsPath);config.pastes.path = path.resolve(config.pastes.path);config.assets.path = path.resolve(config.assets.path);config.thumbnails.path = path.resolve(config.thumbnails.path);++config.thumbnails.variants.path = path.resolve(config.thumbnails.variants.path || path.join(path.dirname(config.thumbnails.path), 'thumbnail-variants'));config.files.path = path.resolve(config.files.path);config.objects.path = path.resolve(config.objects.path);config.dataDir = path.resolve(config.dataDir);@@@ -10,7 +10,7 @@* GET /p/:slug 301 to OpenVibe.Community — pastes live there now* GET /p/:slug/raw 301 to OpenVibe.Community's raw text* GET /p/:slug/screenshot 301 to the screenshot's or avatar's media object (/o/<id>), else to Community-- * GET /t/:id thumbnails (id = filename)++ * GET /t/:id thumbnails (id = filename); ?w=&fmt= a resized WebP/AVIF/JPEG variant (F4)* GET /f/:key files with correct Content-Type + Range* GET /og-image.png the site's share card* GET /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt crawler files (crawl.js): the sitemap lists only@@@ -290,11 +290,30 @@ function saveLiveThumbnail(appId, streamId, imageData) {}// ── Serve Thumbnail File (public /t/:id) ─────────────────────--function serveThumbnail(req, res) {++let _variants = null;++const variants = () => _variants || (_variants = require('./variants').createVariants({++ dir: config.thumbnails.variants.path, maxBytes: config.thumbnails.variants.maxBytes, concurrency: config.thumbnails.variants.concurrency,++}));++++/** Stream one file with the thumbnail headers. */++function sendFile(res, filePath, size, contentType, cacheControl) {++ res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': size, 'Cache-Control': cacheControl, 'X-Robots-Tag': 'noindex' });++ const stream = fs.createReadStream(filePath);++ const done = () => { try { stream.destroy(); } catch { /* */ } };++ res.on('close', done); res.on('finish', done); res.on('error', done);++ stream.on('error', () => { try { res.destroy(); } catch { /* */ } });++ stream.pipe(res);++}++++async function serveThumbnail(req, res) {if (require('../drill').refuseBytes(res)) return; // a restore drill serves no stored filetry {const filename = path.basename(req.params.id || req.params.filename || ''); // prevent traversalconst filePath = path.join(THUMB_DIR, filename);++ // ?w=&fmt= asks for a variant (Media Fabric F4): a bad value is a 400; the original answers when no variant is++ // asked, the cache is busy, or the variant cannot be made.++ const spec = require('./variants').parse(req.query);++ if (spec && spec.error) return res.status(400).json({ error: spec.error });if (!filename || !fs.existsSync(filePath)) {// Return a 1x1 transparent pixel JPEG as fallback@@@ -321,19 +340,16 @@ function serveThumbnail(req, res) {const isLive = filename.startsWith('stream-');const cacheControl = isLive ? 'public, max-age=30' : 'public, max-age=604800, immutable';-- res.writeHead(200, {-- 'Content-Type': contentType,-- 'Content-Length': stat.size,-- 'Cache-Control': cacheControl,-- 'X-Robots-Tag': 'noindex',-- });-- const stream = fs.createReadStream(filePath);-- const done = () => { try { stream.destroy(); } catch { /* */ } };-- res.on('close', done); res.on('finish', done); res.on('error', done);-- stream.on('error', () => { try { res.destroy(); } catch { /* */ } });-- stream.pipe(res);++ if (spec) {++ let v = null;++ try { v = await variants().get(filePath, spec); } catch { v = null; }++ if (v) return sendFile(res, v.file, v.size, v.type, cacheControl);++ // Busy or unmakeable: the original, cached briefly so the variant is made on a later request.++ return sendFile(res, filePath, stat.size, contentType, 'public, max-age=60');++ }++ sendFile(res, filePath, stat.size, contentType, cacheControl);} catch (err) {-- res.status(500).json({ error: 'Failed to serve thumbnail' });++ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve thumbnail' });}}nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++/**++ * Thumbnail variants (Media Fabric F4, docs/media-fabric.md §5): GET /t/:id?w=<width>&fmt=<format> answers the++ * thumbnail resized to a width on a fixed ladder and re-encoded, so a grid of cards downloads what it shows (a 640 px++ * JPEG of about 24 KB is about 13 KB as a 480 px WebP). A variant is a rebuildable derivative: made on its first++ * request, kept in a bounded local cache, and dropped least recently used when the cache is over its budget.++ *++ * parse(query) → null (no variant asked: the original), { w, fmt }, or { error }++ * w snaps up to the ladder (160, 320, 480, 640; never wider than the source); fmt is webp (the++ * default once w is given), avif or jpeg. The format is named in the URL, never negotiated from++ * Accept: Cloudflare caches by URL and ignores Vary.++ * createVariants({ dir, maxBytes, concurrency, sharp }) → { get(srcPath, spec) → { file, size, type } | null }++ * null when the cache is busy (more than `queueMax` waiting): the caller serves the original.++ *++ * The cache key holds the source's name and modification time, so a live thumbnail that is rewritten gets a fresh++ * variant (the old one ages out). Each variant is written to a temporary name and renamed, so a reader never sees a++ * partial file. The decoder refuses images over 40 megapixels.++ */++const fs = require('fs');++const path = require('path');++++const LADDER = [160, 320, 480, 640];++const FORMATS = Object.freeze({ webp: 'image/webp', avif: 'image/avif', jpeg: 'image/jpeg' });++++function parse(query = {}) {++ const hasW = query.w !== undefined && query.w !== '';++ const hasFmt = query.fmt !== undefined && query.fmt !== '';++ if (!hasW && !hasFmt) return null;++ const fmt = hasFmt ? String(query.fmt).toLowerCase() : 'webp';++ if (!FORMATS[fmt]) return { error: 'fmt must be webp, avif or jpeg' };++ let w = LADDER[LADDER.length - 1];++ if (hasW) {++ const n = Number(query.w);++ if (!Number.isInteger(n) || n < 1 || n > 4096) return { error: 'w must be a whole number of pixels from 1 to 4096' };++ w = LADDER.find((x) => x >= n) || LADDER[LADDER.length - 1];++ }++ return { w, fmt };++}++++function createVariants({ dir, maxBytes = 512 * 1024 * 1024, concurrency = 2, queueMax = 64, sharp = null, log = console } = {}) {++ const inflight = new Map(); // key → promise of the made variant++ const waiting = [];++ let running = 0;++ let total = null; // bytes in the cache, counted on first use++++ const lib = () => sharp || require('sharp');++ const ensureDir = () => fs.mkdirSync(dir, { recursive: true });++ function count() {++ if (total !== null) return;++ ensureDir();++ total = 0;++ for (const f of fs.readdirSync(dir)) {++ try { total += fs.statSync(path.join(dir, f)).size; } catch { /* gone */ }++ }++ }++ async function slot(fn) {++ if (running >= concurrency) await new Promise((resolve) => waiting.push(resolve));++ running++;++ try { return await fn(); } finally {++ running--;++ const next = waiting.shift();++ if (next) next();++ }++ }++ /** Drop the least recently used variants until the cache is under 90 % of its budget. */++ function evict() {++ if (total <= maxBytes) return;++ const files = [];++ for (const f of fs.readdirSync(dir)) {++ if (f.endsWith('.tmp')) continue;++ try { const st = fs.statSync(path.join(dir, f)); files.push({ f, size: st.size, at: st.mtimeMs }); } catch { /* gone */ }++ }++ files.sort((a, b) => a.at - b.at);++ for (const x of files) {++ if (total <= maxBytes * 0.9) break;++ try { fs.unlinkSync(path.join(dir, x.f)); total -= x.size; } catch { /* gone */ }++ }++ }++ async function make(srcPath, key, spec) {++ const file = path.join(dir, key);++ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;++ let img = lib()(srcPath, { limitInputPixels: 40_000_000, failOn: 'error' }).rotate().resize({ width: spec.w, withoutEnlargement: true });++ img = spec.fmt === 'avif' ? img.avif({ quality: 50, effort: 2 }) : spec.fmt === 'jpeg' ? img.jpeg({ quality: 80, mozjpeg: true }) : img.webp({ quality: 75 });++ await img.toFile(tmp);++ fs.renameSync(tmp, file);++ const size = fs.statSync(file).size;++ total += size;++ evict();++ return { file, size, type: FORMATS[spec.fmt] };++ }++++ return {++ LADDER, FORMATS,++ stats: () => ({ bytes: total, running, waiting: waiting.length }),++ async get(srcPath, spec) {++ count();++ const st = fs.statSync(srcPath);++ const key = `${path.basename(srcPath)}.${Math.floor(st.mtimeMs)}.${spec.w}.${spec.fmt}`;++ const file = path.join(dir, key);++ try {++ const hit = fs.statSync(file);++ const now = new Date();++ try { fs.utimesSync(file, now, now); } catch { /* evicted meanwhile */ }++ return { file, size: hit.size, type: FORMATS[spec.fmt], hit: true };++ } catch { /* not made yet */ }++ if (inflight.has(key)) return await inflight.get(key);++ if (running >= concurrency && waiting.length >= queueMax) return null;++ const p = slot(() => make(srcPath, key, spec)).finally(() => inflight.delete(key));++ inflight.set(key, p);++ try { return await p; } catch (err) {++ log.warn(`[Thumbnails] variant ${key}: ${err.message}`);++ throw err;++ }++ },++ };++}++++module.exports = { parse, createVariants, LADDER, FORMATS };nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++// Thumbnail variants (Media Fabric F4, server/thumbnails/variants.js): GET /t/:id?w=&fmt= answers the thumbnail resized++// to a ladder width and re-encoded (WebP by default, AVIF, JPEG), made once and served from the cache after; a bad w or++// fmt is a 400; no query is the original, unchanged. The cache follows the source's modification time, drops the least++// recently used over its budget, and a busy cache gives the original (cached briefly) instead of waiting.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-variants-'));++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++ process.env.THUMBNAILS_PATH = dir('thumbnails');++ process.env.THUMBNAIL_VARIANTS_PATH = path.join(tmp, 'variants');++ const sharp = require('sharp');++ const { parse, createVariants } = require('../server/thumbnails/variants');++ const thumbs = require('../server/thumbnails/thumbnail-service');++ let failed = 0;++ const check = async (name, fn) => {++ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failed++; console.log(` ✗ ${name}\n ${err.stack}`); }++ };++ // A 640×360 JPEG with some detail, like the thumbnails Media cuts.++ const jpeg = (file, color) => sharp({ create: { width: 640, height: 360, channels: 3, background: color } })++ .composite([{ input: Buffer.from('<svg width="640" height="360"><circle cx="320" cy="180" r="120" fill="#fff"/></svg>') }])++ .jpeg({ quality: 85 }).toFile(file);++ await jpeg(path.join(process.env.THUMBNAILS_PATH, 'vod-1-1.jpg'), '#3366cc');++ await jpeg(path.join(process.env.THUMBNAILS_PATH, 'stream-live-1.jpg'), '#cc3333');++++ const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ req.params = { id: decodeURIComponent(u.pathname.replace(/^\/t\//, '')) };++ req.query = Object.fromEntries(u.searchParams);++ res.status = (code) => { res.statusCode = code; return res; };++ res.json = (b) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(b)); };++ thumbs.serveThumbnail(req, res);++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const get = async (p) => {++ const r = await fetch(`http://127.0.0.1:${server.address().port}${p}`);++ return { status: r.status, type: r.headers.get('content-type'), cache: r.headers.get('cache-control'), body: Buffer.from(await r.arrayBuffer()) };++ };++++ await check('parse: no query is the original; w snaps up the ladder; webp by default; bad values are errors', () => {++ assert.strictEqual(parse({}), null);++ assert.deepStrictEqual(parse({ w: '480' }), { w: 480, fmt: 'webp' });++ assert.deepStrictEqual(parse({ w: '200' }), { w: 320, fmt: 'webp' });++ assert.deepStrictEqual(parse({ w: '2000', fmt: 'AVIF' }), { w: 640, fmt: 'avif' });++ assert.deepStrictEqual(parse({ fmt: 'jpeg' }), { w: 640, fmt: 'jpeg' });++ assert.ok(parse({ w: 'abc' }).error && parse({ w: '0' }).error && parse({ w: '1.5' }).error);++ assert.ok(parse({ fmt: 'gif' }).error);++ });++++ await check('GET /t/:id?w=480 answers a 480 px WebP, smaller than the original, with the original\'s caching', async () => {++ const orig = await get('/t/vod-1-1.jpg');++ assert.deepStrictEqual([orig.status, orig.type, orig.cache], [200, 'image/jpeg', 'public, max-age=604800, immutable']);++ const v = await get('/t/vod-1-1.jpg?w=480');++ assert.deepStrictEqual([v.status, v.type, v.cache], [200, 'image/webp', 'public, max-age=604800, immutable']);++ const meta = await sharp(v.body).metadata();++ assert.deepStrictEqual([meta.format, meta.width, meta.height], ['webp', 480, 270]);++ assert.ok(v.body.length < orig.body.length, `${v.body.length} < ${orig.body.length}`);++ const again = await get('/t/vod-1-1.jpg?w=480');++ assert.ok(again.body.equals(v.body), 'served from the cache the second time');++ assert.strictEqual(fs.readdirSync(process.env.THUMBNAIL_VARIANTS_PATH).filter((f) => f.startsWith('vod-1-1.jpg.')).length, 1);++ });++++ await check('AVIF and JPEG variants; a bad w or fmt is a 400; a live thumbnail keeps its short cache', async () => {++ const avif = await get('/t/vod-1-1.jpg?w=320&fmt=avif');++ assert.strictEqual(avif.type, 'image/avif');++ assert.strictEqual((await sharp(avif.body).metadata()).width, 320);++ const jpg = await get('/t/vod-1-1.jpg?w=160&fmt=jpeg');++ assert.deepStrictEqual([jpg.type, (await sharp(jpg.body).metadata()).width], ['image/jpeg', 160]);++ assert.strictEqual((await get('/t/vod-1-1.jpg?w=abc')).status, 400);++ assert.strictEqual((await get('/t/vod-1-1.jpg?fmt=gif')).status, 400);++ const live = await get('/t/stream-live-1.jpg?w=320');++ assert.deepStrictEqual([live.status, live.type, live.cache], [200, 'image/webp', 'public, max-age=30']);++ });++++ await check('a rewritten source gets a fresh variant (the key holds its modification time)', async () => {++ const src = path.join(process.env.THUMBNAILS_PATH, 'stream-live-1.jpg');++ const first = await get('/t/stream-live-1.jpg?w=320');++ await jpeg(src, '#22aa44');++ const later = new Date(Date.now() + 5000);++ fs.utimesSync(src, later, later);++ const second = await get('/t/stream-live-1.jpg?w=320');++ assert.ok(!second.body.equals(first.body), 'the new picture, not the cached old one');++ });++++ await check('the cache drops the least recently used variants over its budget', async () => {++ const vdir = path.join(tmp, 'small-cache');++ const v = createVariants({ dir: vdir, maxBytes: 9000, sharp });++ const src = path.join(process.env.THUMBNAILS_PATH, 'vod-1-1.jpg');++ await v.get(src, { w: 640, fmt: 'jpeg' });++ await v.get(src, { w: 480, fmt: 'jpeg' });++ await v.get(src, { w: 320, fmt: 'jpeg' });++ const left = fs.readdirSync(vdir);++ assert.ok(v.stats().bytes <= 9000, `cache at ${v.stats().bytes} bytes`);++ assert.ok(left.some((f) => f.endsWith('.320.jpeg')), 'the newest stays');++ assert.ok(!left.some((f) => f.endsWith('.640.jpeg')), 'the oldest went');++ });++++ await check('a busy cache answers null (the route then serves the original), never an error', async () => {++ let release;++ const gate = new Promise((r) => { release = r; });++ const slow = { calls: 0 };++ const fake = () => ({ rotate() { return this; }, resize() { return this; }, webp() { return this; }, async toFile(f) { slow.calls++; await gate; fs.writeFileSync(f, 'x'); } });++ const v = createVariants({ dir: path.join(tmp, 'busy'), concurrency: 1, queueMax: 0, sharp: fake });++ const src = path.join(process.env.THUMBNAILS_PATH, 'vod-1-1.jpg');++ const first = v.get(src, { w: 320, fmt: 'webp' });++ await new Promise((r) => setImmediate(r));++ assert.strictEqual(await v.get(src, { w: 480, fmt: 'webp' }), null, 'over the queue: null');++ release();++ assert.strictEqual((await first).type, 'image/webp');++ assert.strictEqual(slow.calls, 1);++ });++++ await check('a missing thumbnail with a variant query is still the transparent pixel', async () => {++ const r = await get('/t/nope.jpg?w=320');++ assert.deepStrictEqual([r.status, r.type, r.cache], [200, 'image/jpeg', 'no-cache']);++ });++++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log(failed ? `thumb-variants: ${failed} failed` : 'thumb-variants: all passed');++ process.exit(failed ? 1 : 0);++})();
@@ -552,7 +552,7 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.
@@ -552,7 +552,7 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.
| `GET /p/:slug` | **301** to OpenVibe.Community, the canonical home for pastes since 2026-09-22 (every slug, found or not) |
| `GET /p/:slug` | **301** to OpenVibe.Community, the canonical home for pastes since 2026-09-22 (every slug, found or not) |
| `GET /p/:slug/raw` | **301** to Community's raw text |
| `GET /p/:slug/raw` | **301** to Community's raw text |
| `GET /p/:slug/screenshot` | 301 to a ready, nonprivate legacy screenshot or avatar object; otherwise 301 to Community |
| `GET /p/:slug/screenshot` | 301 to a ready, nonprivate legacy screenshot or avatar object; otherwise 301 to Community |
-| `GET /t/:id` | thumbnail (id = filename), `noindex` |
+| `GET /t/:id` | thumbnail (id = filename), `noindex`. **`?w=` and/or `?fmt=`** answer a variant (Media Fabric F4, `server/thumbnails/variants.js`): the width snaps up to 160, 320, 480 or 640 (never wider than the source), the format is `webp` (the default), `avif` or `jpeg`, named in the URL and never negotiated (Cloudflare ignores `Vary`). Made on the first request and kept in a bounded cache (`THUMBNAIL_VARIANTS_PATH`, default `thumbnail-variants` beside the thumbnails; `MEDIA_THUMB_VARIANTS_MAX_BYTES` 512 MiB, least recently used dropped; `MEDIA_THUMB_VARIANTS_CONCURRENCY` 2). The key holds the source's modification time, so a rewritten live thumbnail gets a fresh variant. Same caching as the original; a busy cache answers the original with `max-age=60`; a bad `w` or `fmt` is a 400. A 640 px VOD thumbnail of about 24 KB is about 13 KB as a 480 px WebP |
| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
| `GET /live/:sel/transcript.json` | **transcript + AI timeline API** — full audio-transcription log and AI overview timeline. `:sel` = slot id / slot slug → slot-scoped sessions; **`@username`** → user-scoped (all their slots, works while offline). Returns `{ live, current, sessions[] (each: ai_overview, transcript, duration…), streamer (overview + stream memories), user }`. `?limit=1..50` sessions (default 10), `?app=`. Cached **30s** (that's the rate limit), CORS-open. |
| `GET /live/:sel/transcript.json` | **transcript + AI timeline API** — full audio-transcription log and AI overview timeline. `:sel` = slot id / slot slug → slot-scoped sessions; **`@username`** → user-scoped (all their slots, works while offline). Returns `{ live, current, sessions[] (each: ai_overview, transcript, duration…), streamer (overview + stream memories), user }`. `?limit=1..50` sessions (default 10), `?app=`. Cached **30s** (that's the rate limit), CORS-open. |
| `GET /live/:sel/chat-insight.json` | **chat insight API** — a user's chat-related AI insight/timeline (`:sel` = `@username` or numeric user id): today-vs-alltime chat overviews, condensed memory, event timeline, plus their streamer overview + stream memories when they stream. Proxied from the app's public chat-AI API over loopback; cached **30s**, CORS-open. |
| `GET /live/:sel/chat-insight.json` | **chat insight API** — a user's chat-related AI insight/timeline (`:sel` = `@username` or numeric user id): today-vs-alltime chat overviews, condensed memory, event timeline, plus their streamer overview + stream memories when they stream. Proxied from the app's public chat-AI API over loopback; cached **30s**, CORS-open. |
@@ -50,6 +50,13 @@ const config = {
@@ -50,6 +50,13 @@ const config = {
},
},
thumbnails: {
thumbnails: {
path: process.env.THUMBNAILS_PATH || './data/thumbnails',
path: process.env.THUMBNAILS_PATH || './data/thumbnails',
+ // Resized and re-encoded thumbnails (GET /t/:id?w=&fmt=, server/thumbnails/variants.js): a rebuildable cache,
+ // least recently used dropped over its budget.
+ variants: {
+ path: process.env.THUMBNAIL_VARIANTS_PATH || '',
+ maxBytes: intEnv('MEDIA_THUMB_VARIANTS_MAX_BYTES', 512 * 1024 * 1024),
+ concurrency: Math.max(1, intEnv('MEDIA_THUMB_VARIANTS_CONCURRENCY', 2)),
+ },
},
},
files: {
files: {
path: process.env.FILES_PATH || './data/files',
path: process.env.FILES_PATH || './data/files',
@@ -192,6 +199,7 @@ config.vod.clipsPath = path.resolve(config.vod.clipsPath);
@@ -192,6 +199,7 @@ config.vod.clipsPath = path.resolve(config.vod.clipsPath);
config.pastes.path = path.resolve(config.pastes.path);
config.pastes.path = path.resolve(config.pastes.path);
config.assets.path = path.resolve(config.assets.path);
config.assets.path = path.resolve(config.assets.path);
config.thumbnails.path = path.resolve(config.thumbnails.path);
config.thumbnails.path = path.resolve(config.thumbnails.path);
+config.thumbnails.variants.path = path.resolve(config.thumbnails.variants.path || path.join(path.dirname(config.thumbnails.path), 'thumbnail-variants'));
config.files.path = path.resolve(config.files.path);
config.files.path = path.resolve(config.files.path);
config.objects.path = path.resolve(config.objects.path);
config.objects.path = path.resolve(config.objects.path);
config.dataDir = path.resolve(config.dataDir);
config.dataDir = path.resolve(config.dataDir);
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
* GET /p/:slug 301 to OpenVibe.Community — pastes live there now
* GET /p/:slug 301 to OpenVibe.Community — pastes live there now
* GET /p/:slug/raw 301 to OpenVibe.Community's raw text
* GET /p/:slug/raw 301 to OpenVibe.Community's raw text
* GET /p/:slug/screenshot 301 to the screenshot's or avatar's media object (/o/<id>), else to Community
* GET /p/:slug/screenshot 301 to the screenshot's or avatar's media object (/o/<id>), else to Community
- * GET /t/:id thumbnails (id = filename)
+ * GET /t/:id thumbnails (id = filename); ?w=&fmt= a resized WebP/AVIF/JPEG variant (F4)
* GET /f/:key files with correct Content-Type + Range
* GET /f/:key files with correct Content-Type + Range
* GET /og-image.png the site's share card
* GET /og-image.png the site's share card
* GET /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt crawler files (crawl.js): the sitemap lists only
* GET /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt crawler files (crawl.js): the sitemap lists only
@@ -290,11 +290,30 @@ function saveLiveThumbnail(appId, streamId, imageData) {
@@ -290,11 +290,30 @@ function saveLiveThumbnail(appId, streamId, imageData) {
}
}
// ── Serve Thumbnail File (public /t/:id) ─────────────────────
// ── Serve Thumbnail File (public /t/:id) ─────────────────────
-function serveThumbnail(req, res) {
+let _variants = null;
+const variants = () => _variants || (_variants = require('./variants').createVariants({
+ dir: config.thumbnails.variants.path, maxBytes: config.thumbnails.variants.maxBytes, concurrency: config.thumbnails.variants.concurrency,
+}));
+
+/** Stream one file with the thumbnail headers. */
+function sendFile(res, filePath, size, contentType, cacheControl) {
+ res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': size, 'Cache-Control': cacheControl, 'X-Robots-Tag': 'noindex' });
+ const stream = fs.createReadStream(filePath);
+ const done = () => { try { stream.destroy(); } catch { /* */ } };
+ res.on('close', done); res.on('finish', done); res.on('error', done);
+ stream.on('error', () => { try { res.destroy(); } catch { /* */ } });
+ stream.pipe(res);
+}
+
+async function serveThumbnail(req, res) {
if (require('../drill').refuseBytes(res)) return; // a restore drill serves no stored file
if (require('../drill').refuseBytes(res)) return; // a restore drill serves no stored file
try {
try {
const filename = path.basename(req.params.id || req.params.filename || ''); // prevent traversal
const filename = path.basename(req.params.id || req.params.filename || ''); // prevent traversal
const filePath = path.join(THUMB_DIR, filename);
const filePath = path.join(THUMB_DIR, filename);
+ // ?w=&fmt= asks for a variant (Media Fabric F4): a bad value is a 400; the original answers when no variant is
+ // asked, the cache is busy, or the variant cannot be made.
+ const spec = require('./variants').parse(req.query);
+ if (spec && spec.error) return res.status(400).json({ error: spec.error });
if (!filename || !fs.existsSync(filePath)) {
if (!filename || !fs.existsSync(filePath)) {
// Return a 1x1 transparent pixel JPEG as fallback
// Return a 1x1 transparent pixel JPEG as fallback
@@ -321,19 +340,16 @@ function serveThumbnail(req, res) {
@@ -321,19 +340,16 @@ function serveThumbnail(req, res) {
const isLive = filename.startsWith('stream-');
const isLive = filename.startsWith('stream-');
const cacheControl = isLive ? 'public, max-age=30' : 'public, max-age=604800, immutable';
const cacheControl = isLive ? 'public, max-age=30' : 'public, max-age=604800, immutable';
- res.writeHead(200, {
- 'Content-Type': contentType,
- 'Content-Length': stat.size,
- 'Cache-Control': cacheControl,
- 'X-Robots-Tag': 'noindex',
- });
- const stream = fs.createReadStream(filePath);
- const done = () => { try { stream.destroy(); } catch { /* */ } };
- res.on('close', done); res.on('finish', done); res.on('error', done);
- stream.on('error', () => { try { res.destroy(); } catch { /* */ } });
- stream.pipe(res);
+ if (spec) {
+ let v = null;
+ try { v = await variants().get(filePath, spec); } catch { v = null; }
+ if (v) return sendFile(res, v.file, v.size, v.type, cacheControl);
+ // Busy or unmakeable: the original, cached briefly so the variant is made on a later request.
+ return sendFile(res, filePath, stat.size, contentType, 'public, max-age=60');
+ }
+ sendFile(res, filePath, stat.size, contentType, cacheControl);
} catch (err) {
} catch (err) {
- res.status(500).json({ error: 'Failed to serve thumbnail' });
+ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve thumbnail' });
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+/**
+ * Thumbnail variants (Media Fabric F4, docs/media-fabric.md §5): GET /t/:id?w=<width>&fmt=<format> answers the
+ * thumbnail resized to a width on a fixed ladder and re-encoded, so a grid of cards downloads what it shows (a 640 px
+ * JPEG of about 24 KB is about 13 KB as a 480 px WebP). A variant is a rebuildable derivative: made on its first
+ * request, kept in a bounded local cache, and dropped least recently used when the cache is over its budget.
+ *
+ * parse(query) → null (no variant asked: the original), { w, fmt }, or { error }
+ * w snaps up to the ladder (160, 320, 480, 640; never wider than the source); fmt is webp (the
+ * default once w is given), avif or jpeg. The format is named in the URL, never negotiated from
+ * Accept: Cloudflare caches by URL and ignores Vary.
+ * createVariants({ dir, maxBytes, concurrency, sharp }) → { get(srcPath, spec) → { file, size, type } | null }
+ * null when the cache is busy (more than `queueMax` waiting): the caller serves the original.
+ *
+ * The cache key holds the source's name and modification time, so a live thumbnail that is rewritten gets a fresh
+ * variant (the old one ages out). Each variant is written to a temporary name and renamed, so a reader never sees a
+ * partial file. The decoder refuses images over 40 megapixels.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const LADDER = [160, 320, 480, 640];
+const FORMATS = Object.freeze({ webp: 'image/webp', avif: 'image/avif', jpeg: 'image/jpeg' });
+
+function parse(query = {}) {
+ const hasW = query.w !== undefined && query.w !== '';
+ const hasFmt = query.fmt !== undefined && query.fmt !== '';
+ if (!hasW && !hasFmt) return null;
+ const fmt = hasFmt ? String(query.fmt).toLowerCase() : 'webp';
+ if (!FORMATS[fmt]) return { error: 'fmt must be webp, avif or jpeg' };
+ let w = LADDER[LADDER.length - 1];
+ if (hasW) {
+ const n = Number(query.w);
+ if (!Number.isInteger(n) || n < 1 || n > 4096) return { error: 'w must be a whole number of pixels from 1 to 4096' };
+ w = LADDER.find((x) => x >= n) || LADDER[LADDER.length - 1];
+ }
+ return { w, fmt };
+}
+
+function createVariants({ dir, maxBytes = 512 * 1024 * 1024, concurrency = 2, queueMax = 64, sharp = null, log = console } = {}) {
+ const inflight = new Map(); // key → promise of the made variant
+ const waiting = [];
+ let running = 0;
+ let total = null; // bytes in the cache, counted on first use
+
+ const lib = () => sharp || require('sharp');
+ const ensureDir = () => fs.mkdirSync(dir, { recursive: true });
+ function count() {
+ if (total !== null) return;
+ ensureDir();
+ total = 0;
+ for (const f of fs.readdirSync(dir)) {
+ try { total += fs.statSync(path.join(dir, f)).size; } catch { /* gone */ }
+ }
+ }
+ async function slot(fn) {
+ if (running >= concurrency) await new Promise((resolve) => waiting.push(resolve));
+ running++;
+ try { return await fn(); } finally {
+ running--;
+ const next = waiting.shift();
+ if (next) next();
+ }
+ }
+ /** Drop the least recently used variants until the cache is under 90 % of its budget. */
+ function evict() {
+ if (total <= maxBytes) return;
+ const files = [];
+ for (const f of fs.readdirSync(dir)) {
+ if (f.endsWith('.tmp')) continue;
+ try { const st = fs.statSync(path.join(dir, f)); files.push({ f, size: st.size, at: st.mtimeMs }); } catch { /* gone */ }
+ }
+ files.sort((a, b) => a.at - b.at);
+ for (const x of files) {
+ if (total <= maxBytes * 0.9) break;
+ try { fs.unlinkSync(path.join(dir, x.f)); total -= x.size; } catch { /* gone */ }
+ }
+ }
+ async function make(srcPath, key, spec) {
+ const file = path.join(dir, key);
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
+ let img = lib()(srcPath, { limitInputPixels: 40_000_000, failOn: 'error' }).rotate().resize({ width: spec.w, withoutEnlargement: true });
+ img = spec.fmt === 'avif' ? img.avif({ quality: 50, effort: 2 }) : spec.fmt === 'jpeg' ? img.jpeg({ quality: 80, mozjpeg: true }) : img.webp({ quality: 75 });
+ await img.toFile(tmp);
+ fs.renameSync(tmp, file);
+ const size = fs.statSync(file).size;
+ total += size;
+ evict();
+ return { file, size, type: FORMATS[spec.fmt] };
+ }
+
+ return {
+ LADDER, FORMATS,
+ stats: () => ({ bytes: total, running, waiting: waiting.length }),
+ async get(srcPath, spec) {
+ count();
+ const st = fs.statSync(srcPath);
+ const key = `${path.basename(srcPath)}.${Math.floor(st.mtimeMs)}.${spec.w}.${spec.fmt}`;
+ const file = path.join(dir, key);
+ try {
+ const hit = fs.statSync(file);
+ const now = new Date();
+ try { fs.utimesSync(file, now, now); } catch { /* evicted meanwhile */ }
+ return { file, size: hit.size, type: FORMATS[spec.fmt], hit: true };
+ } catch { /* not made yet */ }
+ if (inflight.has(key)) return await inflight.get(key);
+ if (running >= concurrency && waiting.length >= queueMax) return null;
+ const p = slot(() => make(srcPath, key, spec)).finally(() => inflight.delete(key));
+ inflight.set(key, p);
+ try { return await p; } catch (err) {
+ log.warn(`[Thumbnails] variant ${key}: ${err.message}`);
+ throw err;
+ }
+ },
+ };
+}
+
+module.exports = { parse, createVariants, LADDER, FORMATS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+// Thumbnail variants (Media Fabric F4, server/thumbnails/variants.js): GET /t/:id?w=&fmt= answers the thumbnail resized
+// to a ladder width and re-encoded (WebP by default, AVIF, JPEG), made once and served from the cache after; a bad w or
+// fmt is a 400; no query is the original, unchanged. The cache follows the source's modification time, drops the least
+// recently used over its budget, and a busy cache gives the original (cached briefly) instead of waiting.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-variants-'));
+ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+ process.env.THUMBNAILS_PATH = dir('thumbnails');
+ process.env.THUMBNAIL_VARIANTS_PATH = path.join(tmp, 'variants');
+ const sharp = require('sharp');
+ const { parse, createVariants } = require('../server/thumbnails/variants');
+ const thumbs = require('../server/thumbnails/thumbnail-service');
+ let failed = 0;
+ const check = async (name, fn) => {
+ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failed++; console.log(` ✗ ${name}\n ${err.stack}`); }
+ };
+ // A 640×360 JPEG with some detail, like the thumbnails Media cuts.
+ const jpeg = (file, color) => sharp({ create: { width: 640, height: 360, channels: 3, background: color } })
+ .composite([{ input: Buffer.from('<svg width="640" height="360"><circle cx="320" cy="180" r="120" fill="#fff"/></svg>') }])
+ .jpeg({ quality: 85 }).toFile(file);
+ await jpeg(path.join(process.env.THUMBNAILS_PATH, 'vod-1-1.jpg'), '#3366cc');
+ await jpeg(path.join(process.env.THUMBNAILS_PATH, 'stream-live-1.jpg'), '#cc3333');
+
+ const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ req.params = { id: decodeURIComponent(u.pathname.replace(/^\/t\//, '')) };
+ req.query = Object.fromEntries(u.searchParams);
+ res.status = (code) => { res.statusCode = code; return res; };
+ res.json = (b) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(b)); };
+ thumbs.serveThumbnail(req, res);
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const get = async (p) => {
+ const r = await fetch(`http://127.0.0.1:${server.address().port}${p}`);
+ return { status: r.status, type: r.headers.get('content-type'), cache: r.headers.get('cache-control'), body: Buffer.from(await r.arrayBuffer()) };
+ };
+
+ await check('parse: no query is the original; w snaps up the ladder; webp by default; bad values are errors', () => {
+ assert.strictEqual(parse({}), null);
+ assert.deepStrictEqual(parse({ w: '480' }), { w: 480, fmt: 'webp' });
+ assert.deepStrictEqual(parse({ w: '200' }), { w: 320, fmt: 'webp' });
+ assert.deepStrictEqual(parse({ w: '2000', fmt: 'AVIF' }), { w: 640, fmt: 'avif' });
+ assert.deepStrictEqual(parse({ fmt: 'jpeg' }), { w: 640, fmt: 'jpeg' });
+ assert.ok(parse({ w: 'abc' }).error && parse({ w: '0' }).error && parse({ w: '1.5' }).error);
+ assert.ok(parse({ fmt: 'gif' }).error);
+ });
+
+ await check('GET /t/:id?w=480 answers a 480 px WebP, smaller than the original, with the original\'s caching', async () => {
+ const orig = await get('/t/vod-1-1.jpg');
+ assert.deepStrictEqual([orig.status, orig.type, orig.cache], [200, 'image/jpeg', 'public, max-age=604800, immutable']);
+ const v = await get('/t/vod-1-1.jpg?w=480');
+ assert.deepStrictEqual([v.status, v.type, v.cache], [200, 'image/webp', 'public, max-age=604800, immutable']);
+ const meta = await sharp(v.body).metadata();
+ assert.deepStrictEqual([meta.format, meta.width, meta.height], ['webp', 480, 270]);
+ assert.ok(v.body.length < orig.body.length, `${v.body.length} < ${orig.body.length}`);
+ const again = await get('/t/vod-1-1.jpg?w=480');
+ assert.ok(again.body.equals(v.body), 'served from the cache the second time');
+ assert.strictEqual(fs.readdirSync(process.env.THUMBNAIL_VARIANTS_PATH).filter((f) => f.startsWith('vod-1-1.jpg.')).length, 1);
+ });
+
+ await check('AVIF and JPEG variants; a bad w or fmt is a 400; a live thumbnail keeps its short cache', async () => {
+ const avif = await get('/t/vod-1-1.jpg?w=320&fmt=avif');
+ assert.strictEqual(avif.type, 'image/avif');
+ assert.strictEqual((await sharp(avif.body).metadata()).width, 320);
+ const jpg = await get('/t/vod-1-1.jpg?w=160&fmt=jpeg');
+ assert.deepStrictEqual([jpg.type, (await sharp(jpg.body).metadata()).width], ['image/jpeg', 160]);
+ assert.strictEqual((await get('/t/vod-1-1.jpg?w=abc')).status, 400);
+ assert.strictEqual((await get('/t/vod-1-1.jpg?fmt=gif')).status, 400);
+ const live = await get('/t/stream-live-1.jpg?w=320');
+ assert.deepStrictEqual([live.status, live.type, live.cache], [200, 'image/webp', 'public, max-age=30']);
+ });
+
+ await check('a rewritten source gets a fresh variant (the key holds its modification time)', async () => {
+ const src = path.join(process.env.THUMBNAILS_PATH, 'stream-live-1.jpg');
+ const first = await get('/t/stream-live-1.jpg?w=320');
+ await jpeg(src, '#22aa44');
+ const later = new Date(Date.now() + 5000);
+ fs.utimesSync(src, later, later);
+ const second = await get('/t/stream-live-1.jpg?w=320');
+ assert.ok(!second.body.equals(first.body), 'the new picture, not the cached old one');
+ });
+
+ await check('the cache drops the least recently used variants over its budget', async () => {
+ const vdir = path.join(tmp, 'small-cache');
+ const v = createVariants({ dir: vdir, maxBytes: 9000, sharp });
+ const src = path.join(process.env.THUMBNAILS_PATH, 'vod-1-1.jpg');
+ await v.get(src, { w: 640, fmt: 'jpeg' });
+ await v.get(src, { w: 480, fmt: 'jpeg' });
+ await v.get(src, { w: 320, fmt: 'jpeg' });
+ const left = fs.readdirSync(vdir);
+ assert.ok(v.stats().bytes <= 9000, `cache at ${v.stats().bytes} bytes`);
+ assert.ok(left.some((f) => f.endsWith('.320.jpeg')), 'the newest stays');
+ assert.ok(!left.some((f) => f.endsWith('.640.jpeg')), 'the oldest went');
+ });
+
+ await check('a busy cache answers null (the route then serves the original), never an error', async () => {
+ let release;
+ const gate = new Promise((r) => { release = r; });
+ const slow = { calls: 0 };
+ const fake = () => ({ rotate() { return this; }, resize() { return this; }, webp() { return this; }, async toFile(f) { slow.calls++; await gate; fs.writeFileSync(f, 'x'); } });
+ const v = createVariants({ dir: path.join(tmp, 'busy'), concurrency: 1, queueMax: 0, sharp: fake });
+ const src = path.join(process.env.THUMBNAILS_PATH, 'vod-1-1.jpg');
+ const first = v.get(src, { w: 320, fmt: 'webp' });
+ await new Promise((r) => setImmediate(r));
+ assert.strictEqual(await v.get(src, { w: 480, fmt: 'webp' }), null, 'over the queue: null');
+ release();
+ assert.strictEqual((await first).type, 'image/webp');
+ assert.strictEqual(slow.calls, 1);
+ });
+
+ await check('a missing thumbnail with a variant query is still the transparent pixel', async () => {
+ const r = await get('/t/nope.jpg?w=320');
+ assert.deepStrictEqual([r.status, r.type, r.cache], [200, 'image/jpeg', 'no-cache']);
+ });
+
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log(failed ? `thumb-variants: ${failed} failed` : 'thumb-variants: all passed');
+ process.exit(failed ? 1 : 0);
+})();