openvibers/openvibe.live Commits
Card thumbnails arrive over their own colour (L52 step 6) (#564)
Changes
@@@ -75,6 +75,14 @@ function ovImgW(url, w) {}const OV_IMG_RETRY = 'if(this.dataset.full&&this.src!==this.dataset.full){this.src=this.dataset.full;return}';++/** The colour a Media thumbnail's name carries (Media #290: <kind>-<id>-<time>-<rrggbb>.jpg), as '#rrggbb', or ''.++ A card paints it behind the picture, so a slow image arrives over its own colour, not a grey hole (plan L52). */++function ovThumbColour(url) {++ // After the time (10+ digits), so a stream's stable stream-<app>-<id>.jpg never reads its id as a colour.++ const m = /-\d{10,}-([0-9a-f]{6})\.(?:jpe?g|webp|avif|png)(?:[?#].*)?$/i.exec(String(url || ''));++ return m ? `#${m[1].toLowerCase()}` : '';++}++/** Inner img-or-letter for an element that is itself the avatar circle. */function _avatarInner(url, name) {const letter = ((String(name || '?'))[0] || '?').toUpperCase();@@@ -2136,7 +2144,9 @@ function thumbImg(thumbnailUrl, fallbackIcon, alt, regenerateUrl = null) {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)">++ const colour = ovThumbColour(thumbnailUrl);++ const bg = colour ? ` style="background-color:${colour}"` : '';++ return `<img src="${esc(src)}"${set}${bg} alt="${esc(alt || '')}" loading="lazy" data-regenerate-url="${esc(regenerateUrl || '')}" onerror="handleThumbnailError(this)">${iconHtml(' style="display:none"')}`;}if (regenerateUrl) {@@@ -120,6 +120,9 @@// A picture paste is its original upload (one is 2460 px, drawn at 390): Media's 640 px copy, the original once if it// fails, then the placeholder.const img = el('img', { class: 'cf-thumb', src: typeof ovImgW === 'function' ? ovImgW(url, 640) : url, alt: '', loading: 'lazy', decoding: 'async' });++ // The picture's own colour first (Media names a thumbnail with it), so it fills in over that colour.++ const colour = typeof ovThumbColour === 'function' ? ovThumbColour(url) : '';++ if (colour) img.style.backgroundColor = colour;img.addEventListener('error', () => {if (img.src !== url && !img.dataset.retried) { img.dataset.retried = '1'; img.src = url; return; }img.replaceWith(placeholder(fallbackIcon, who));@@@ -1,7 +1,8 @@'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.++// regenerated thumbnail drops the srcset, so the browser shows the new picture rather than the failed variants. A++// thumbnail whose name carries its colour (Media #290) is drawn over that colour until the picture arrives.const assert = require('assert');const fs = require('fs');const path = require('path');@@@ -10,7 +11,7 @@ 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);++vm.runInContext(`${between('function esc(str) {', '\n}\n')}\n}\n${between('function ovThumbColour(', '\n}\n')}\n}\n${between('function thumbImg(', '\nfunction formatDuration(')}\nthis.thumbImg = thumbImg; this.ovThumbColour = ovThumbColour;`, 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);@@@ -23,5 +24,12 @@ assert.ok(other.includes('src="https://example.org/x.jpg"') && !other.includes('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');++const coloured = ctx.thumbImg('https://openvibe.media/t/vod-1-1730000000000-3566ce.jpg', 'fa-video', 'x');++assert.ok(coloured.includes('style="background-color:#3566ce"'), 'its own colour behind the picture');++assert.ok(!media.includes('background-color'), 'an older name has no colour and none is invented');++assert.strictEqual(ctx.ovThumbColour('https://openvibe.media/t/clip-9-1730000000000-ABCDEF.jpg?w=480&fmt=webp'), '#abcdef');++assert.strictEqual(ctx.ovThumbColour('https://openvibe.media/t/stream-live-100234.jpg'), '', 'a stream\'s stable name: its id is not a colour');++assert.strictEqual(ctx.ovThumbColour('https://openvibe.media/t/vod-1-2.jpg'), '');++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');
@@ -75,6 +75,14 @@ function ovImgW(url, w) {
@@ -75,6 +75,14 @@ function ovImgW(url, w) {
}
}
const OV_IMG_RETRY = 'if(this.dataset.full&&this.src!==this.dataset.full){this.src=this.dataset.full;return}';
const OV_IMG_RETRY = 'if(this.dataset.full&&this.src!==this.dataset.full){this.src=this.dataset.full;return}';
+/** The colour a Media thumbnail's name carries (Media #290: <kind>-<id>-<time>-<rrggbb>.jpg), as '#rrggbb', or ''.
+ A card paints it behind the picture, so a slow image arrives over its own colour, not a grey hole (plan L52). */
+function ovThumbColour(url) {
+ // After the time (10+ digits), so a stream's stable stream-<app>-<id>.jpg never reads its id as a colour.
+ const m = /-\d{10,}-([0-9a-f]{6})\.(?:jpe?g|webp|avif|png)(?:[?#].*)?$/i.exec(String(url || ''));
+ return m ? `#${m[1].toLowerCase()}` : '';
+}
+
/** Inner img-or-letter for an element that is itself the avatar circle. */
/** Inner img-or-letter for an element that is itself the avatar circle. */
function _avatarInner(url, name) {
function _avatarInner(url, name) {
const letter = ((String(name || '?'))[0] || '?').toUpperCase();
const letter = ((String(name || '?'))[0] || '?').toUpperCase();
@@ -2136,7 +2144,9 @@ function thumbImg(thumbnailUrl, fallbackIcon, alt, regenerateUrl = null) {
@@ -2136,7 +2144,9 @@ function thumbImg(thumbnailUrl, fallbackIcon, alt, regenerateUrl = null) {
const sized = /^https:\/\/openvibe\.media\/t\/[^?#\s]+$/.test(thumbnailUrl);
const sized = /^https:\/\/openvibe\.media\/t\/[^?#\s]+$/.test(thumbnailUrl);
const src = sized ? `${thumbnailUrl}?w=480&fmt=webp` : 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"` : '';
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)">
+ const colour = ovThumbColour(thumbnailUrl);
+ const bg = colour ? ` style="background-color:${colour}"` : '';
+ return `<img src="${esc(src)}"${set}${bg} 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) {
@@ -120,6 +120,9 @@
@@ -120,6 +120,9 @@
// A picture paste is its original upload (one is 2460 px, drawn at 390): Media's 640 px copy, the original once if it
// A picture paste is its original upload (one is 2460 px, drawn at 390): Media's 640 px copy, the original once if it
// fails, then the placeholder.
// fails, then the placeholder.
const img = el('img', { class: 'cf-thumb', src: typeof ovImgW === 'function' ? ovImgW(url, 640) : url, alt: '', loading: 'lazy', decoding: 'async' });
const img = el('img', { class: 'cf-thumb', src: typeof ovImgW === 'function' ? ovImgW(url, 640) : url, alt: '', loading: 'lazy', decoding: 'async' });
+ // The picture's own colour first (Media names a thumbnail with it), so it fills in over that colour.
+ const colour = typeof ovThumbColour === 'function' ? ovThumbColour(url) : '';
+ if (colour) img.style.backgroundColor = colour;
img.addEventListener('error', () => {
img.addEventListener('error', () => {
if (img.src !== url && !img.dataset.retried) { img.dataset.retried = '1'; img.src = url; return; }
if (img.src !== url && !img.dataset.retried) { img.dataset.retried = '1'; img.src = url; return; }
img.replaceWith(placeholder(fallbackIcon, who));
img.replaceWith(placeholder(fallbackIcon, who));
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
'use strict';
'use strict';
// Card thumbnails (public/js/app.js thumbImg): an openvibe.media thumbnail loads a WebP near the card's width through
// 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
// 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.
+// regenerated thumbnail drops the srcset, so the browser shows the new picture rather than the failed variants. A
+// thumbnail whose name carries its colour (Media #290) is drawn over that colour until the picture arrives.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -10,7 +11,7 @@ const vm = require('vm');
@@ -10,7 +11,7 @@ const vm = require('vm');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');
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 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({});
const ctx = vm.createContext({});
-vm.runInContext(`${between('function esc(str) {', '\n}\n')}\n}\n${between('function thumbImg(', '\nfunction formatDuration(')}\nthis.thumbImg = thumbImg;`, ctx);
+vm.runInContext(`${between('function esc(str) {', '\n}\n')}\n}\n${between('function ovThumbColour(', '\n}\n')}\n}\n${between('function thumbImg(', '\nfunction formatDuration(')}\nthis.thumbImg = thumbImg; this.ovThumbColour = ovThumbColour;`, ctx);
const media = ctx.thumbImg('https://openvibe.media/t/vod-1-2.jpg', 'fa-video', 'A stream', '/api/thumbnails/generate/vod/1');
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('src="https://openvibe.media/t/vod-1-2.jpg?w=480&fmt=webp"'), media);
@@ -23,5 +24,12 @@ assert.ok(other.includes('src="https://example.org/x.jpg"') && !other.includes('
@@ -23,5 +24,12 @@ assert.ok(other.includes('src="https://example.org/x.jpg"') && !other.includes('
const queried = ctx.thumbImg('https://openvibe.media/t/vod-1-2.jpg?t=5', 'fa-video', 'x');
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.ok(!queried.includes('srcset'), 'a URL that already has a query is used as it is');
+const coloured = ctx.thumbImg('https://openvibe.media/t/vod-1-1730000000000-3566ce.jpg', 'fa-video', 'x');
+assert.ok(coloured.includes('style="background-color:#3566ce"'), 'its own colour behind the picture');
+assert.ok(!media.includes('background-color'), 'an older name has no colour and none is invented');
+assert.strictEqual(ctx.ovThumbColour('https://openvibe.media/t/clip-9-1730000000000-ABCDEF.jpg?w=480&fmt=webp'), '#abcdef');
+assert.strictEqual(ctx.ovThumbColour('https://openvibe.media/t/stream-live-100234.jpg'), '', 'a stream\'s stable name: its id is not a colour');
+assert.strictEqual(ctx.ovThumbColour('https://openvibe.media/t/vod-1-2.jpg'), '');
+
assert.match(js, /img\.removeAttribute\('srcset'\);[^\n]*\n\s*img\.src = `\$\{data\.thumbnail_url\}/, 'a regenerated thumbnail drops the srcset before its new src');
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');
console.log('thumb-srcset: all passed');