openvibers/openvibe.live Commits

Hero render cost, part two: stop promoting the entire app to one layer

OP OpenVibers committed 16 Sept 2026 · 0b2737cd05 · 6 files, +48 −30

After the first pass production still reported 126-146 composited layers and ~41MP of layer area
on a 1.2MP viewport. Attributing every layer to its DOM node showed the largest were not in the
hero at all:

- main#app, 12.8MP, compositing reason "Overlap". body::before/::after were fixed-position ambient
  glows with filter: blur(70px). Fixed makes each a composited layer, and #app paints above them,
  so Chrome promoted the entire app to a single texture to keep it on top — re-rasterised as the
  page changed, under a 70px blur. Now radial gradients with no filter, positioned absolutely
  inside the page's own layer and inside its width (a first attempt put the right glow past the
  edge and scrolled the page sideways; caught and fixed before shipping).
- .cta-ambient, 4.1MP: 140% the size of the About banner, blurred and drifting. A paused
  animation still keeps a layer. Still glow, no blur.
- The About banner's border sweep animated a registered custom property, repainting a conic
  gradient across the ~2MP banner every frame. Held at a fixed angle.
- The restream card had backdrop-filter: blur(14px) — its own 1.2MP layer re-blurring the page.
  Removed, with isolation: isolate taking over the stacking context the blur used to create;
  without it the hub's z-index:-1 animated glow escapes to #app and promotes the app again.
- Every odometer digit track carried will-change: transform — 26 permanent layers. Transitions
  promote on their own while they run.
- The darkening overlay added over the hero frame in the first pass became a second full-hero
  layer by overlapping the animating frame. The tint is now a gradient in the frame's own
  background-image.

