openvibers/openvibe.live Commits

Live: VOD and clip cards load WebP thumbnails near their width (srcset over Media's /t/ variants) (#127)

OP OpenVibers committed 29 days ago · 88327b4842 · 2 files, +33 −1

**Live's VOD and clip cards load WebP thumbnails near their width (a `srcset` over Media's new thumbnail variants).**

**Why:** the home's recent recordings and clips, the channel pages and the media lists show each 640 × 360 JPEG thumbnail (about 24 KB) in a card about 300 px wide. Media#118 adds `/t/:id?w=&fmt=` (Fabric F4): WebP, AVIF or JPEG at 160, 320, 480 or 640 px, made once and cached. A 480 px WebP is about 13 KB.

**Change (`public/js/app.js`):**
- **`thumbImg()`** writes `src` as the 480 px WebP for an `https://openvibe.media/t/…` URL with no query. It adds a `srcset` of 320, 480 and 640 px WebP with `sizes="(max-width: 600px) 100vw, 320px"`. Any other URL, or one that already has a query, is used as it was.
- **`handleThumbnailError()`** removes the `srcset` before it sets the regenerated thumbnail's `src`; otherwise the browser would keep choosing from the failed variants.
- **Live-stream cards** are unchanged. Their crossfade sets `src` directly, which a `srcset` would override.

Until Media#118 is deployed, Media ignores the query and answers the original, so the deploy order does not matter. Static-only: no restart.

**Test (`thumb-srcset.test.js`, new):**
- `thumbImg()`, run from the shipped file, writes the WebP `src`, the `srcset` ladder and the `sizes`, and keeps the regenerate path;
- another host, or a URL with a query, is unchanged;
- the error handler drops the `srcset` before setting the new `src`.

The home's size budgets hold. The 7 test files that reach the change pass.

Changes

ddiff --git a/public/js/app.js b/public/js/app.js
iindex c9cb60e1..db29b421 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -2021,6 +2021,7 @@ async function handleThumbnailError(img) {
const res = await fetch(regenerateUrl, { method: 'POST', credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (res.ok && data.thumbnail_url) {
++ img.removeAttribute('srcset'); // the sized variants were of the thumbnail that failed
img.src = `${data.thumbnail_url}${data.thumbnail_url.includes('?') ? '&' : '?'}t=${Date.now()}`;
img.style.display = '';
if (fallback) fallback.style.display = 'none';
@@@ -2038,7 +2039,11 @@ function thumbImg(thumbnailUrl, fallbackIcon, alt, regenerateUrl = null) {
? `<span class="ov-mark ov-mark--ph" data-size="44" data-static="1"${extra || ''}></span>`
: `<i class="fa-solid ${fallbackIcon}"${extra || ''}></i>`;
if (thumbnailUrl) {
-- return `<img src="${esc(thumbnailUrl)}" alt="${esc(alt || '')}" loading="lazy" data-regenerate-url="${esc(regenerateUrl || '')}" onerror="handleThumbnailError(this)">
++ // openvibe.media thumbnails come in sizes (Media's /t/:id?w=&fmt=): a card loads a WebP near its width.
++ const sized = /^https:\/\/openvibe\.media\/t\/[^?#\s]+$/.test(thumbnailUrl);
++ const src = sized ? `${thumbnailUrl}?w=480&fmt=webp` : thumbnailUrl;
++ const set = sized ? ` srcset="${esc([320, 480, 640].map((w) => `${thumbnailUrl}?w=${w}&fmt=webp ${w}w`).join(', '))}" sizes="(max-width: 600px) 100vw, 320px"` : '';
++ return `<img src="${esc(src)}"${set} alt="${esc(alt || '')}" loading="lazy" data-regenerate-url="${esc(regenerateUrl || '')}" onerror="handleThumbnailError(this)">
${iconHtml(' style="display:none"')}`;
}
if (regenerateUrl) {
ddiff --git a/test/thumb-srcset.test.js b/test/thumb-srcset.test.js
nnew file mode 100644
iindex 00000000..b89114ac
---- /dev/null
++++ b/test/thumb-srcset.test.js
@@@ -0,0 +1,27 @@
++'use strict';
++// Card thumbnails (public/js/app.js thumbImg): an openvibe.media thumbnail loads a WebP near the card's width through
++// Media's /t/:id?w=&fmt= variants (a 480 px src and a 320/480/640 srcset); any other URL is used as it is; and a
++// regenerated thumbnail drops the srcset, so the browser shows the new picture rather than the failed variants.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');
++const between = (startMark, endMark) => { const a = js.indexOf(startMark); const b = js.indexOf(endMark, a); assert.ok(a >= 0 && b > a, startMark); return js.slice(a, b); };
++const ctx = vm.createContext({});
++vm.runInContext(`${between('function esc(str) {', '\n}\n')}\n}\n${between('function thumbImg(', '\nfunction formatDuration(')}\nthis.thumbImg = thumbImg;`, ctx);
++
++const media = ctx.thumbImg('https://openvibe.media/t/vod-1-2.jpg', 'fa-video', 'A stream', '/api/thumbnails/generate/vod/1');
++assert.ok(media.includes('src="https://openvibe.media/t/vod-1-2.jpg?w=480&amp;fmt=webp"'), media);
++assert.ok(media.includes('srcset="https://openvibe.media/t/vod-1-2.jpg?w=320&amp;fmt=webp 320w, https://openvibe.media/t/vod-1-2.jpg?w=480&amp;fmt=webp 480w, https://openvibe.media/t/vod-1-2.jpg?w=640&amp;fmt=webp 640w"'), media);
++assert.ok(media.includes('sizes="(max-width: 600px) 100vw, 320px"'));
++assert.ok(media.includes('data-regenerate-url="/api/thumbnails/generate/vod/1"'), 'the regenerate path stays');
++
++const other = ctx.thumbImg('https://example.org/x.jpg', 'fa-video', 'x');
++assert.ok(other.includes('src="https://example.org/x.jpg"') && !other.includes('srcset'), 'another host is used as it is');
++const queried = ctx.thumbImg('https://openvibe.media/t/vod-1-2.jpg?t=5', 'fa-video', 'x');
++assert.ok(!queried.includes('srcset'), 'a URL that already has a query is used as it is');
++
++assert.match(js, /img\.removeAttribute\('srcset'\);[^\n]*\n\s*img\.src = `\$\{data\.thumbnail_url\}/, 'a regenerated thumbnail drops the srcset before its new src');
++console.log('thumb-srcset: all passed');