openvibers/openvibe.media Commits
Media: public image objects answer ?w=&fmt= variants (avatars), ladder adds 64 and 96 (#120)
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`. **`?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 ithumbnail 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 objectwithout a signature is a 404 like any other private one). A native object in theR2 popularity cache redirects to its verified R2 copy; a GET of a native object@@@ -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 viralVODs, 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, routeepochs, canary shifts with auto-revert, player QoE beacons, seek SLOs.- **F6 cost truth:** bill imports, forecasts, budget guards, the owner dashboard.@@@ -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);@@@ -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') {@@@ -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 = {}) {@@@ -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]);
@@ -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`. **`?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 /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. |
@@ -583,7 +583,9 @@ someone else's private one is the missing answer, anything else 403. The media i
@@ -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`.
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
`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
`/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
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
R2 popularity cache redirects to its verified R2 copy; a GET of a native object
@@ -453,7 +453,10 @@ reads are metered by CDN logs. Nothing here bills delivery.
@@ -453,7 +453,10 @@ reads are metered by CDN logs. Nothing here bills delivery.
player moves to HLS.
player moves to HLS.
- **F4 reactive derivatives:** on-demand renditions and image variants, keep-vs-regenerate economics, AV1 for viral
- **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
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
- **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.
epochs, canary shifts with auto-revert, player QoE beacons, seek SLOs.
- **F6 cost truth:** bill imports, forecasts, budget guards, the owner dashboard.
- **F6 cost truth:** bill imports, forecasts, budget guards, the owner dashboard.
@@ -200,6 +200,8 @@ config.pastes.path = path.resolve(config.pastes.path);
@@ -200,6 +200,8 @@ 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.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.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);
@@ -787,6 +787,12 @@ router.delete('/:id/holds/:holdId', appOnly, async (req, res) => {
@@ -787,6 +787,12 @@ router.delete('/:id/holds/:holdId', appOnly, async (req, res) => {
const INLINE = /^(image\/(?!svg)|video\/|audio\/|application\/pdf$|text\/plain$)/;
const INLINE = /^(image\/(?!svg)|video\/|audio\/|application\/pdf$|text\/plain$)/;
const publicRouter = express.Router();
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) ──
// ── 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
// 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) => {
@@ -992,6 +998,12 @@ publicRouter.get('/:id', async (req, res) => {
'Cache-Control': obj.visibility === 'private' || sandbox ? 'private, no-store' : 'public, max-age=3600',
'Cache-Control': obj.visibility === 'private' || sandbox ? 'private, no-store' : 'public, max-age=3600',
'Content-Disposition': contentDisposition(md.filename || obj.id, { inline: INLINE.test(mime) }),
'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
// 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.
// 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);
if (!obj.legacy_ref && !sandbox) await require('./popularity').record(obj, req);
@@ -1011,6 +1023,15 @@ publicRouter.get('/:id', async (req, res) => {
@@ -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.
// 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
// 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.
// 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 || []) {
for (const loc of decision.candidates || []) {
const provider = loc.provider;
const provider = loc.provider;
if (provider === 'local') {
if (provider === 'local') {
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* request, kept in a bounded local cache, and dropped least recently used when the cache is over its budget.
* 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 }
* 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
* 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.
* Accept: Cloudflare caches by URL and ignores Vary.
* createVariants({ dir, maxBytes, concurrency, sharp }) → { get(srcPath, spec) → { file, size, type } | null }
* createVariants({ dir, maxBytes, concurrency, sharp }) → { get(srcPath, spec) → { file, size, type } | null }
@@ -19,7 +19,8 @@
@@ -19,7 +19,8 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
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' });
const FORMATS = Object.freeze({ webp: 'image/webp', avif: 'image/avif', jpeg: 'image/jpeg' });
function parse(query = {}) {
function parse(query = {}) {
@@ -137,6 +137,27 @@ const crypto = require('crypto');
@@ -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']);
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');
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 ──
// ── Service tokens ──
r = await call('POST', O, { bearer: tok(), headers: { 'x-ov-subject': `user:${SUBJECT}` }, body: { kind: 'asset', size_bytes: 3 } });
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));
assert.strictEqual(r.status, 201, JSON.stringify(r.body));
@@ -170,7 +191,7 @@ const crypto = require('crypto');
@@ -170,7 +191,7 @@ const crypto = require('crypto');
assert.ok(page1.body.next_cursor);
assert.ok(page1.body.next_cursor);
const page2 = await call('GET', `${O}?limit=2&cursor=${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);
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.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}?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]);
assert.deepStrictEqual((await call('GET', `${O}?visibility=private&kind=file`)).body.objects.map(o => o.id), [id]);