openvibers/openvibe.mediahub Commits

Video: cards load WebP thumbnails near the width they show (srcset over Media's /t/ variants) (#48)

OP OpenVibers committed 29 days ago · b7d3234dad · 2 files, +19 −2

**openvibe.video's cards load WebP thumbnails near the width they show (a `srcset` over Media's new thumbnail variants).**

**Why:** the front page loads 19 thumbnails, 480 KB in all. Each is the 640 × 360 JPEG (about 24 KB) shown about 230 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 (`server/video/pages.js`):** for an `https://openvibe.media/t/…` URL, `thumbImg()` writes `src` as the 480 px WebP and a `srcset` of 320, 480 and 640 px WebP.
- **Grid cards** use `sizes="(max-width: 520px) calc(100vw - 32px), (max-width: 820px) 45vw, 240px"`: one column on a phone, two at mid widths, about 240 px on desktop.
- **Playlist rows** use `sizes="132px"`.
- **Any other image URL** is used as it was.
- Social cards and structured data keep the original JPEG.

Until Media#118 is deployed, Media ignores the query and answers the original, so the deploy order does not matter.

**Test:** `video-pages.test.js` checks the home's `src`, the `srcset` ladder and the `sizes`. The 3 test files that reach the change pass.

Changes

ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex ab45276..cb71586 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -47,6 +47,19 @@ function httpsUrl(u) {
try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; }
}
++/**
++ * A card's thumbnail: openvibe.media's thumbnails come in sizes (Media's /t/:id?w=&fmt=, Fabric F4), so a card asks for
++ * a WebP near the width it shows (about 13 KB for 480 px instead of the 24 KB 640 px JPEG). Any other image is used
++ * as it is. `sizes` is the card's rendered width in the grid (one column on a phone, five across a desktop).
++ */
++const THUMB_WIDTHS = [320, 480, 640];
++function thumbImg(url, sizes) {
++ if (!/^https:\/\/openvibe\.media\/t\/[^?#\s]+$/.test(url || '')) return html`<img src="${url}" alt="" loading="lazy">`;
++ const srcset = THUMB_WIDTHS.map((w) => `${url}?w=${w}&fmt=webp ${w}w`).join(', ');
++ return html`<img src="${`${url}?w=480&fmt=webp`}" srcset="${srcset}" sizes="${sizes}" alt="" loading="lazy">`;
++}
++const CARD_SIZES = '(max-width: 520px) calc(100vw - 32px), (max-width: 820px) 45vw, 240px';
++
/**
* An item's playable file. Live's `file_url` is always the file; its `playback_url` is the HLS playlist when Media has
* one (Live T4 step 5), so it is only the fallback for answers from before `file_url` existed.
@@@ -313,7 +326,7 @@ function meta(parts) {
function card(o) {
const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;
return html`<article class="ovv-card">
--<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? html`<img src="${o.thumb}" alt="" loading="lazy">` : html`<span class="ovv-card-placeholder"><i class="fa-solid ${o.kind === 'clip' ? 'fa-scissors' : 'fa-video'}"></i></span>`}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag">${o.badge}</span>` : ''}</a>
++<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : html`<span class="ovv-card-placeholder"><i class="fa-solid ${o.kind === 'clip' ? 'fa-scissors' : 'fa-video'}"></i></span>`}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag">${o.badge}</span>` : ''}</a>
<div class="ovv-card-body">
<h3 class="ovv-card-title"><a href="${o.href}">${o.title}</a></h3>
${meta([channel, o.date ? html`<time datetime="${o.iso}">${o.date}</time>` : null])}
@@@ -1062,7 +1075,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
const channel = info.username || info.display_name || null;
const dur = info.duration_seconds != null ? info.duration_seconds : info.duration;
rows.push(html`<li class="ovv-item-row">
--<a class="ovv-item-media" href="${href}" tabindex="-1" aria-hidden="true">${thumb ? html`<img src="${thumb}" alt="" loading="lazy">` : html`<span class="ovv-card-placeholder"><i class="fa-solid ${it.kind === 'clip' ? 'fa-scissors' : 'fa-video'}"></i></span>`}${dur != null ? html`<span class="ovv-dur">${durationLabel(dur)}</span>` : ''}</a>
++<a class="ovv-item-media" href="${href}" tabindex="-1" aria-hidden="true">${thumb ? thumbImg(thumb, '132px') : html`<span class="ovv-card-placeholder"><i class="fa-solid ${it.kind === 'clip' ? 'fa-scissors' : 'fa-video'}"></i></span>`}${dur != null ? html`<span class="ovv-dur">${durationLabel(dur)}</span>` : ''}</a>
<div class="ovv-playlist-main"><a class="ovv-item-title" href="${href}">${title}</a>${channel ? html`<span class="ovv-meta">@${channel}</span>` : ''}</div>
${isOwner ? itemControls(pl.id, it) : ''}
</li>`);
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex d923d69..2deced5 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -230,6 +230,10 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('/v/101'));
assert.ok(r.text.includes('/c/201'));
assert.ok(!r.text.includes('/v/102'), 'the unlisted VOD is not in a list');
++ // openvibe.media thumbnails are asked for in sizes: a 480 px WebP by default, 320/480/640 in the srcset.
++ assert.ok(r.text.includes('src="https://openvibe.media/t/101.jpg?w=480&amp;fmt=webp"'), 'a WebP thumbnail near the card width');
++ assert.ok(r.text.includes('https://openvibe.media/t/101.jpg?w=320&amp;fmt=webp 320w, https://openvibe.media/t/101.jpg?w=480&amp;fmt=webp 480w, https://openvibe.media/t/101.jpg?w=640&amp;fmt=webp 640w'), 'the srcset ladder');
++ assert.ok(/sizes="\(max-width: 520px\) calc\(100vw - 32px\)/.test(r.text), 'sizes follows the grid');
});
await check('the VOD watch page has a player, both tracks, chapters and a transcript', async () => {