Match videos: composed VS-card OG preview + richer share metadata

Sports match share links now show the full VS composition (fighters,
flags, event, weight class, referee/court/match chips) in WhatsApp /
Telegram / Facebook / Twitter previews instead of the raw video frame.

- Browsershot + puppeteer-bundled Chromium screenshots a dedicated
  1200x675 (true 16:9) render of the vs-mini composition. Chrome runs
  through a small wrapper (bin/browsershot-node.sh) that raises the fd
  limit and reads its full env, since PHP-FPM's stripped env otherwise
  makes Chromium fail to launch.
- Render is JPEG @ q85 (~100 KB) instead of PNG (~700 KB) — WhatsApp
  silently drops OG images above roughly 300 KB, which is why music
  thumbnails always showed and match cards didn't.
- SportsMatchController@store/update renders synchronously so the
  picture is guaranteed to exist before the user copies the share URL;
  edits regenerate and old stamped variants are cleaned. The og:image
  URL carries a ?v={updated_at} stamp so scrapers recache on edit.
- accessShare() (/s/{token}) now detects scraper user agents and serves
  the video page HTML inline instead of a 302 — some crawlers don't
  follow redirects when building link previews.
- og:description / twitter:description built from headerData(): flag
  emoji + fighter names (blue vs red), event, weight, match#, court,
  referee, view count, upload date.
- Fixed 3-dot menu on video cards being hidden behind the next card
  (:has(.dropdown-menu.show) promotes the active card above siblings
  and lifts the overflow:hidden clip on .yt-video-info).
- vs-mini: skip the entrance animation on touch devices and expose a
  .vs-mini-static class so the OG render always captures the final
  frame instead of a random mid-animation moment.

Also adds an admin gallery at /admin/og-previews for spot-checking the
generated preview JPEGs.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-08-11 12:34:40 +03:00
1 parent 665bad76bf
commit 3e18bd2150
14 files changed
+3562 -21

No files matched your search

@@ -205,6 +205,68 @@
background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%);
overflow: hidden;
will-change: transform;
/* Hidden until JS's fit() has measured the parent and set the real
--vs-mini-scale. Without this, a card whose parent had 0 height at
registration time (grid still resolving on mobile) would paint the
stage at the fallback 0.5 scale — larger than the ~640×360 mobile
thumb — clipping to just the center (looks like a broken card). */
visibility: hidden;
}
.vs-mini.vs-mini-fit .vs-mini-stage { visibility: visible; }
/* ══ Touch / no-hover devices: skip the orchestrated intro entirely and
render every element in its final state. The intro relies on ~1.3s
of CSS animation-delays; on mobile it was fragile — anything that
interrupted timing (late layout resolve, ResizeObserver refires
when the browser toolbar hides/shows, portrait class toggles that
swap `vsmPanelL` ↔ `vsmPanelT`) left cards frozen mid-animation.
On mobile users are scrolling, not hovering to watch a preview,
so the intro adds no value — show the final composition instead. ══ */
/* Same "no-animation, final-state" treatment when the parent explicitly
requests it — used by the OG-image render route so Browsershot
screenshots the composed final frame regardless of what Chromium
thinks its hover capability is. */
.vs-mini-static,
.vs-mini-static .vs-mini-panel,
.vs-mini-static .vs-mini-info,
.vs-mini-static .vs-mini-top,
.vs-mini-static .vs-mini-center,
.vs-mini-static .vs-mini-bottom {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.vs-mini-static .vs-mini-panel-red { transform: translateX(0) !important; }
.vs-mini-static .vs-mini-panel-blue { transform: translateX(0) !important; }
.vs-mini-static .vs-mini-top { transform: translateX(-50%) !important; }
.vs-mini-static .vs-mini-bottom { transform: translateX(-50%) !important; }
.vs-mini-static .vs-mini-center { transform: translate(-50%, -52%) !important; }
.vs-mini-static .vs-mini-photo,
.vs-mini-static .vs-mini-word,
.vs-mini-static .vs-mini-shine { animation: none !important; }
@media (hover: none) {
.vs-mini,
.vs-mini .vs-mini-panel,
.vs-mini .vs-mini-info,
.vs-mini .vs-mini-top,
.vs-mini .vs-mini-center,
.vs-mini .vs-mini-bottom {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.vs-mini .vs-mini-panel-red { transform: translateX(0) !important; }
.vs-mini .vs-mini-panel-blue { transform: translateX(0) !important; }
.vs-mini .vs-mini-top { transform: translateX(-50%) !important; }
.vs-mini .vs-mini-bottom { transform: translateX(-50%) !important; }
.vs-mini .vs-mini-center { transform: translate(-50%, -52%) !important; }
/* Also skip the ambient photo drift + VS pulse/shine — they were
running fine, but there's no need to burn cycles on animation
loops for a scrolling grid on mobile. */
.vs-mini .vs-mini-photo,
.vs-mini .vs-mini-word,
.vs-mini .vs-mini-shine { animation: none !important; }
}
/* Animations only run while the .vs-mini-run class is set (removed +
@@ -489,6 +551,9 @@
const sw = portrait ? 1080 : 1920;
const sh = portrait ? 1920 : 1080;
el.style.setProperty('--vs-mini-scale', Math.min(w / sw, h / sh));
// Reveal the stage — hidden by default so the CSS's fallback
// scale never paints a pre-fit oversized/clipped stage.
el.classList.add('vs-mini-fit');
}
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
@@ -518,19 +583,22 @@
}).observe(document.body, { childList: true, subtree: true });
// Restart entrance animations whenever the mouse leaves a card thumb
// that has a VS mini in it. removing + re-adding the class in the
// next frame forces the CSS animation to play from the start.
document.addEventListener('mouseleave', (e) => {
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
if (!thumb) return;
const vsm = thumb.querySelector(':scope > .vs-mini');
if (!vsm) return;
vsm.classList.remove('vs-mini-run');
// Reflow-then-reapply — CSS animations only restart when the
// running class is removed and reintroduced across a frame.
void vsm.offsetWidth;
vsm.classList.add('vs-mini-run');
}, true);
// that has a VS mini in it. Only bind on real hover devices — on touch,
// Chrome synthesizes mouseleave on tap-lift and during scroll, which
// kept restarting animations and leaving cards frozen mid-intro
// (elements at opacity:0 during the 0.5–1.3s animation-delay window).
const hasHover = window.matchMedia && window.matchMedia('(hover: hover)').matches;
if (hasHover) {
document.addEventListener('mouseleave', (e) => {
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
if (!thumb) return;
const vsm = thumb.querySelector(':scope > .vs-mini');
if (!vsm) return;
vsm.classList.remove('vs-mini-run');
void vsm.offsetWidth;
vsm.classList.add('vs-mini-run');
}, true);
}
})();
</script>
@endonce