openvibers/openvibe.live Commits
Live: VOD and clip cards load WebP thumbnails near their width (srcset over Media's /t/ variants) (#127)
Changes
@@@ -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 failedimg.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) {nnew file mode 100644@@@ -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&fmt=webp"'), media);++assert.ok(media.includes('srcset="https://openvibe.media/t/vod-1-2.jpg?w=320&fmt=webp 320w, https://openvibe.media/t/vod-1-2.jpg?w=480&fmt=webp 480w, https://openvibe.media/t/vod-1-2.jpg?w=640&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');
@@ -2021,6 +2021,7 @@ async function handleThumbnailError(img) {
@@ -2021,6 +2021,7 @@ async function handleThumbnailError(img) {
const res = await fetch(regenerateUrl, { method: 'POST', credentials: 'include' });
const res = await fetch(regenerateUrl, { method: 'POST', credentials: 'include' });
const data = await res.json().catch(() => ({}));
const data = await res.json().catch(() => ({}));
if (res.ok && data.thumbnail_url) {
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.src = `${data.thumbnail_url}${data.thumbnail_url.includes('?') ? '&' : '?'}t=${Date.now()}`;
img.style.display = '';
img.style.display = '';
if (fallback) fallback.style.display = 'none';
if (fallback) fallback.style.display = 'none';
@@ -2038,7 +2039,11 @@ function thumbImg(thumbnailUrl, fallbackIcon, alt, regenerateUrl = null) {
@@ -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>`
? `<span class="ov-mark ov-mark--ph" data-size="44" data-static="1"${extra || ''}></span>`
: `<i class="fa-solid ${fallbackIcon}"${extra || ''}></i>`;
: `<i class="fa-solid ${fallbackIcon}"${extra || ''}></i>`;
if (thumbnailUrl) {
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"')}`;
${iconHtml(' style="display:none"')}`;
}
}
if (regenerateUrl) {
if (regenerateUrl) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -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&fmt=webp"'), media);
+assert.ok(media.includes('srcset="https://openvibe.media/t/vod-1-2.jpg?w=320&fmt=webp 320w, https://openvibe.media/t/vod-1-2.jpg?w=480&fmt=webp 480w, https://openvibe.media/t/vod-1-2.jpg?w=640&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');