openvibers/openvibe.media Commits

Media: public image objects answer ?w=&fmt= variants (avatars), ladder adds 64 and 96 (#120)

OP OpenVibers committed 29 days ago · 6e2af81cff · 6 files, +56 −6

Changes

ddiff --git a/README.md b/README.md
iindex 042bd79..e49453f 100644
---- a/README.md
++++ b/README.md
@@@ -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`. **`?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 /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 64, 96, 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. |
@@@ -583,7 +583,9 @@ someone else's private one is the missing answer, anything else 403. The media i
thumbnail only while its VOD or clip is public. Tests: `test/security-*.test.js`.
`GET /o/:id` serves object bytes by canonical id (`med_…`): public/unlisted
--objects openly, private ones only with a valid signature from
++objects openly (and, for a JPEG, PNG, WebP, AVIF or GIF with a local copy, `?w=&fmt=` answers a resized variant exactly
++as `/t/:id` does, from its own cache `OBJECT_VARIANTS_PATH`, default `object-variants`; Network's `/avatar/:username?s=`
++and the shared navbar ask for avatars this way; without a local copy, or when the cache is busy, the original), private ones only with a valid signature from
`/api/v2/:app/objects/:id/download`, deleted ones 410 (a deleted private object
without a signature is a 404 like any other private one). A native object in the
R2 popularity cache redirects to its verified R2 copy; a GET of a native object
ddiff --git a/docs/media-fabric.md b/docs/media-fabric.md
iindex fe55663..f99506c 100644
---- a/docs/media-fabric.md
++++ b/docs/media-fabric.md
@@@ -453,7 +453,10 @@ reads are metered by CDN logs. Nothing here bills delivery.
player moves to HLS.
- **F4 reactive derivatives:** on-demand renditions and image variants, keep-vs-regenerate economics, AV1 for viral
VODs, compute placement. (Slice 1 shipped: one on-demand 720p rung, queued by the master playlist / JSON download
-- and cut into the timeline; the economics, AV1 and compute placement are still open.)
++ and cut into the timeline. Slice 2 shipped 2026-10-15: image variants. `/t/:id?w=&fmt=` and `/o/:id?w=&fmt=` (public
++ images with a local copy) answer WebP, AVIF or JPEG at 64–640 px, kept in bounded LRU caches; openvibe.video's and
++ Live's cards and the shared navbar's avatars ask for them. The economics beyond an LRU budget, AV1 and compute
++ placement are still open.)
- **F5 multi-CDN delivery:** Bunny path (B2 origin), R2 custom-domain path, OpenVibe edge first up to capacity, route
epochs, canary shifts with auto-revert, player QoE beacons, seek SLOs.
- **F6 cost truth:** bill imports, forecasts, budget guards, the owner dashboard.
ddiff --git a/server/config.js b/server/config.js
iindex e8ecb50..6f60184 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -200,6 +200,8 @@ 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'));
++// Variants of public image objects (GET /o/:id?w=&fmt=), beside the thumbnail variants, with the same budget each.
++config.thumbnails.variants.objectsPath = path.resolve(process.env.OBJECT_VARIANTS_PATH || path.join(path.dirname(config.thumbnails.path), 'object-variants'));
config.files.path = path.resolve(config.files.path);
config.objects.path = path.resolve(config.objects.path);
config.dataDir = path.resolve(config.dataDir);
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 25324b8..4bff3f8 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -787,6 +787,12 @@ router.delete('/:id/holds/:holdId', appOnly, async (req, res) => {
const INLINE = /^(image\/(?!svg)|video\/|audio\/|application\/pdf$|text\/plain$)/;
const publicRouter = express.Router();
++// Images a variant can be made of (sharp decodes them; a GIF's first frame); an SVG is never rasterized here.
++const VARIANT_MIME = /^image\/(jpeg|png|webp|avif|gif)$/i;
++let _objectVariants = null;
++const objectVariants = () => _objectVariants || (_objectVariants = require('../thumbnails/variants').createVariants({
++ dir: config.thumbnails.variants.objectsPath, maxBytes: config.thumbnails.variants.maxBytes, concurrency: config.thumbnails.variants.concurrency,
++}));
// ── Segment-native video (docs/media-fabric.md §3, F3.1; MEDIA_HLS_ENABLED) ──
// GET /o/:id/master.m3u8, /o/:id/source/index.m3u8, /o/:id/source/{init.mp4,NNNNNN.m4s} and — for a produced
@@@ -992,6 +998,12 @@ publicRouter.get('/:id', async (req, res) => {
'Cache-Control': obj.visibility === 'private' || sandbox ? 'private, no-store' : 'public, max-age=3600',
'Content-Disposition': contentDisposition(md.filename || obj.id, { inline: INLINE.test(mime) }),
};
++ // ?w=&fmt= on a public or unlisted image asks for a variant (Media Fabric F4, thumbnails/variants.js): resized
++ // from its local copy and cached (an avatar of 18 KB is under 1 KB at 96 px). Without a local copy, or when the
++ // cache is busy or the image cannot be decoded, the original answers.
++ const variants = require('../thumbnails/variants');
++ const spec = VARIANT_MIME.test(mime) && obj.visibility !== 'private' && !sandbox ? variants.parse(req.query) : null;
++ if (spec && spec.error) return res.status(400).json({ error: spec.error });
// A viewer of a native object, counted once a day without being identified (objects/popularity.js); the
// object tiering reads these counts. Sandbox objects are never tiered, so they are not counted.
if (!obj.legacy_ref && !sandbox) await require('./popularity').record(obj, req);
@@@ -1011,6 +1023,15 @@ publicRouter.get('/:id', async (req, res) => {
// A stale `present` local row whose file is gone falls through to the next copy.
// The router's candidates carry their keys: a copy it resolved without a media_locations row (the object's
// own canonical copy) is served too.
++ if (spec) {
++ const local = (decision.candidates || []).find((loc) => loc.provider === 'local' && fs.existsSync(loc.key));
++ let v = null;
++ if (local) { try { v = await objectVariants().get(local.key, spec); } catch { v = null; } }
++ if (v) {
++ res.writeHead(200, { ...headers, 'Content-Type': v.type, 'Content-Length': v.size, 'Content-Disposition': contentDisposition(md.filename || obj.id, { inline: true }) });
++ return fs.createReadStream(v.file).on('error', () => res.destroy()).pipe(res);
++ }
++ }
for (const loc of decision.candidates || []) {
const provider = loc.provider;
if (provider === 'local') {
ddiff --git a/server/thumbnails/variants.js b/server/thumbnails/variants.js
iindex 81318b4..2844d11 100644
---- a/server/thumbnails/variants.js
++++ b/server/thumbnails/variants.js
@@@ -6,7 +6,7 @@
* 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
++ * w snaps up to the ladder (64, 96, 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 }
@@@ -19,7 +19,8 @@
const fs = require('fs');
const path = require('path');
--const LADDER = [160, 320, 480, 640];
++// 64 and 96 are avatar sizes (a 32 or 48 px avatar on a 2x screen); 320–640 are card thumbnails.
++const LADDER = [64, 96, 160, 320, 480, 640];
const FORMATS = Object.freeze({ webp: 'image/webp', avif: 'image/avif', jpeg: 'image/jpeg' });
function parse(query = {}) {
ddiff --git a/test/objects-api.test.js b/test/objects-api.test.js
iindex a3d4924..8b98f28 100644
---- a/test/objects-api.test.js
++++ b/test/objects-api.test.js
@@@ -137,6 +137,27 @@ const crypto = require('crypto');
assert.deepStrictEqual([r.status, r.headers.get('content-range'), r.headers.get('content-type')], [206, `bytes 0-7/${png.length}`, 'image/png']);
console.log('✅ public objects: 302 to the public location, ranged bytes at /o/:id');
++ // ── Image variants (Fabric F4): ?w=&fmt= on a public image resizes its local copy; anything unmakeable is the original ──
++ const realPng = await require('sharp')({ create: { width: 300, height: 200, channels: 3, background: '#3366cc' } }).png().toBuffer();
++ r = await call('POST', O, { body: { kind: 'avatar', visibility: 'public' } });
++ const imgId = r.body.id;
++ await call('PUT', `${O}/${imgId}/content`, { raw: realPng, headers: { 'content-type': 'image/png' } });
++ r = await call('POST', `${O}/${imgId}/complete`);
++ assert.strictEqual(r.body.lifecycle_status, 'ready');
++ const variant = await fetch(`${base}/o/${imgId}?w=96&fmt=webp`);
++ const vbuf = Buffer.from(await variant.arrayBuffer());
++ assert.deepStrictEqual([variant.status, variant.headers.get('content-type'), variant.headers.get('cache-control')], [200, 'image/webp', 'public, max-age=3600']);
++ const vmeta = await require('sharp')(vbuf).metadata();
++ assert.deepStrictEqual([vmeta.format, vmeta.width, vmeta.height], ['webp', 96, 64]);
++ assert.ok(vbuf.length < realPng.length, 'smaller than the original');
++ r = await call('GET', `/o/${imgId}?w=96&fmt=gif`, { bearer: null });
++ assert.strictEqual(r.status, 400, 'a bad format is a 400');
++ r = await call('GET', `/o/${pubId}?w=96`, { bearer: null });
++ assert.deepStrictEqual([r.status, r.headers.get('content-type')], [200, 'image/png'], 'bytes that do not decode: the original');
++ r = await call('GET', `/o/${imgId}`, { bearer: null });
++ assert.deepStrictEqual([r.status, r.headers.get('content-type')], [200, 'image/png'], 'no query: the original');
++ console.log('✅ image variants: a public image in its asked size, the original otherwise');
++
// ── Service tokens ──
r = await call('POST', O, { bearer: tok(), headers: { 'x-ov-subject': `user:${SUBJECT}` }, body: { kind: 'asset', size_bytes: 3 } });
assert.strictEqual(r.status, 201, JSON.stringify(r.body));
@@@ -170,7 +191,7 @@ const crypto = require('crypto');
assert.ok(page1.body.next_cursor);
const page2 = await call('GET', `${O}?limit=2&cursor=${page1.body.next_cursor}`);
const seen = [...page1.body.objects, ...page2.body.objects].map(o => o.id);
-- assert.deepStrictEqual(seen, [svcId, pubId, id], 'newest first, no duplicates');
++ assert.deepStrictEqual(seen, [svcId, imgId, pubId, id], 'newest first, no duplicates');
assert.strictEqual(page2.body.next_cursor, null);
assert.deepStrictEqual((await call('GET', `${O}?kind=screenshot`)).body.objects.map(o => o.id), [pubId]);
assert.deepStrictEqual((await call('GET', `${O}?visibility=private&kind=file`)).body.objects.map(o => o.id), [id]);