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

@@ -0,0 +1,80 @@
{{-- ══════════════════════════════════════════════════════════════════════
VS OG — 1200×630 standalone page consumed by Browsershot for the
social-share preview image. Renders the exact same .vs-mini
composition used on video cards, but at fixed 1200×630 with
animations disabled (via .vs-mini-static) so the screenshot always
captures the final composed frame.
Public but noindex — only invoked by the internal ogImage()
controller through a loopback HTTP request from Browsershot.
════════════════════════════════════════════════════════════════════════ --}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OG Preview</title>
<meta name="robots" content="noindex,nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
{{-- Force http:// on the flag CSS URL — Laravel's asset() caches the
scheme on first use (https from AppServiceProvider::forceScheme) and
URL::forceScheme('http') in the controller can't override that
cache. Chromium hits this endpoint via loopback where https isn't
reachable, so we build the URL ourselves. --}}
<link rel="stylesheet" href="http://{{ request()->getHost() }}/vendor/flag-icons/css/flag-icons.min.css">
<style>
/* Canvas is a true 16:9 at 1200×675 so the 1920×1080 vs-mini stage
maps cleanly with scale 0.625 (no overflow on either axis). WhatsApp/
Facebook accept any aspect ratio for og:image; 1200×675 is inside
their recommended range and preserves the full VS composition. */
html, body {
margin: 0; padding: 0;
width: 1200px; height: 675px;
background: #050507;
overflow: hidden;
}
.og-frame {
position: relative;
width: 1200px; height: 675px;
overflow: hidden;
}
/* Same wrapper the vs-mini expects — .yt-video-thumb is what the mini's
`position: absolute; inset: 0` anchors to, and its aspect drives fit(). */
.og-frame .yt-video-thumb {
position: relative;
width: 100%; height: 100%;
overflow: hidden;
background: #050507;
}
</style>
</head>
<body>
<div class="og-frame">
<div class="yt-video-thumb">
{{-- vs-mini reads $video->sportsMatch->headerData() to compose. --}}
@include('components.partials.vs-mini', ['video' => $video])
</div>
</div>
<script>
// Force the mini into its final composed state — no animation-delay
// window, no ResizeObserver races. We also inline the correct scale for
// a 1200×630 frame (1200/1920 = 0.625) so we don't depend on the fit()
// callback firing before Browsershot captures.
(function () {
const el = document.querySelector('.vs-mini');
if (!el) return;
el.classList.add('vs-mini-static', 'vs-mini-fit');
el.style.setProperty('--vs-mini-scale', String(1200 / 1920));
// Signal readiness for Browsershot's waitUntilNetworkIdle to observe
// once webfonts are done rendering.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => { window.__vsOgReady = true; });
} else {
window.__vsOgReady = true;
}
})();
</script>
</body>
</html>
+74 -5
View File
@@ -3,18 +3,87 @@
@section('title', ($video->title ?? 'Match Video') . ' | ' . config('app.name'))
@push('head')
@php
// ── Build a rich share-preview description from the match record ──
// Layout the user requested:
// Abdulla Bassam vs Zakaria Shuaiter
// National Team Selection Trials · Match# 13 · Court 1 · Anoj K.R - Mahadwa K
// 37 views · Aug 09, 2026
// WhatsApp collapses newlines to spaces but respects `\n`; other
// platforms (Facebook, Telegram, Slack) render them as line breaks.
$hd = $video->sportsMatch?->headerData() ?? [];
$redName = trim((string) ($hd['red']['name'] ?? ''));
$blueName = trim((string) ($hd['blue']['name'] ?? ''));
$redFlag = strtoupper(trim((string) ($hd['red']['flag'] ?? '')));
$blueFlag = strtoupper(trim((string) ($hd['blue']['flag'] ?? '')));
$event = trim((string) ($hd['championship'] ?? ''));
$matchNo = trim((string) ($hd['match_number'] ?? ''));
$court = trim((string) ($hd['court'] ?? ''));
$referee = trim((string) ($hd['referee']['name'] ?? ''));
$weight = trim((string) ($hd['weight_category'] ?? ''));
// ISO2 country code → Unicode regional-indicator emoji flag (e.g. "BH" → 🇧🇭).
// Emoji flags are the only way to embed a flag in a text-only <meta>
// field. They render on WhatsApp, Telegram, iMessage, and mobile
// Facebook — the platforms people actually preview shared links in.
// (The self-hosted flag-icons library used elsewhere in the app is
// for HTML; meta descriptions can't carry markup.)
$flagEmoji = function (string $c): string {
if (! preg_match('/^[A-Z]{2}$/', $c)) return '';
return mb_chr(0x1F1E6 + (ord($c[0]) - 65), 'UTF-8')
. mb_chr(0x1F1E6 + (ord($c[1]) - 65), 'UTF-8');
};
$blueTag = trim(($flagEmoji($blueFlag) . ' ' . $blueName));
$redTag = trim(($flagEmoji($redFlag) . ' ' . $redName));
// Line 1 — VS matchup (blue vs red, matching the on-card layout).
$ogLineVs = '';
if ($blueTag !== '' && $redTag !== '') {
$ogLineVs = $blueTag . ' vs ' . $redTag;
} elseif ($blueTag !== '' || $redTag !== '') {
$ogLineVs = $blueTag ?: $redTag;
}
// Line 2 — event context, joined with " · " so it reads well even
// when a platform strips the newlines.
$ogParts = array_values(array_filter([
$event,
$weight,
$matchNo !== '' ? 'Match# ' . $matchNo : '',
$court !== '' ? 'Court ' . $court : '',
$referee,
], fn ($s) => $s !== ''));
$ogLineCtx = implode(' · ', $ogParts);
// Line 3 — engagement + date, mirroring the video meta row.
$viewsLbl = number_format((int) ($video->view_count ?? 0)) . ' views';
$dateLbl = optional($video->created_at)->format('M d, Y');
$ogLineMeta = trim($viewsLbl . ($dateLbl ? ' · ' . $dateLbl : ''));
$ogDescription = trim(implode("\n", array_filter([$ogLineVs, $ogLineCtx, $ogLineMeta])));
// Fall back to the raw description if the match record is empty.
if ($ogDescription === '') {
$ogDescription = Str::limit(strip_tags($video->description ?? (config('app.name') . ' — watch now')), 200);
}
// Append a version query so WhatsApp/Facebook recache when the match
// (fighters, flags, event, weight) is edited. The ogImage controller
// internally keys its file cache by the same stamp.
$ogStamp = optional($video->sportsMatch?->updated_at ?? $video->updated_at)->timestamp;
$ogUrl = route('videos.ogImage', $video) . ($ogStamp ? '?v=' . $ogStamp : '');
@endphp
<meta property="og:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:description" content="{{ $ogDescription }}">
<meta property="og:image" content="{{ $ogUrl }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:height" content="675">
<meta property="og:url" content="{{ $video->share_url }}">
<meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
<meta name="twitter:description" content="{{ $ogDescription }}">
<meta name="twitter:image" content="{{ $ogUrl }}">
@endpush
@section('extra_styles')