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

+29
View File
@@ -61,6 +61,35 @@ Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->n
Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']);
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
// vs-og-frame is the Browsershot-only, no-animation, 1200×630 render used by
// the OG image generator. Distinct from /vs-preview (line 43) which is a
// full-page interactive preview of the arena intro overlay.
Route::get('/videos/{video}/vs-og-frame', [VideoController::class, 'vsPreview'])->name('videos.vsOgFrame');
// Admin-only gallery of all generated OG preview PNGs (the composed VS
// cards that WhatsApp/Facebook see when a match video URL is shared).
Route::get('/admin/og-previews', function () {
abort_unless(auth()->check() && auth()->user()->isSuperAdmin(), 403);
$files = collect(glob(storage_path('app/og-cache/match-*.{jpg,png}'), GLOB_BRACE) ?: [])
->map(fn ($f) => [
'name' => basename($f),
'size' => filesize($f),
'mtime' => filemtime($f),
'id' => (int) (explode('-', pathinfo($f, PATHINFO_FILENAME))[1] ?? 0),
])
->sortByDesc('mtime')
->values();
return view('admin.og-previews', ['files' => $files]);
})->name('admin.og-previews');
Route::get('/admin/og-previews/{name}', function (string $name) {
abort_unless(auth()->check() && auth()->user()->isSuperAdmin(), 403);
abort_unless(preg_match('/^match-\d+-\d+\.(jpg|png)$/', $name), 404);
$path = storage_path('app/og-cache/' . $name);
abort_unless(is_file($path), 404);
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return response()->file($path, ['Content-Type' => $ext === 'png' ? 'image/png' : 'image/jpeg']);
})->name('admin.og-previews.file');
Route::get('/s/{token}', [VideoController::class, 'accessShare'])->name('share.access');
Route::get('/videos/{video}/insights', [VideoController::class, 'insights'])->name('videos.insights')->middleware('auth');
Route::get('/videos/{video}/insights/country/{country}', [VideoController::class, 'insightsCountry'])->name('videos.insights.country')->middleware('auth');