openvibers/openvibe.live Commits
Home: paint the hero frame with the HTML (largest paint was 2.9 s, after the hero API) (#206)
Changes
@@@ -684,8 +684,6 @@ function renderHeroBackground(media, moments) {if (!wrap) return;const layers = wrap.querySelectorAll('.hero-bg-layer');if (layers.length < 2) return;-- // Prefer the full AI-moment frame set (not the shuffled/sliced collage media), so the-- // background cycles through all of the day's frames.let frames = (moments || []).filter(m => m && m.thumbnail).map(m => m.thumbnail);if (!frames.length) frames = (media || []).filter(m => m && m.kind === 'moment' && m.thumbnail).map(m => m.thumbnail);if (!frames.length) frames = (media || []).filter(m => m && m.thumbnail && m.kind !== 'paste').map(m => m.thumbnail);@@@ -694,10 +692,12 @@ function renderHeroBackground(media, moments) {if (!frames.length) { wrap.style.display = 'none'; return; }wrap.style.display = '';_heroBgActive = 0;-- layers[0].style.backgroundImage = `linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url("${frames[0]}")`;++ // Unless the server painted this frame (seo/pages.js): set it and restart the Ken Burns drift.++ if (!(layers[0].style.backgroundImage || '').includes(frames[0])) {++ layers[0].style.backgroundImage = `linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url("${frames[0]}")`;++ layers[0].classList.remove('active'); void layers[0].offsetWidth; layers[0].classList.add('active');++ } else layers[0].classList.add('active');layers[1].classList.remove('active');-- // Re-trigger the Ken-Burns animation on first paint.-- layers[0].classList.remove('active'); void layers[0].offsetWidth; layers[0].classList.add('active');if (frames.length < 2 || (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches && frames.length < 2)) return;let idx = 0;_heroBgTimer = setInterval(() => {@@@ -23,7 +23,8 @@ if (!url) { console.error('usage: measure-page.js <url> [--runs N] [--phone] [--const OBSERVERS = `(() => {window.__ov = { fcp: 0, lcp: 0, cls: 0, longTasks: [], };try { new PerformanceObserver(l => { for (const e of l.getEntries()) if (e.name === 'first-contentful-paint') __ov.fcp = e.startTime; }).observe({ type: 'paint', buffered: true }); } catch (e) {}-- try { new PerformanceObserver(l => { for (const e of l.getEntries()) __ov.lcp = e.startTime; }).observe({ type: 'largest-contentful-paint', buffered: true }); } catch (e) {}++ const describe = (el) => { if (!el) return ''; const id = el.id ? '#' + el.id : ''; const cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.') : ''; const txt = (el.textContent || '').trim().slice(0, 40); return el.tagName.toLowerCase() + id + cls + (txt ? ' "' + txt + '"' : ''); };++ try { new PerformanceObserver(l => { for (const e of l.getEntries()) { __ov.lcp = e.startTime; __ov.lcpEl = describe(e.element) + (e.url ? ' ' + e.url.slice(0, 80) : ''); } }).observe({ type: 'largest-contentful-paint', buffered: true }); } catch (e) {}try { new PerformanceObserver(l => { for (const e of l.getEntries()) if (!e.hadRecentInput) __ov.cls += e.value; }).observe({ type: 'layout-shift', buffered: true }); } catch (e) {}try { new PerformanceObserver(l => { for (const e of l.getEntries()) __ov.longTasks.push([e.startTime, e.duration]); }).observe({ type: 'longtask', buffered: true }); } catch (e) {}})();`;@@@ -50,7 +51,7 @@ async function runOnce() {await sleep(SETTLE);const page = await cdp.evaluate(`(() => ({-- fcp: Math.round(__ov.fcp), lcp: Math.round(__ov.lcp), cls: +__ov.cls.toFixed(3),++ fcp: Math.round(__ov.fcp), lcp: Math.round(__ov.lcp), lcpEl: __ov.lcpEl || '', cls: +__ov.cls.toFixed(3),longTaskMs: Math.round(__ov.longTasks.reduce((s, t) => s + t[1], 0)),tbt: Math.round(__ov.longTasks.filter(t => t[0] > __ov.fcp).reduce((s, t) => s + Math.max(0, t[1] - 50), 0)),domNodes: document.getElementsByTagName('*').length,@@@ -607,6 +607,7 @@ module.exports.startViewerSampler = startViewerSampler;module.exports.FALLBACK_AUDIENCES = FALLBACK_AUDIENCES;module.exports.FALLBACK_QUIPS = FALLBACK_QUIPS;module.exports.heroSlogans = heroSlogans;++module.exports.heroMoments = heroMoments;// Warm the hero caches once boot has finished (~6s on production). Every deploy restarts the process with empty caches, and// the first home page load after it used to wait on every upstream call cold (~2.3s measured right@@@ -514,7 +514,11 @@ async function _homeMeta() {}),...lists,];-- return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot, cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined };++ // The hero's first AI-moment frame is the page's largest paint; named here, it is fetched with the HTML and++ // painted with the first render instead of after app-home.js asks /api/home/hero for it (LCP 2.9 s → ~FCP).++ let heroImage = null;++ try { const first = (await require('../home/routes').heroMoments())[0]; heroImage = first && /^https:\/\/[^\s"'()<>\\]+$/.test(first.thumbnail || '') ? first.thumbnail : null; } catch { heroImage = null; }++ return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot, heroImage, cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined };}/**@@@ -876,6 +880,7 @@ function _headBlock(meta) {parts.push(`<meta property="video:duration" content="${meta.video.duration}">`);}if (meta.snapshot) parts.push(NOJS_STYLE);++ if (meta.heroImage) parts.push(`<link rel="preload" as="image" href="${esc(meta.heroImage)}" fetchpriority="high">`);return '\n' + parts.map(x => ' ' + x).join('\n') + '\n';}@@@ -929,6 +934,8 @@ function render(meta, urlPath) {// never see a flash, and the SPA removes #seo-prerender on boot (see app.js). Not cloaking:// it summarises the same content the SPA renders.if (meta.snapshot) html = _prerender(html, meta.snapshot + _summary(meta));++ // The first hero layer shows that frame already (the same background app-home.js gives it).++ if (meta.heroImage) html = html.replace('<div class="hero-bg-layer"></div>', `<div class="hero-bg-layer active" style="background-image:linear-gradient(rgba(0,0,0,0.5),rgba(0,0,0,0.5)),url("${esc(meta.heroImage)}")"></div>`);return html;}@@@ -232,6 +232,22 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {}});++ await check('the home page paints its first hero frame with the HTML (preloaded, on the first layer)', async () => {++ const pages = require('../server/seo/pages');++ const frame = 'https://openvibe.media/o/med_01HEROFRAME';++ await db.setState('home_hero_moments', JSON.stringify({ moments: [{ vodId: 100, offset: 5, title: 'x', thumbnail: frame }] }));++ const meta = await pages._pageMeta('/');++ assert.strictEqual(meta.heroImage, frame);++ const html = pages.render(meta, '/');++ assert.ok(head(html).includes(`<link rel="preload" as="image" href="${frame}" fetchpriority="high">`), 'preloaded in the head');++ assert.ok(html.includes(`<div class="hero-bg-layer active" style="background-image:linear-gradient(rgba(0,0,0,0.5),rgba(0,0,0,0.5)),url("${frame}")"></div><div class="hero-bg-layer"></div>`), 'the first layer shows it');++ // A thumbnail that is not a plain https URL is never written into the page.++ await db.setState('home_hero_moments', JSON.stringify({ moments: [{ vodId: 100, thumbnail: 'https://x.test/a")%3Bbackground:url("//evil' }] }));++ assert.strictEqual((await pages._pageMeta('/')).heroImage, null);++ await db.setState('home_hero_moments', '{}');++ assert.ok(!pages.render(await pages._pageMeta('/'), '/').includes('rel="preload" as="image"'), 'no frame, no preload');++ });++await check('no other shell page claims the home page as canonical; a slot names its channel', async () => {const dash = (await get('/dashboard')).body;assert.deepStrictEqual(canonicalsOf(dash), []);
@@ -684,8 +684,6 @@ function renderHeroBackground(media, moments) {
@@ -684,8 +684,6 @@ function renderHeroBackground(media, moments) {
if (!wrap) return;
if (!wrap) return;
const layers = wrap.querySelectorAll('.hero-bg-layer');
const layers = wrap.querySelectorAll('.hero-bg-layer');
if (layers.length < 2) return;
if (layers.length < 2) return;
- // Prefer the full AI-moment frame set (not the shuffled/sliced collage media), so the
- // background cycles through all of the day's frames.
let frames = (moments || []).filter(m => m && m.thumbnail).map(m => m.thumbnail);
let frames = (moments || []).filter(m => m && m.thumbnail).map(m => m.thumbnail);
if (!frames.length) frames = (media || []).filter(m => m && m.kind === 'moment' && m.thumbnail).map(m => m.thumbnail);
if (!frames.length) frames = (media || []).filter(m => m && m.kind === 'moment' && m.thumbnail).map(m => m.thumbnail);
if (!frames.length) frames = (media || []).filter(m => m && m.thumbnail && m.kind !== 'paste').map(m => m.thumbnail);
if (!frames.length) frames = (media || []).filter(m => m && m.thumbnail && m.kind !== 'paste').map(m => m.thumbnail);
@@ -694,10 +692,12 @@ function renderHeroBackground(media, moments) {
@@ -694,10 +692,12 @@ function renderHeroBackground(media, moments) {
if (!frames.length) { wrap.style.display = 'none'; return; }
if (!frames.length) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
wrap.style.display = '';
_heroBgActive = 0;
_heroBgActive = 0;
- layers[0].style.backgroundImage = `linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url("${frames[0]}")`;
+ // Unless the server painted this frame (seo/pages.js): set it and restart the Ken Burns drift.
+ if (!(layers[0].style.backgroundImage || '').includes(frames[0])) {
+ layers[0].style.backgroundImage = `linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url("${frames[0]}")`;
+ layers[0].classList.remove('active'); void layers[0].offsetWidth; layers[0].classList.add('active');
+ } else layers[0].classList.add('active');
layers[1].classList.remove('active');
layers[1].classList.remove('active');
- // Re-trigger the Ken-Burns animation on first paint.
- layers[0].classList.remove('active'); void layers[0].offsetWidth; layers[0].classList.add('active');
if (frames.length < 2 || (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches && frames.length < 2)) return;
if (frames.length < 2 || (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches && frames.length < 2)) return;
let idx = 0;
let idx = 0;
_heroBgTimer = setInterval(() => {
_heroBgTimer = setInterval(() => {
@@ -23,7 +23,8 @@ if (!url) { console.error('usage: measure-page.js <url> [--runs N] [--phone] [--
@@ -23,7 +23,8 @@ if (!url) { console.error('usage: measure-page.js <url> [--runs N] [--phone] [--
const OBSERVERS = `(() => {
const OBSERVERS = `(() => {
window.__ov = { fcp: 0, lcp: 0, cls: 0, longTasks: [], };
window.__ov = { fcp: 0, lcp: 0, cls: 0, longTasks: [], };
try { new PerformanceObserver(l => { for (const e of l.getEntries()) if (e.name === 'first-contentful-paint') __ov.fcp = e.startTime; }).observe({ type: 'paint', buffered: true }); } catch (e) {}
try { new PerformanceObserver(l => { for (const e of l.getEntries()) if (e.name === 'first-contentful-paint') __ov.fcp = e.startTime; }).observe({ type: 'paint', buffered: true }); } catch (e) {}
- try { new PerformanceObserver(l => { for (const e of l.getEntries()) __ov.lcp = e.startTime; }).observe({ type: 'largest-contentful-paint', buffered: true }); } catch (e) {}
+ const describe = (el) => { if (!el) return ''; const id = el.id ? '#' + el.id : ''; const cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.') : ''; const txt = (el.textContent || '').trim().slice(0, 40); return el.tagName.toLowerCase() + id + cls + (txt ? ' "' + txt + '"' : ''); };
+ try { new PerformanceObserver(l => { for (const e of l.getEntries()) { __ov.lcp = e.startTime; __ov.lcpEl = describe(e.element) + (e.url ? ' ' + e.url.slice(0, 80) : ''); } }).observe({ type: 'largest-contentful-paint', buffered: true }); } catch (e) {}
try { new PerformanceObserver(l => { for (const e of l.getEntries()) if (!e.hadRecentInput) __ov.cls += e.value; }).observe({ type: 'layout-shift', buffered: true }); } catch (e) {}
try { new PerformanceObserver(l => { for (const e of l.getEntries()) if (!e.hadRecentInput) __ov.cls += e.value; }).observe({ type: 'layout-shift', buffered: true }); } catch (e) {}
try { new PerformanceObserver(l => { for (const e of l.getEntries()) __ov.longTasks.push([e.startTime, e.duration]); }).observe({ type: 'longtask', buffered: true }); } catch (e) {}
try { new PerformanceObserver(l => { for (const e of l.getEntries()) __ov.longTasks.push([e.startTime, e.duration]); }).observe({ type: 'longtask', buffered: true }); } catch (e) {}
})();`;
})();`;
@@ -50,7 +51,7 @@ async function runOnce() {
@@ -50,7 +51,7 @@ async function runOnce() {
await sleep(SETTLE);
await sleep(SETTLE);
const page = await cdp.evaluate(`(() => ({
const page = await cdp.evaluate(`(() => ({
- fcp: Math.round(__ov.fcp), lcp: Math.round(__ov.lcp), cls: +__ov.cls.toFixed(3),
+ fcp: Math.round(__ov.fcp), lcp: Math.round(__ov.lcp), lcpEl: __ov.lcpEl || '', cls: +__ov.cls.toFixed(3),
longTaskMs: Math.round(__ov.longTasks.reduce((s, t) => s + t[1], 0)),
longTaskMs: Math.round(__ov.longTasks.reduce((s, t) => s + t[1], 0)),
tbt: Math.round(__ov.longTasks.filter(t => t[0] > __ov.fcp).reduce((s, t) => s + Math.max(0, t[1] - 50), 0)),
tbt: Math.round(__ov.longTasks.filter(t => t[0] > __ov.fcp).reduce((s, t) => s + Math.max(0, t[1] - 50), 0)),
domNodes: document.getElementsByTagName('*').length,
domNodes: document.getElementsByTagName('*').length,
@@ -607,6 +607,7 @@ module.exports.startViewerSampler = startViewerSampler;
@@ -607,6 +607,7 @@ module.exports.startViewerSampler = startViewerSampler;
module.exports.FALLBACK_AUDIENCES = FALLBACK_AUDIENCES;
module.exports.FALLBACK_AUDIENCES = FALLBACK_AUDIENCES;
module.exports.FALLBACK_QUIPS = FALLBACK_QUIPS;
module.exports.FALLBACK_QUIPS = FALLBACK_QUIPS;
module.exports.heroSlogans = heroSlogans;
module.exports.heroSlogans = heroSlogans;
+module.exports.heroMoments = heroMoments;
// Warm the hero caches once boot has finished (~6s on production). Every deploy restarts the process with empty caches, and
// Warm the hero caches once boot has finished (~6s on production). Every deploy restarts the process with empty caches, and
// the first home page load after it used to wait on every upstream call cold (~2.3s measured right
// the first home page load after it used to wait on every upstream call cold (~2.3s measured right
@@ -514,7 +514,11 @@ async function _homeMeta() {
@@ -514,7 +514,11 @@ async function _homeMeta() {
}),
}),
...lists,
...lists,
];
];
- return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot, cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined };
+ // The hero's first AI-moment frame is the page's largest paint; named here, it is fetched with the HTML and
+ // painted with the first render instead of after app-home.js asks /api/home/hero for it (LCP 2.9 s → ~FCP).
+ let heroImage = null;
+ try { const first = (await require('../home/routes').heroMoments())[0]; heroImage = first && /^https:\/\/[^\s"'()<>\\]+$/.test(first.thumbnail || '') ? first.thumbnail : null; } catch { heroImage = null; }
+ return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot, heroImage, cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined };
}
}
/**
/**
@@ -876,6 +880,7 @@ function _headBlock(meta) {
@@ -876,6 +880,7 @@ function _headBlock(meta) {
parts.push(`<meta property="video:duration" content="${meta.video.duration}">`);
parts.push(`<meta property="video:duration" content="${meta.video.duration}">`);
}
}
if (meta.snapshot) parts.push(NOJS_STYLE);
if (meta.snapshot) parts.push(NOJS_STYLE);
+ if (meta.heroImage) parts.push(`<link rel="preload" as="image" href="${esc(meta.heroImage)}" fetchpriority="high">`);
return '\n' + parts.map(x => ' ' + x).join('\n') + '\n';
return '\n' + parts.map(x => ' ' + x).join('\n') + '\n';
}
}
@@ -929,6 +934,8 @@ function render(meta, urlPath) {
@@ -929,6 +934,8 @@ function render(meta, urlPath) {
// never see a flash, and the SPA removes #seo-prerender on boot (see app.js). Not cloaking:
// never see a flash, and the SPA removes #seo-prerender on boot (see app.js). Not cloaking:
// it summarises the same content the SPA renders.
// it summarises the same content the SPA renders.
if (meta.snapshot) html = _prerender(html, meta.snapshot + _summary(meta));
if (meta.snapshot) html = _prerender(html, meta.snapshot + _summary(meta));
+ // The first hero layer shows that frame already (the same background app-home.js gives it).
+ if (meta.heroImage) html = html.replace('<div class="hero-bg-layer"></div>', `<div class="hero-bg-layer active" style="background-image:linear-gradient(rgba(0,0,0,0.5),rgba(0,0,0,0.5)),url("${esc(meta.heroImage)}")"></div>`);
return html;
return html;
}
}
@@ -232,6 +232,22 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -232,6 +232,22 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
}
}
});
});
+ await check('the home page paints its first hero frame with the HTML (preloaded, on the first layer)', async () => {
+ const pages = require('../server/seo/pages');
+ const frame = 'https://openvibe.media/o/med_01HEROFRAME';
+ await db.setState('home_hero_moments', JSON.stringify({ moments: [{ vodId: 100, offset: 5, title: 'x', thumbnail: frame }] }));
+ const meta = await pages._pageMeta('/');
+ assert.strictEqual(meta.heroImage, frame);
+ const html = pages.render(meta, '/');
+ assert.ok(head(html).includes(`<link rel="preload" as="image" href="${frame}" fetchpriority="high">`), 'preloaded in the head');
+ assert.ok(html.includes(`<div class="hero-bg-layer active" style="background-image:linear-gradient(rgba(0,0,0,0.5),rgba(0,0,0,0.5)),url("${frame}")"></div><div class="hero-bg-layer"></div>`), 'the first layer shows it');
+ // A thumbnail that is not a plain https URL is never written into the page.
+ await db.setState('home_hero_moments', JSON.stringify({ moments: [{ vodId: 100, thumbnail: 'https://x.test/a")%3Bbackground:url("//evil' }] }));
+ assert.strictEqual((await pages._pageMeta('/')).heroImage, null);
+ await db.setState('home_hero_moments', '{}');
+ assert.ok(!pages.render(await pages._pageMeta('/'), '/').includes('rel="preload" as="image"'), 'no frame, no preload');
+ });
+
await check('no other shell page claims the home page as canonical; a slot names its channel', async () => {
await check('no other shell page claims the home page as canonical; a slot names its channel', async () => {
const dash = (await get('/dashboard')).body;
const dash = (await get('/dashboard')).body;
assert.deepStrictEqual(canonicalsOf(dash), []);
assert.deepStrictEqual(canonicalsOf(dash), []);