openvibers/openvibe.media Commits
openvibe.media's home says what Media is (open media and object storage for OpenVibe and your apps): real counts, what it does, how an app uses it, the limits it enforces and the questions people ask, with the public index kept below (#156)
Changes
@@@ -234,7 +234,7 @@ nav.tabs{display:flex;gap:.25rem;padding:.2rem 0 .7rem;flex-wrap:wrap}nav.tabs a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}nav.tabs a:hover{color:var(--text)}nav.tabs a.on{color:var(--on-accent-strong,#fff);background:var(--accent-strong,#1d4ed8)}nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{opacity:1}--.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem}++.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(250px,100%),1fr));gap:1rem}.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}.card .thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}.card .thumb img{width:100%;height:100%;object-fit:cover;display:block}@@@ -250,9 +250,71 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op.pg{color:var(--acc);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--line);border-radius:8px}.pg.dis{color:var(--muted);opacity:.4}.empty{padding:3rem;text-align:center;color:var(--muted)}++.sec-h{font-size:1.3rem;margin:2rem 0 .6rem}++.hero-stats{margin:0;display:grid;gap:10px}++.hero-stats div{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px}++.hero-stats dt{font-size:1.25rem;font-weight:700}.hero-stats dd{margin:2px 0 0;color:var(--muted);font-size:.92rem}++.faq{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:4px 16px;margin:8px 0}++.faq[open]{padding-bottom:12px}.faq summary{cursor:pointer;font-weight:600;padding:8px 0}.faq p{margin:4px 0 0;color:var(--muted)}`;--const DESCRIPTION = 'Index of all public media on the OpenVibe network — videos, clips, files and thumbnails.';++const DESCRIPTION = 'Videos, clips, pictures and files for every OpenVibe site, and the same API for your apps: objects, S3, thumbnails in any size and HLS.';++const TITLE = 'OpenVibe.Media: open media and object storage';++++/** The home's questions: shown on the first page and given to search engines as FAQPage data (the same text). */++const HOME_FAQ = [++ { q: 'Do I need an account to watch?', a: 'No. Public videos, clips, sounds and files play and download without an account; watch pages for recordings and clips are on OpenVibe.Video.' },++ { q: 'How does my app store files here?', a: 'Over S3 with a project key from OpenVibe.Zone (any S3 tool, endpoint s3.openvibe.media), or through the object API with the openvibe-sdk/media client: single or multipart uploads, resumable, signed URLs.' },++ { q: 'What sizes can a thumbnail be?', a: 'Any public thumbnail answers /t/<id>?w=<width>&fmt=<format>: widths 64, 96, 160, 320, 480 or 640, as WebP, AVIF or JPEG, made once and cached.' },++ { q: 'Can I show what a live stream looks like right now?', a: 'Yes: /live/@<username>/frame.jpg is a near-real-time frame of that streamer\'s live recording (?w= for a width, ?format=json for a status). When they are offline it is an offline card with a 404.' },++ { q: 'What happens to something I delete?', a: 'A deleted object can be restored for a while before it is purged; the limits below say for how long. Its thumbnails and copies go with it.' },++ { q: 'What are the limits?', a: 'They are published as JSON at /limits.json and in the table on this page: storage per project, upload and part sizes, and how long an unfinished upload holds its space.' },++];++function homeFaqJsonLd() {++ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a.replace(/<(\w+)>/g, '$1') } })) };++}++const bytes = (n) => { const v = Number(n) || 0; return v >= 1024 ** 3 ? `${+(v / 1024 ** 3).toFixed(1)} GB` : `${Math.round(v / 1024 ** 2)} MB`; };++++/** The first page's developer half: what Media is for, how an app uses it, the limits it enforces, the questions. */++function developerSections() {++ const L = require('../limits').limitsOf(config).limits;++ const rows = L.filter((l) => ['storage_bytes', 'single_upload_bytes', 'multipart_bytes', 'part_bytes', 'unfinished_upload_hours', 'deleted_retention_days'].includes(l.id))++ .map((l) => ({ label: l.label, values: [l.unit === 'bytes' ? bytes(l.production) : `${l.production} ${l.unit}`, l.unit === 'bytes' ? bytes(l.sandbox) : `${l.sandbox} ${l.unit}`] }));++ return showcase.features({++ id: 'what',++ title: 'What it does',++ lede: 'The media layer under every OpenVibe site, open to your apps through the same API.',++ items: [++ { icon: 'ov:media', title: 'Objects and S3', text: 'Store any file in a project namespace: single or multipart uploads, signed URLs, and an S3 endpoint for the tools you have (through OpenVibe.Zone).', href: 'https://openvibe.zone/' },++ { icon: 'ov:video', title: 'Recordings and clips', text: 'Live recordings, clips cut from them or straight from the stream timeline, and clips trimmed in place. Watch pages are on OpenVibe.Video.', href: 'https://openvibe.video/' },++ { icon: 'ov:live', title: 'Streaming playback', text: 'Byte-range playback for every video, HLS for the ones that have a timeline, so players can seek without downloading.' },++ { icon: 'ov:pics', title: 'Thumbnails in any size', text: '/t/<id>?w=&fmt= gives WebP, AVIF or JPEG at six widths, so a card loads about half the bytes.' },++ { icon: 'ov:camera', title: 'Live frames', text: '/live/@<name>/frame.jpg: a near-real-time picture of a live stream, for embeds and bots.' },++ { icon: 'ov:check', title: 'Limits in public', text: 'Every limit Media enforces is published at /limits.json, the same numbers OpenVibe.Services and Zone show.', href: '/limits.json' },++ ],++ }) + showcase.code({++ id: 'api',++ title: 'From your app',++ lede: 'Public media needs no key. Your own objects take a project token or a Zone key.',++ samples: [++ { label: 'Thumbnail', lang: 'html', code: `<img src="${config.publicUrl}/t/<thumbnail>?w=320&fmt=webp" width="320" loading="lazy" alt="">++<img src="${config.publicUrl}/live/@goosely/frame.jpg?w=640" alt="goosely, live now">` },++ { label: 'Node (openvibe-sdk)', lang: 'js', code: `const { createObjectsClient } = require('openvibe-sdk/media');++++// tokens: createServiceTokenClient() for your developer app; app is your project id++const objects = createObjectsClient({ app: 'prj_…', tokenClient: tokens });++const obj = await objects.upload(fs.readFileSync('q3.pdf'), { kind: 'file', mimeType: 'application/pdf', filename: 'q3.pdf' });++const { url } = await objects.signedUrl(obj.id, { ttl: 300 });` },++ { label: 'S3 (any tool)', lang: 'bash', code: `aws --endpoint-url https://s3.openvibe.media s3 cp ./site s3://assets/ --recursive # a Zone key and zone` },++ ],++ }) + showcase.limits({++ title: 'Limits',++ lede: 'What one project gets in each environment.',++ columns: ['Production', 'Sandbox'],++ rows,++ source: `${config.publicUrl}/limits.json`,++ }) + `<section class="sc-sec" id="questions" aria-labelledby="faq-h"><h2 id="faq-h">Questions</h2>${HOME_FAQ.map((f) => `<details class="faq"><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</section>`;++}function renderPage(tab, page, data, counts) {const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));@@@ -260,26 +322,34 @@ function renderPage(tab, page, data, counts) {const first = tab === 'all' && page === 1;const canonical = `${config.publicUrl}/${first ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}`;// The one <h1> on the page: the showcase hero on the index's front page, the plain intro elsewhere.++ const stats = [++ [counts.videos, 'public recordings and videos'], [counts.clips, 'public clips'],++ [counts.emotes + counts.sounds, 'emotes and sounds'], [counts.thumbnails, 'thumbnails in six sizes'],++ ].filter(([n]) => n > 0);const heading = first ? showcase.hero({eyebrow: 'OpenVibe.Media',-- title: 'Every public file on the',-- accent: 'OpenVibe network',-- lede: 'Videos, clips, emotes, sounds, files and thumbnails from every OpenVibe site, in one index. Watch and download public media without an account.',++ title: 'Open media and object storage',++ accent: 'for OpenVibe and your apps.',++ lede: 'Every video, clip, picture and file on the OpenVibe sites lives here, and your apps get the same API: objects and S3, thumbnails in any size, streaming playback and live frames. Public media plays and downloads without an account.',actions: [-- { label: 'Browse videos', href: '/?tab=videos', primary: true },-- { label: 'Watch on OpenVibe.Live', href: appUrl('live') + '/' },++ { label: 'Browse public media', href: '#media', primary: true },++ { label: 'Watch on OpenVibe.Video', href: 'https://openvibe.video/' },++ { label: 'Use it from your app', href: '#api' },],++ html: stats.length ? `<dl class="hero-stats">${stats.map(([n, d]) => `<div><dt>${Number(n).toLocaleString('en-US')}</dt><dd>${d}</dd></div>`).join('')}</dl>` : undefined,}) : '<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>';const body = `${heading}++${first ? `<h2 id="media" class="sec-h">Latest public media</h2>` : ''}<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>++${first ? developerSections() : ''}${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) : ''}`;return pageFrame.page({seo: {-- title: 'OpenVibe.Media — Media Index', description: DESCRIPTION, canonical,++ title: TITLE, description: DESCRIPTION, canonical,robots: page > 1 ? 'noindex, follow' : 'index, follow', ogType: 'website',-- jsonLd: page === 1 ? browseJsonLd(tab, data.cards) : [],++ jsonLd: page === 1 ? [...browseJsonLd(tab, data.cards), ...(first ? [homeFaqJsonLd()] : [])] : [],},css: BROWSE_CSS,body,@@@ -13,8 +13,8 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');(async () => {const BUDGETS = {-- htmlRawKB: 25, // measured 19.8 (fresh database)-- htmlBrotliKB: 6.5, // 5.0++ htmlRawKB: 43, // measured 38.8 on 2026-10-22 (fresh database): the home gained its developer half (what it does, code, limits, questions and their FAQPage data); 19.8 before++ htmlBrotliKB: 10.7, // 9.7 on 2026-10-22; 5.0 beforejsFiles: 4, // 3// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of// openvibe.network, so it is counted here; the page itself did not grow.@@@ -160,8 +160,17 @@ const http = require('http');for (const k of ['all', 'videos', 'clips', 'emotes', 'sounds', 'thumbnails', 'files']) assert.ok(r.body.includes(`href="/?tab=${k}"`), `tab ${k}`);assert.ok(r.body.includes('class="pager"') && r.body.includes('Page 1 /'), 'the pager');assert.ok(r.body.includes('<meta name="robots" content="index, follow">'));++ // The front page is also Media's public home for developers: what it does, how an app uses it, the limits it++ // enforces (limitsOf, the same numbers as /limits.json) and the questions, which are FAQPage data too.++ assert.ok(r.body.includes('<title>OpenVibe.Media: open media and object storage'), 'the title says what it is');++ for (const id of ['media', 'what', 'api', 'questions']) assert.ok(r.body.includes(`id="${id}"`), `the home has #${id}`);++ assert.ok(r.body.includes('sc-limits') && r.body.includes('/limits.json'), 'the limits table and its source');++ const faq = ld.find((x) => x['@type'] === 'FAQPage');++ assert.ok(faq && faq.mainEntity.length >= 5, 'FAQPage data');++ for (const q of faq.mainEntity) assert.ok(r.body.includes(`<summary>${q.name.replace(/'/g, ''')}</summary>`) || r.body.includes(`<summary>${q.name}</summary>`), `the page asks "${q.name}"`);r = await get('/?tab=videos', nav);assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'the hero is the front page\'s only');++ assert.ok(!r.body.includes('id="questions"') && !r.body.includes('FAQPage'), 'the developer half is the front page\'s only');r = await get('/?page=2', nav);assert.ok(!r.body.includes('application/ld+json'), 'a later (noindex) page carries none');assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'no hero after page 1');
@@ -234,7 +234,7 @@ nav.tabs{display:flex;gap:.25rem;padding:.2rem 0 .7rem;flex-wrap:wrap}
@@ -234,7 +234,7 @@ nav.tabs{display:flex;gap:.25rem;padding:.2rem 0 .7rem;flex-wrap:wrap}
nav.tabs a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}
nav.tabs a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}
nav.tabs a:hover{color:var(--text)}nav.tabs a.on{color:var(--on-accent-strong,#fff);background:var(--accent-strong,#1d4ed8)}
nav.tabs a:hover{color:var(--text)}nav.tabs a.on{color:var(--on-accent-strong,#fff);background:var(--accent-strong,#1d4ed8)}
nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{opacity:1}
nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{opacity:1}
-.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(250px,100%),1fr));gap:1rem}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
.card .thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}
.card .thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}
.card .thumb img{width:100%;height:100%;object-fit:cover;display:block}
.card .thumb img{width:100%;height:100%;object-fit:cover;display:block}
@@ -250,9 +250,71 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op
@@ -250,9 +250,71 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op
.pg{color:var(--acc);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--line);border-radius:8px}
.pg{color:var(--acc);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--line);border-radius:8px}
.pg.dis{color:var(--muted);opacity:.4}
.pg.dis{color:var(--muted);opacity:.4}
.empty{padding:3rem;text-align:center;color:var(--muted)}
.empty{padding:3rem;text-align:center;color:var(--muted)}
+.sec-h{font-size:1.3rem;margin:2rem 0 .6rem}
+.hero-stats{margin:0;display:grid;gap:10px}
+.hero-stats div{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px}
+.hero-stats dt{font-size:1.25rem;font-weight:700}.hero-stats dd{margin:2px 0 0;color:var(--muted);font-size:.92rem}
+.faq{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:4px 16px;margin:8px 0}
+.faq[open]{padding-bottom:12px}.faq summary{cursor:pointer;font-weight:600;padding:8px 0}.faq p{margin:4px 0 0;color:var(--muted)}
`;
`;
-const DESCRIPTION = 'Index of all public media on the OpenVibe network — videos, clips, files and thumbnails.';
+const DESCRIPTION = 'Videos, clips, pictures and files for every OpenVibe site, and the same API for your apps: objects, S3, thumbnails in any size and HLS.';
+const TITLE = 'OpenVibe.Media: open media and object storage';
+
+/** The home's questions: shown on the first page and given to search engines as FAQPage data (the same text). */
+const HOME_FAQ = [
+ { q: 'Do I need an account to watch?', a: 'No. Public videos, clips, sounds and files play and download without an account; watch pages for recordings and clips are on OpenVibe.Video.' },
+ { q: 'How does my app store files here?', a: 'Over S3 with a project key from OpenVibe.Zone (any S3 tool, endpoint s3.openvibe.media), or through the object API with the openvibe-sdk/media client: single or multipart uploads, resumable, signed URLs.' },
+ { q: 'What sizes can a thumbnail be?', a: 'Any public thumbnail answers /t/<id>?w=<width>&fmt=<format>: widths 64, 96, 160, 320, 480 or 640, as WebP, AVIF or JPEG, made once and cached.' },
+ { q: 'Can I show what a live stream looks like right now?', a: 'Yes: /live/@<username>/frame.jpg is a near-real-time frame of that streamer\'s live recording (?w= for a width, ?format=json for a status). When they are offline it is an offline card with a 404.' },
+ { q: 'What happens to something I delete?', a: 'A deleted object can be restored for a while before it is purged; the limits below say for how long. Its thumbnails and copies go with it.' },
+ { q: 'What are the limits?', a: 'They are published as JSON at /limits.json and in the table on this page: storage per project, upload and part sizes, and how long an unfinished upload holds its space.' },
+];
+function homeFaqJsonLd() {
+ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a.replace(/<(\w+)>/g, '$1') } })) };
+}
+const bytes = (n) => { const v = Number(n) || 0; return v >= 1024 ** 3 ? `${+(v / 1024 ** 3).toFixed(1)} GB` : `${Math.round(v / 1024 ** 2)} MB`; };
+
+/** The first page's developer half: what Media is for, how an app uses it, the limits it enforces, the questions. */
+function developerSections() {
+ const L = require('../limits').limitsOf(config).limits;
+ const rows = L.filter((l) => ['storage_bytes', 'single_upload_bytes', 'multipart_bytes', 'part_bytes', 'unfinished_upload_hours', 'deleted_retention_days'].includes(l.id))
+ .map((l) => ({ label: l.label, values: [l.unit === 'bytes' ? bytes(l.production) : `${l.production} ${l.unit}`, l.unit === 'bytes' ? bytes(l.sandbox) : `${l.sandbox} ${l.unit}`] }));
+ return showcase.features({
+ id: 'what',
+ title: 'What it does',
+ lede: 'The media layer under every OpenVibe site, open to your apps through the same API.',
+ items: [
+ { icon: 'ov:media', title: 'Objects and S3', text: 'Store any file in a project namespace: single or multipart uploads, signed URLs, and an S3 endpoint for the tools you have (through OpenVibe.Zone).', href: 'https://openvibe.zone/' },
+ { icon: 'ov:video', title: 'Recordings and clips', text: 'Live recordings, clips cut from them or straight from the stream timeline, and clips trimmed in place. Watch pages are on OpenVibe.Video.', href: 'https://openvibe.video/' },
+ { icon: 'ov:live', title: 'Streaming playback', text: 'Byte-range playback for every video, HLS for the ones that have a timeline, so players can seek without downloading.' },
+ { icon: 'ov:pics', title: 'Thumbnails in any size', text: '/t/<id>?w=&fmt= gives WebP, AVIF or JPEG at six widths, so a card loads about half the bytes.' },
+ { icon: 'ov:camera', title: 'Live frames', text: '/live/@<name>/frame.jpg: a near-real-time picture of a live stream, for embeds and bots.' },
+ { icon: 'ov:check', title: 'Limits in public', text: 'Every limit Media enforces is published at /limits.json, the same numbers OpenVibe.Services and Zone show.', href: '/limits.json' },
+ ],
+ }) + showcase.code({
+ id: 'api',
+ title: 'From your app',
+ lede: 'Public media needs no key. Your own objects take a project token or a Zone key.',
+ samples: [
+ { label: 'Thumbnail', lang: 'html', code: `<img src="${config.publicUrl}/t/<thumbnail>?w=320&fmt=webp" width="320" loading="lazy" alt="">
+<img src="${config.publicUrl}/live/@goosely/frame.jpg?w=640" alt="goosely, live now">` },
+ { label: 'Node (openvibe-sdk)', lang: 'js', code: `const { createObjectsClient } = require('openvibe-sdk/media');
+
+// tokens: createServiceTokenClient() for your developer app; app is your project id
+const objects = createObjectsClient({ app: 'prj_…', tokenClient: tokens });
+const obj = await objects.upload(fs.readFileSync('q3.pdf'), { kind: 'file', mimeType: 'application/pdf', filename: 'q3.pdf' });
+const { url } = await objects.signedUrl(obj.id, { ttl: 300 });` },
+ { label: 'S3 (any tool)', lang: 'bash', code: `aws --endpoint-url https://s3.openvibe.media s3 cp ./site s3://assets/ --recursive # a Zone key and zone` },
+ ],
+ }) + showcase.limits({
+ title: 'Limits',
+ lede: 'What one project gets in each environment.',
+ columns: ['Production', 'Sandbox'],
+ rows,
+ source: `${config.publicUrl}/limits.json`,
+ }) + `<section class="sc-sec" id="questions" aria-labelledby="faq-h"><h2 id="faq-h">Questions</h2>${HOME_FAQ.map((f) => `<details class="faq"><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</section>`;
+}
function renderPage(tab, page, data, counts) {
function renderPage(tab, page, data, counts) {
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
@@ -260,26 +322,34 @@ function renderPage(tab, page, data, counts) {
@@ -260,26 +322,34 @@ function renderPage(tab, page, data, counts) {
const first = tab === 'all' && page === 1;
const first = tab === 'all' && page === 1;
const canonical = `${config.publicUrl}/${first ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}`;
const canonical = `${config.publicUrl}/${first ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}`;
// The one <h1> on the page: the showcase hero on the index's front page, the plain intro elsewhere.
// The one <h1> on the page: the showcase hero on the index's front page, the plain intro elsewhere.
+ const stats = [
+ [counts.videos, 'public recordings and videos'], [counts.clips, 'public clips'],
+ [counts.emotes + counts.sounds, 'emotes and sounds'], [counts.thumbnails, 'thumbnails in six sizes'],
+ ].filter(([n]) => n > 0);
const heading = first ? showcase.hero({
const heading = first ? showcase.hero({
eyebrow: 'OpenVibe.Media',
eyebrow: 'OpenVibe.Media',
- title: 'Every public file on the',
- accent: 'OpenVibe network',
- lede: 'Videos, clips, emotes, sounds, files and thumbnails from every OpenVibe site, in one index. Watch and download public media without an account.',
+ title: 'Open media and object storage',
+ accent: 'for OpenVibe and your apps.',
+ lede: 'Every video, clip, picture and file on the OpenVibe sites lives here, and your apps get the same API: objects and S3, thumbnails in any size, streaming playback and live frames. Public media plays and downloads without an account.',
actions: [
actions: [
- { label: 'Browse videos', href: '/?tab=videos', primary: true },
- { label: 'Watch on OpenVibe.Live', href: appUrl('live') + '/' },
+ { label: 'Browse public media', href: '#media', primary: true },
+ { label: 'Watch on OpenVibe.Video', href: 'https://openvibe.video/' },
+ { label: 'Use it from your app', href: '#api' },
],
],
+ html: stats.length ? `<dl class="hero-stats">${stats.map(([n, d]) => `<div><dt>${Number(n).toLocaleString('en-US')}</dt><dd>${d}</dd></div>`).join('')}</dl>` : undefined,
}) : '<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>';
}) : '<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>';
const body = `${heading}
const body = `${heading}
+${first ? `<h2 id="media" class="sec-h">Latest public media</h2>` : ''}
<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
+${first ? developerSections() : ''}
${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) : ''}`;
${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) : ''}`;
return pageFrame.page({
return pageFrame.page({
seo: {
seo: {
- title: 'OpenVibe.Media — Media Index', description: DESCRIPTION, canonical,
+ title: TITLE, description: DESCRIPTION, canonical,
robots: page > 1 ? 'noindex, follow' : 'index, follow', ogType: 'website',
robots: page > 1 ? 'noindex, follow' : 'index, follow', ogType: 'website',
- jsonLd: page === 1 ? browseJsonLd(tab, data.cards) : [],
+ jsonLd: page === 1 ? [...browseJsonLd(tab, data.cards), ...(first ? [homeFaqJsonLd()] : [])] : [],
},
},
css: BROWSE_CSS,
css: BROWSE_CSS,
body,
body,
@@ -13,8 +13,8 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -13,8 +13,8 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
(async () => {
(async () => {
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 25, // measured 19.8 (fresh database)
- htmlBrotliKB: 6.5, // 5.0
+ htmlRawKB: 43, // measured 38.8 on 2026-10-22 (fresh database): the home gained its developer half (what it does, code, limits, questions and their FAQPage data); 19.8 before
+ htmlBrotliKB: 10.7, // 9.7 on 2026-10-22; 5.0 before
jsFiles: 4, // 3
jsFiles: 4, // 3
// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of
// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of
// openvibe.network, so it is counted here; the page itself did not grow.
// openvibe.network, so it is counted here; the page itself did not grow.
@@ -160,8 +160,17 @@ const http = require('http');
@@ -160,8 +160,17 @@ const http = require('http');
for (const k of ['all', 'videos', 'clips', 'emotes', 'sounds', 'thumbnails', 'files']) assert.ok(r.body.includes(`href="/?tab=${k}"`), `tab ${k}`);
for (const k of ['all', 'videos', 'clips', 'emotes', 'sounds', 'thumbnails', 'files']) assert.ok(r.body.includes(`href="/?tab=${k}"`), `tab ${k}`);
assert.ok(r.body.includes('class="pager"') && r.body.includes('Page 1 /'), 'the pager');
assert.ok(r.body.includes('class="pager"') && r.body.includes('Page 1 /'), 'the pager');
assert.ok(r.body.includes('<meta name="robots" content="index, follow">'));
assert.ok(r.body.includes('<meta name="robots" content="index, follow">'));
+ // The front page is also Media's public home for developers: what it does, how an app uses it, the limits it
+ // enforces (limitsOf, the same numbers as /limits.json) and the questions, which are FAQPage data too.
+ assert.ok(r.body.includes('<title>OpenVibe.Media: open media and object storage'), 'the title says what it is');
+ for (const id of ['media', 'what', 'api', 'questions']) assert.ok(r.body.includes(`id="${id}"`), `the home has #${id}`);
+ assert.ok(r.body.includes('sc-limits') && r.body.includes('/limits.json'), 'the limits table and its source');
+ const faq = ld.find((x) => x['@type'] === 'FAQPage');
+ assert.ok(faq && faq.mainEntity.length >= 5, 'FAQPage data');
+ for (const q of faq.mainEntity) assert.ok(r.body.includes(`<summary>${q.name.replace(/'/g, ''')}</summary>`) || r.body.includes(`<summary>${q.name}</summary>`), `the page asks "${q.name}"`);
r = await get('/?tab=videos', nav);
r = await get('/?tab=videos', nav);
assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'the hero is the front page\'s only');
assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'the hero is the front page\'s only');
+ assert.ok(!r.body.includes('id="questions"') && !r.body.includes('FAQPage'), 'the developer half is the front page\'s only');
r = await get('/?page=2', nav);
r = await get('/?page=2', nav);
assert.ok(!r.body.includes('application/ld+json'), 'a later (noindex) page carries none');
assert.ok(!r.body.includes('application/ld+json'), 'a later (noindex) page carries none');
assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'no hero after page 1');
assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'no hero after page 1');