openvibers/openvibe.media Commits

Media: thumbnail variants (?w=&fmt=, WebP/AVIF/JPEG on a width ladder), made once and cached (Fabric F4) (#118)

OP OpenVibers committed 29 days ago · 8fc7e91c55 · 6 files, +284 −15

**Thumbnail variants (Media Fabric F4, image slice): `GET /t/:id?w=&fmt=` answers the thumbnail resized and re-encoded, made once and cached.**

**Why:** openvibe.video's front page loads 19 thumbnails, 480 KB in all. Each is the 640 × 360 JPEG Media cuts (about 24 KB), shown about 230 px wide. As a 480 px WebP the same picture is about 13 KB. The design (docs/media-fabric.md §5) treats image sizes and formats as rebuildable derivatives, "generated on demand, kept only while cheaper than regenerating". This is that, for thumbnails.

**The route (`server/thumbnails/thumbnail-service.js`, `server/thumbnails/variants.js`):**
- **Width and format.** `w` snaps up to the ladder 160, 320, 480 or 640, never wider than the source. `fmt` is `webp` (the default once `w` is given), `avif` or `jpeg`. The format is in the URL and is never negotiated from `Accept`, because Cloudflare caches by URL and ignores `Vary`. A bad `w` or `fmt` is a 400.
- **No query** is the original, byte for byte as before.
- **Made once.** sharp decodes (40 MP cap, EXIF orientation applied), resizes and encodes: WebP q75, AVIF q50 effort 2, or mozjpeg q80. The output goes to a temporary name and is renamed into place, and a concurrent request for the same variant joins the one in flight.
- **Cache.** The `thumbnail-variants` directory sits beside the thumbnails (`THUMBNAIL_VARIANTS_PATH`). It has a 512 MiB budget (`MEDIA_THUMB_VARIANTS_MAX_BYTES`), and the least recently used variants are dropped below 90 % when it is over. A hit touches the file. The key holds the source's modification time, so a rewritten live thumbnail gets a fresh variant and the old one ages out.
- **Concurrency.** Two encodes at a time (`MEDIA_THUMB_VARIANTS_CONCURRENCY`). When more than 64 wait, or an encode fails, the original answers, cached for only 60 s so a later request makes the variant. A variant request never fails because of the cache.
- **Caching headers** are the original's: immutable for VOD and clip thumbnails, 30 s for live ones. The missing-thumbnail pixel is unchanged.

**Test (`thumb-variants.test.js`, new, seven checks):**
- the ladder and the format parse;
- a 480 px WebP with the original's caching, smaller than the original and served from the cache the second time;
- AVIF and JPEG variants, 400s for bad values, and a live thumbnail's 30 s cache;
- a rewritten source gets a new variant;
- LRU eviction under a small budget;
- a busy cache answers null rather than an error;
- the missing-thumbnail pixel.

The 92 test files that reach the change pass. `media-timeline` failed once under load and passed alone.

**Next:** openvibe.video and Live's grids ask for `?w=…&fmt=webp` with a `srcset`.

Changes

ddiff --git a/README.md b/README.md
iindex 81df467..dd4d1fc 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` |
++| `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. |
ddiff --git a/server/config.js b/server/config.js
iindex 8375802..e8ecb50 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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);
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 219fdca..45bed88 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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
ddiff --git a/server/thumbnails/thumbnail-service.js b/server/thumbnails/thumbnail-service.js
iindex 5abbae1..0fc106c 100644
---- a/server/thumbnails/thumbnail-service.js
++++ b/server/thumbnails/thumbnail-service.js
@@@ -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 file
try {
const filename = path.basename(req.params.id || req.params.filename || ''); // prevent traversal
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)) {
// 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' });
}
}
ddiff --git a/server/thumbnails/variants.js b/server/thumbnails/variants.js
nnew file mode 100644
iindex 0000000..81318b4
---- /dev/null
++++ b/server/thumbnails/variants.js
@@@ -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 };
ddiff --git a/test/thumb-variants.test.js b/test/thumb-variants.test.js
nnew file mode 100644
iindex 0000000..d89a294
---- /dev/null
++++ b/test/thumb-variants.test.js
@@@ -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);
++})();