openvibers/openvibe.live Commits

Home: paint the hero frame with the HTML (largest paint was 2.9 s, after the hero API) (#206)

OP OpenVibers committed 27 days ago · e5822ecbdc · 5 files, +33 −8

* Home: the hero's first AI-moment frame comes with the HTML (preloaded, painted on the first layer) instead of after /api/home/hero; it was the page's largest paint at 2.9 s on a throttled phone. measure-page names the LCP element

* Home hero: the client guard costs no bytes on the home budget (jsBrotliKB was 262.1 > 262)

Changes

ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 8010a4ee..8520f77c 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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(() => {
ddiff --git a/scripts/perf/measure-page.js b/scripts/perf/measure-page.js
iindex 3baeb3ce..134b998e 100644
---- a/scripts/perf/measure-page.js
++++ b/scripts/perf/measure-page.js
@@@ -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,
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 242d59bf..04bd73f5 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -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
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex 55267a7a..b799b025 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -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(&quot;${esc(meta.heroImage)}&quot;)"></div>`);
return html;
}
ddiff --git a/test/seo-ssr.test.js b/test/seo-ssr.test.js
iindex 7ee6db09..f5c6fd25 100644
---- a/test/seo-ssr.test.js
++++ b/test/seo-ssr.test.js
@@@ -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(&quot;${frame}&quot;)"></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), []);