Local attribution after these changes: main#app no longer promoted; 126 layers / 41.5MP down to
119 / 26.2MP; no horizontal scroll at 1366 or 390px.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/home-fx.css b/public/css/home-fx.css
iindex 04d504dc..36a0ca94 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -298,13 +298,10 @@
100% { transform: translate3d(3px, 9px, 0) rotate(calc(var(--rot, -2deg) * -0.6)) scale(var(--depth-scale, 1)); }
}
--/* Background frames: no filter; the darkening is an overlay painted once. */
++/* Background frames: no filter. The darkening is a gradient layered into each frame's own
++ background-image (app.js), so it costs neither a filter pass nor a second full-hero layer. */
.hero-bg-layer { filter: none !important; will-change: auto !important; }
.hero-bg-layer.active { will-change: transform !important; }
--.hero-bg::after {
-- content: ''; position: absolute; inset: 0; pointer-events: none;
-- background: rgba(0, 0, 0, 0.5);
--}
/* Aurora: the gradients are already soft; the blur and blend were redundant and expensive. */
.hero-aurora, .hero-aurora::before, .hero-aurora::after {
ddiff --git a/public/css/home-tour.css b/public/css/home-tour.css
iindex 00736ada..bad31fcf 100644
---- a/public/css/home-tour.css
++++ b/public/css/home-tour.css
@@@ -9,7 +9,12 @@
background: color-mix(in srgb, var(--bg-card) 62%, transparent);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 24px 60px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.06);
-- backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
++ /* No backdrop blur: it made this 1.2MP card its own layer re-blurring the page behind it. The
++ fill is opaque enough on its own. The blur was also the only thing making .tour a stacking
++ context, so isolation takes over that job — without it the hub's z-index:-1 glow escapes to
++ main#app and the whole app gets promoted to a layer to sit above it. */
++ background: color-mix(in srgb, var(--bg-card) 86%, transparent);
++ isolation: isolate;
}
.tour::before { content: ''; position: absolute; inset: 0; pointer-events: none; opacity: 0.35;
background-image: radial-gradient(rgba(255,255,255,0.10) 0.8px, transparent 0.9px); background-size: 22px 22px;
ddiff --git a/public/css/style.css b/public/css/style.css
iindex c2e099b4..0de8f9a3 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -71,14 +71,21 @@ body {
background-image: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 42%);
background-attachment: fixed;
}
++/* Ambient corner glows. These were fixed-position squares with filter: blur(70px). Fixed makes each
++ one a composited layer, and main#app — which paints on top of them — then had to be promoted to
++ its own layer for overlapping them: a single 12.8MP texture of the entire app, re-rasterised as
++ the page changed, plus a 70px blur (among the most expensive filters there is) on top.
++ A radial gradient reaches the same soft edge with no filter, and absolute positioning keeps the
++ glows in the page's own layer, so nothing above them needs promoting. They sit behind the top of
++ the page, which is where they were ever visible past the hero anyway. */
body::before,
body::after {
content: '';
-- position: fixed;
++ position: absolute;
pointer-events: none;
z-index: 0;
-- filter: blur(70px);
opacity: 0.32;
++ border-radius: 50%;
}
/* ── Vibe coding widget ───────────────────────────────────── */
@@@ -833,19 +840,23 @@ body::after {
display: grid;
gap: 7px;
}
++/* Both glows stay inside the page's width — an absolutely positioned box past the right edge
++ widens the document and scrolls it sideways. The gradient centre sits on the corner instead. */
body::before {
-- top: -80px;
-- left: -30px;
-- width: 280px;
-- height: 280px;
-- background: color-mix(in srgb, var(--accent) 28%, transparent);
++ top: 0;
++ left: 0;
++ width: min(560px, 70vw);
++ height: 560px;
++ border-radius: 0;
++ background: radial-gradient(circle at 12% 8%, color-mix(in srgb, var(--accent) 28%, transparent) 0%, color-mix(in srgb, var(--accent) 12%, transparent) 30%, transparent 62%);
}
body::after {
-- right: -40px;
-- top: 25vh;
-- width: 240px;
-- height: 240px;
-- background: rgba(52, 152, 219, 0.15);
++ right: 0;
++ top: calc(25vh - 120px);
++ width: min(480px, 60vw);
++ height: 480px;
++ border-radius: 0;
++ background: radial-gradient(circle at 92% 50%, rgba(52, 152, 219, 0.15) 0%, rgba(52, 152, 219, 0.06) 32%, transparent 62%);
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-light); }
@@@ -2498,7 +2509,9 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
background: conic-gradient(from var(--cta-angle, 0deg), transparent 0deg, color-mix(in srgb, var(--accent) 80%, transparent) 60deg, color-mix(in srgb, var(--accent) 15%, transparent) 140deg, transparent 200deg, color-mix(in srgb, var(--accent) 55%, transparent) 300deg, transparent 360deg);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor; mask-composite: exclude;
-- animation: ctaBorder 6s linear infinite;
++ /* Held still. The sweep animated a registered custom property, which repaints a conic gradient
++ across the whole ~2MP banner every frame; a fixed angle keeps the lit edge without that. */
++ --cta-angle: 210deg;
opacity: 0.7;
}
@property --cta-angle { syntax: '<angle>'; initial-value: 0deg; inherits: false; }
@@@ -2511,10 +2524,11 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
radial-gradient(38% 42% at 18% 22%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 60%),
radial-gradient(34% 40% at 82% 30%, color-mix(in srgb, var(--accent-dark) 14%, transparent), transparent 60%),
radial-gradient(46% 50% at 60% 96%, rgba(52,152,219,0.10), transparent 62%);
-- filter: blur(8px);
-- animation: ctaDrift 18s ease-in-out infinite alternate;
++ /* No blur (the gradients already fade to nothing) and no drift: this layer is 140% the size
++ of the banner — 4MP on desktop — and an animation, even paused off screen, keeps it
++ promoted. A still glow reads the same. */
++ animation: none;
}
--@keyframes ctaDrift { from { transform: translate3d(-2%, -1%, 0) scale(1); } to { transform: translate3d(3%, 2%, 0) scale(1.08); } }
/* Rising embers */
.cta-embers { position: absolute; inset: 0; z-index: -1; pointer-events: none; overflow: hidden; }
ddiff --git a/public/index.html b/public/index.html
iindex db4b244a..91a4c15c 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,9 +88,9 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
-- <link rel="stylesheet" href="/css/style.css?v=214">
-- <link rel="stylesheet" href="/css/home-fx.css?v=18">
-- <link rel="stylesheet" href="/css/home-tour.css?v=11">
++ <link rel="stylesheet" href="/css/style.css?v=216">
++ <link rel="stylesheet" href="/css/home-fx.css?v=19">
++ <link rel="stylesheet" href="/css/home-tour.css?v=12">
<link rel="stylesheet" href="/css/i18n-star.css?v=11">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
@@@ -5094,7 +5094,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
--<script src="/js/ov-odometer.js?v=3" defer></script>
++<script src="/js/ov-odometer.js?v=4" defer></script>
<script src="/js/ov-density.js?v=4" defer></script>
<!-- ── Route bundles ──────────────────────────────────────────────────────────
Measured with Chrome's precise coverage on a loaded home page: 14.3% of the JavaScript this
@@@ -5139,7 +5139,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
};
})();
</script>
--<script src="/js/app.js?v=181" defer></script>
++<script src="/js/app.js?v=182" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 216274cb..6997893a 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -2147,7 +2147,7 @@ function renderHeroBackground(media, moments) {
if (!frames.length) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
_heroBgActive = 0;
-- layers[0].style.backgroundImage = `url("${frames[0]}")`;
++ layers[0].style.backgroundImage = `linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url("${frames[0]}")`;
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');
@@@ -2160,7 +2160,9 @@ function renderHeroBackground(media, moments) {
const el = layers[next];
const pre = new Image();
pre.onload = () => {
-- el.style.backgroundImage = `url("${frames[idx]}")`;
++ // The darkening is part of the frame's own background rather than a filter or an overlay element:
++ // either of those becomes a second full-hero layer on top of an animating one.
++ el.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url("${frames[idx]}")`;
el.classList.remove('active'); void el.offsetWidth; el.classList.add('active');
layers[_heroBgActive].classList.remove('active');
_heroBgActive = next;
ddiff --git a/public/js/ov-odometer.js b/public/js/ov-odometer.js
iindex f1bfd881..aca3c166 100644
---- a/public/js/ov-odometer.js
++++ b/public/js/ov-odometer.js
@@@ -27,7 +27,7 @@
.ovnum{--ovnum-h:1.25em;display:inline-flex;align-items:center;line-height:var(--ovnum-h);font-variant-numeric:tabular-nums}
.ovnum-cell{display:inline-block;line-height:var(--ovnum-h)}
.ovnum-digit{display:inline-block;overflow:hidden;height:var(--ovnum-h);width:1ch;position:relative}
--.ovnum-track{display:block;will-change:transform;transition:transform var(--ovnum-dur,.62s) cubic-bezier(.22,1,.28,1)}
++.ovnum-track{display:block;transition:transform var(--ovnum-dur,.62s) cubic-bezier(.22,1,.28,1)}
.ovnum-track span{display:block;height:var(--ovnum-h);line-height:var(--ovnum-h);text-align:center}
.ovnum.is-up .ovnum-digit.changed{animation:ovnumUp .62s ease-out}
.ovnum.is-down .ovnum-digit.changed{animation:ovnumDown .62s ease-out}