openvibers/openvibe.mediahub Commits
Trending rows carry their real thumbnails (Contracts 0.320.0) (#308)
Changes
@@@ -529,7 +529,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.312.0++- openvibe-contracts: v0.320.0- openvibe-sdk: v0.94.0- openvibe-shared: v4.129.0<!-- versions:end -->@@@ -8,7 +8,7 @@"port": "4990","domain": "openvibe.download and openvibe.video (openvibe.pics is parked: it redirects to openvibe.network)","packages": [-- "openvibe-contracts v0.312.0",++ "openvibe-contracts v0.320.0","openvibe-sdk v0.94.0","openvibe-shared v4.129.0"],@@@ -42,7 +42,7 @@"limits and readiness","deploy files"],-- "contracts": "openvibe-contracts v0.312.0",++ "contracts": "openvibe-contracts v0.320.0","sdk": "openvibe-sdk v0.94.0","shared": "openvibe-shared v4.129.0","deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08, https://openvibe.video since 2026-10-12 and https://openvibe.pics since 2026-10-15.",@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0","pg": "^8.23.0",@@@ -1610,9 +1610,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.312.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",++ "version": "0.320.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0",++ "integrity": "sha512-ECShhtflTLicQosxVSEPlBFQn9eSnOUyO//Z2oS2NPscvb5RtZusrVq3xYWcJIs9XiJ7ws2Q9pAWPaRZIpEvwQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -22,7 +22,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0","pg": "^8.23.0",@@@ -6,7 +6,9 @@* Video publishes "Trending": the most viewed public recordings and clips of the last seven days, each linking* to its openvibe.video watch page. The rows come from Live's public Content feed (GET /api/content/feed with* sort=top&window=week), which merges Media's public VODs and clips and already drops private, unlisted and-- * banned-account items; this module only picks the top six, merges the two lists and builds the card.++ * banned-account items; this module only picks the top six, merges the two lists and builds the card. An entry++ * carries the item's own thumbnail (https on openvibe.media, asked for at 640 px and declared 640×360) when the++ * feed has one; no picture is ever a placeholder.** The file is public and CORS *, cached for two minutes. A failed feed keeps the last good card; with none yet it* answers 503, and Network keeps the site's previous card for its own 30-minute window.@@@ -30,7 +32,29 @@ function plain(value, max) {const views = (item) => (Number.isFinite(Number(item && item.views)) ? Number(item.views) : 0);--/** The card itself, most viewed first. `items` are feed items ({ kind, id, title, views }). */++// A card's picture: the 16:9 shape the menu draws, at the 640 px step of Media's thumbnail ladder.++const THUMB_W = 640;++const THUMB_H = 360;++const MEDIA_THUMB = /^https:\/\/openvibe\.media\/t\/[^?#\s]+$/;++++/**++ * The entry's own picture ({ src, w?, h? }) or null. Only a real https thumbnail on an openvibe.* host (or this++ * site's own origin) is sent: the feed's null or a third-party picture is left out, never a placeholder. Media's++ * /t/ thumbnails come in sizes, so a card asks for the 640 px WebP it draws and says it is 640×360.++ */++function imageOf(item) {++ const raw = String((item && item.thumbnail_url) || '').trim();++ if (!raw || raw.length > 400) return null;++ let u;++ try { u = new URL(raw); } catch { return null; }++ if (u.protocol !== 'https:' || u.username || u.password) return null;++ const host = u.hostname.toLowerCase();++ if (host !== 'openvibe.video' && !host.startsWith('openvibe.')) return null;++ if (MEDIA_THUMB.test(raw)) return { src: `${raw}?w=${THUMB_W}&fmt=webp`, w: THUMB_W, h: THUMB_H };++ return { src: raw };++}++++/** The card itself, most viewed first. `items` are feed items ({ kind, id, title, views, thumbnail_url? }). */function cardFrom(items, at = Date.now()) {const seen = new Set();const rows = (Array.isArray(items) ? items : [])@@@ -45,11 +69,14 @@ function cardFrom(items, at = Date.now()) {.slice(0, LIMIT).map((it) => {const n = views(it);-- return {++ const item = {title: plain(it.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording'), 80),meta: `${n.toLocaleString('en-US')} ${n === 1 ? 'view' : 'views'} this week`,url: `${BASE}/${it.kind === 'clip' ? 'c' : 'v'}/${Number(it.id)}`,};++ const image = imageOf(it);++ if (image) item.image = image;++ return item;});return {id: CARD_ID,@@@ -107,4 +134,4 @@ function createMenuCardHandler({ feed, now = Date.now }) {};}--module.exports = { createMenuCardHandler, cardFrom, plain, SITE, CARD_ID, CARD_TTL_S };++module.exports = { createMenuCardHandler, cardFrom, imageOf, plain, SITE, CARD_ID, CARD_TTL_S };@@@ -2,7 +2,8 @@// The menu's network card for OpenVibe.Video (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves the// week's most viewed public recordings and clips from Live's Content feed, CORS *, cached two minutes.const assert = require('assert');--const { createMenuCardHandler, cardFrom, plain, CARD_TTL_S } = require('../server/video/menu-card');++const contracts = require('openvibe-contracts');++const { createMenuCardHandler, cardFrom, imageOf, plain, CARD_TTL_S } = require('../server/video/menu-card');const vod = (id, views, title = `Recording ${id}`) => ({ kind: 'vod', id, title, views });const clip = (id, views, title = `Clip ${id}`) => ({ kind: 'clip', id, title, views });@@@ -22,11 +23,27 @@ assert.deepStrictEqual(card.items, [{ title: 'Recording 3', meta: '30 views this week', url: 'https://openvibe.video/v/3' },]);assert.deepStrictEqual(card.more, { label: 'Watch more', url: 'https://openvibe.video/' });++assert.ok(contracts.validate('frame.menu-cards@1', [card]).valid, 'the file validates against frame.menu-cards@1');for (const item of card.items) {assert.ok(item.title.length <= 80 && item.meta.length <= 120);assert.ok(item.url.startsWith('https://openvibe.video/'));}++// Pictures: the item's own Media thumbnail at 640 px and 640x360; a real picture only, never a placeholder.++const pictured = cardFrom([++ vod(1, 30),++ { kind: 'clip', id: 2, title: 'Clip 2', views: 20, thumbnail_url: 'https://openvibe.media/t/clip-2-1.jpg' },++ { kind: 'vod', id: 3, title: 'Elsewhere', views: 10, thumbnail_url: 'https://www.youtube.com/i/x.jpg' },++ { kind: 'vod', id: 4, title: 'Plain http', views: 5, thumbnail_url: 'http://openvibe.media/t/vod-4-1.jpg' },++ { kind: 'vod', id: 5, title: 'Own origin', views: 1, thumbnail_url: 'https://openvibe.video/live/nova/frame.jpg' },++]);++assert.deepStrictEqual(pictured.items[0], { title: 'Recording 1', meta: '30 views this week', url: 'https://openvibe.video/v/1' }, 'no picture: no image key');++assert.deepStrictEqual(pictured.items[1].image, { src: 'https://openvibe.media/t/clip-2-1.jpg?w=640&fmt=webp', w: 640, h: 360 });++assert.ok(!pictured.items[2].image && !pictured.items[3].image, 'a third-party or plain-http picture is left out');++assert.deepStrictEqual(pictured.items[4].image, { src: 'https://openvibe.video/live/nova/frame.jpg' }, 'another allowed host is used as it is');++assert.ok(contracts.validate('frame.menu-cards@1', [pictured]).valid, 'a pictured file validates against frame.menu-cards@1');++assert.strictEqual(imageOf({ thumbnail_url: 'https://openvibe.media/t/x.jpg?w=480' }).src, 'https://openvibe.media/t/x.jpg?w=480', 'an already-sized thumbnail is not re-queried');++// At most six, one view is singular, a repeated item appears once, and anything that is not a public VOD/clip is dropped.assert.strictEqual(cardFrom(Array.from({ length: 8 }, (_, i) => vod(i + 1, 100 - i))).items.length, 6);assert.strictEqual(cardFrom([clip(1, 1)]).items[0].meta, '1 view this week');
@@ -529,7 +529,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -529,7 +529,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.312.0
+- openvibe-contracts: v0.320.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.129.0
- openvibe-shared: v4.129.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"port": "4990",
"port": "4990",
"domain": "openvibe.download and openvibe.video (openvibe.pics is parked: it redirects to openvibe.network)",
"domain": "openvibe.download and openvibe.video (openvibe.pics is parked: it redirects to openvibe.network)",
"packages": [
"packages": [
- "openvibe-contracts v0.312.0",
+ "openvibe-contracts v0.320.0",
"openvibe-sdk v0.94.0",
"openvibe-sdk v0.94.0",
"openvibe-shared v4.129.0"
"openvibe-shared v4.129.0"
],
],
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
"limits and readiness",
"limits and readiness",
"deploy files"
"deploy files"
],
],
- "contracts": "openvibe-contracts v0.312.0",
+ "contracts": "openvibe-contracts v0.320.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.129.0",
"shared": "openvibe-shared v4.129.0",
"deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08, https://openvibe.video since 2026-10-12 and https://openvibe.pics since 2026-10-15.",
"deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08, https://openvibe.video since 2026-10-12 and https://openvibe.pics since 2026-10-15.",
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1610,9 +1610,9 @@
@@ -1610,9 +1610,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.312.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
+ "version": "0.320.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0",
+ "integrity": "sha512-ECShhtflTLicQosxVSEPlBFQn9eSnOUyO//Z2oS2NPscvb5RtZusrVq3xYWcJIs9XiJ7ws2Q9pAWPaRZIpEvwQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.320.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -6,7 +6,9 @@
@@ -6,7 +6,9 @@
* Video publishes "Trending": the most viewed public recordings and clips of the last seven days, each linking
* Video publishes "Trending": the most viewed public recordings and clips of the last seven days, each linking
* to its openvibe.video watch page. The rows come from Live's public Content feed (GET /api/content/feed with
* to its openvibe.video watch page. The rows come from Live's public Content feed (GET /api/content/feed with
* sort=top&window=week), which merges Media's public VODs and clips and already drops private, unlisted and
* sort=top&window=week), which merges Media's public VODs and clips and already drops private, unlisted and
- * banned-account items; this module only picks the top six, merges the two lists and builds the card.
+ * banned-account items; this module only picks the top six, merges the two lists and builds the card. An entry
+ * carries the item's own thumbnail (https on openvibe.media, asked for at 640 px and declared 640×360) when the
+ * feed has one; no picture is ever a placeholder.
*
*
* The file is public and CORS *, cached for two minutes. A failed feed keeps the last good card; with none yet it
* The file is public and CORS *, cached for two minutes. A failed feed keeps the last good card; with none yet it
* answers 503, and Network keeps the site's previous card for its own 30-minute window.
* answers 503, and Network keeps the site's previous card for its own 30-minute window.
@@ -30,7 +32,29 @@ function plain(value, max) {
@@ -30,7 +32,29 @@ function plain(value, max) {
const views = (item) => (Number.isFinite(Number(item && item.views)) ? Number(item.views) : 0);
const views = (item) => (Number.isFinite(Number(item && item.views)) ? Number(item.views) : 0);
-/** The card itself, most viewed first. `items` are feed items ({ kind, id, title, views }). */
+// A card's picture: the 16:9 shape the menu draws, at the 640 px step of Media's thumbnail ladder.
+const THUMB_W = 640;
+const THUMB_H = 360;
+const MEDIA_THUMB = /^https:\/\/openvibe\.media\/t\/[^?#\s]+$/;
+
+/**
+ * The entry's own picture ({ src, w?, h? }) or null. Only a real https thumbnail on an openvibe.* host (or this
+ * site's own origin) is sent: the feed's null or a third-party picture is left out, never a placeholder. Media's
+ * /t/ thumbnails come in sizes, so a card asks for the 640 px WebP it draws and says it is 640×360.
+ */
+function imageOf(item) {
+ const raw = String((item && item.thumbnail_url) || '').trim();
+ if (!raw || raw.length > 400) return null;
+ let u;
+ try { u = new URL(raw); } catch { return null; }
+ if (u.protocol !== 'https:' || u.username || u.password) return null;
+ const host = u.hostname.toLowerCase();
+ if (host !== 'openvibe.video' && !host.startsWith('openvibe.')) return null;
+ if (MEDIA_THUMB.test(raw)) return { src: `${raw}?w=${THUMB_W}&fmt=webp`, w: THUMB_W, h: THUMB_H };
+ return { src: raw };
+}
+
+/** The card itself, most viewed first. `items` are feed items ({ kind, id, title, views, thumbnail_url? }). */
function cardFrom(items, at = Date.now()) {
function cardFrom(items, at = Date.now()) {
const seen = new Set();
const seen = new Set();
const rows = (Array.isArray(items) ? items : [])
const rows = (Array.isArray(items) ? items : [])
@@ -45,11 +69,14 @@ function cardFrom(items, at = Date.now()) {
@@ -45,11 +69,14 @@ function cardFrom(items, at = Date.now()) {
.slice(0, LIMIT)
.slice(0, LIMIT)
.map((it) => {
.map((it) => {
const n = views(it);
const n = views(it);
- return {
+ const item = {
title: plain(it.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording'), 80),
title: plain(it.title || (it.kind === 'clip' ? 'Clip' : 'Stream recording'), 80),
meta: `${n.toLocaleString('en-US')} ${n === 1 ? 'view' : 'views'} this week`,
meta: `${n.toLocaleString('en-US')} ${n === 1 ? 'view' : 'views'} this week`,
url: `${BASE}/${it.kind === 'clip' ? 'c' : 'v'}/${Number(it.id)}`,
url: `${BASE}/${it.kind === 'clip' ? 'c' : 'v'}/${Number(it.id)}`,
};
};
+ const image = imageOf(it);
+ if (image) item.image = image;
+ return item;
});
});
return {
return {
id: CARD_ID,
id: CARD_ID,
@@ -107,4 +134,4 @@ function createMenuCardHandler({ feed, now = Date.now }) {
@@ -107,4 +134,4 @@ function createMenuCardHandler({ feed, now = Date.now }) {
};
};
}
}
-module.exports = { createMenuCardHandler, cardFrom, plain, SITE, CARD_ID, CARD_TTL_S };
+module.exports = { createMenuCardHandler, cardFrom, imageOf, plain, SITE, CARD_ID, CARD_TTL_S };
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
// The menu's network card for OpenVibe.Video (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves the
// The menu's network card for OpenVibe.Video (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves the
// week's most viewed public recordings and clips from Live's Content feed, CORS *, cached two minutes.
// week's most viewed public recordings and clips from Live's Content feed, CORS *, cached two minutes.
const assert = require('assert');
const assert = require('assert');
-const { createMenuCardHandler, cardFrom, plain, CARD_TTL_S } = require('../server/video/menu-card');
+const contracts = require('openvibe-contracts');
+const { createMenuCardHandler, cardFrom, imageOf, plain, CARD_TTL_S } = require('../server/video/menu-card');
const vod = (id, views, title = `Recording ${id}`) => ({ kind: 'vod', id, title, views });
const vod = (id, views, title = `Recording ${id}`) => ({ kind: 'vod', id, title, views });
const clip = (id, views, title = `Clip ${id}`) => ({ kind: 'clip', id, title, views });
const clip = (id, views, title = `Clip ${id}`) => ({ kind: 'clip', id, title, views });
@@ -22,11 +23,27 @@ assert.deepStrictEqual(card.items, [
@@ -22,11 +23,27 @@ assert.deepStrictEqual(card.items, [
{ title: 'Recording 3', meta: '30 views this week', url: 'https://openvibe.video/v/3' },
{ title: 'Recording 3', meta: '30 views this week', url: 'https://openvibe.video/v/3' },
]);
]);
assert.deepStrictEqual(card.more, { label: 'Watch more', url: 'https://openvibe.video/' });
assert.deepStrictEqual(card.more, { label: 'Watch more', url: 'https://openvibe.video/' });
+assert.ok(contracts.validate('frame.menu-cards@1', [card]).valid, 'the file validates against frame.menu-cards@1');
for (const item of card.items) {
for (const item of card.items) {
assert.ok(item.title.length <= 80 && item.meta.length <= 120);
assert.ok(item.title.length <= 80 && item.meta.length <= 120);
assert.ok(item.url.startsWith('https://openvibe.video/'));
assert.ok(item.url.startsWith('https://openvibe.video/'));
}
}
+// Pictures: the item's own Media thumbnail at 640 px and 640x360; a real picture only, never a placeholder.
+const pictured = cardFrom([
+ vod(1, 30),
+ { kind: 'clip', id: 2, title: 'Clip 2', views: 20, thumbnail_url: 'https://openvibe.media/t/clip-2-1.jpg' },
+ { kind: 'vod', id: 3, title: 'Elsewhere', views: 10, thumbnail_url: 'https://www.youtube.com/i/x.jpg' },
+ { kind: 'vod', id: 4, title: 'Plain http', views: 5, thumbnail_url: 'http://openvibe.media/t/vod-4-1.jpg' },
+ { kind: 'vod', id: 5, title: 'Own origin', views: 1, thumbnail_url: 'https://openvibe.video/live/nova/frame.jpg' },
+]);
+assert.deepStrictEqual(pictured.items[0], { title: 'Recording 1', meta: '30 views this week', url: 'https://openvibe.video/v/1' }, 'no picture: no image key');
+assert.deepStrictEqual(pictured.items[1].image, { src: 'https://openvibe.media/t/clip-2-1.jpg?w=640&fmt=webp', w: 640, h: 360 });
+assert.ok(!pictured.items[2].image && !pictured.items[3].image, 'a third-party or plain-http picture is left out');
+assert.deepStrictEqual(pictured.items[4].image, { src: 'https://openvibe.video/live/nova/frame.jpg' }, 'another allowed host is used as it is');
+assert.ok(contracts.validate('frame.menu-cards@1', [pictured]).valid, 'a pictured file validates against frame.menu-cards@1');
+assert.strictEqual(imageOf({ thumbnail_url: 'https://openvibe.media/t/x.jpg?w=480' }).src, 'https://openvibe.media/t/x.jpg?w=480', 'an already-sized thumbnail is not re-queried');
+
// At most six, one view is singular, a repeated item appears once, and anything that is not a public VOD/clip is dropped.
// At most six, one view is singular, a repeated item appears once, and anything that is not a public VOD/clip is dropped.
assert.strictEqual(cardFrom(Array.from({ length: 8 }, (_, i) => vod(i + 1, 100 - i))).items.length, 6);
assert.strictEqual(cardFrom(Array.from({ length: 8 }, (_, i) => vod(i + 1, 100 - i))).items.length, 6);
assert.strictEqual(cardFrom([clip(1, 1)]).items[0].meta, '1 view this week');
assert.strictEqual(cardFrom([clip(1, 1)]).items[0].meta, '1 view this week');