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

+280 -1
View File
@@ -26,7 +26,7 @@ class VideoController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
}
public function index()
@@ -3126,12 +3126,65 @@ class VideoController extends Controller
$dest .= '?track=' . (int) $request->input('track');
}
// Social scrapers (WhatsApp, Facebook, Twitter, LinkedIn, Telegram,
// Discord, Slack) frequently DO NOT follow 302 redirects when
// building link previews — they read og:* tags from the response
// body of the URL they were given. Serving them a redirect leaves
// them with no title/image, which is what the user hit today. For
// those user agents, render the target video page directly so the
// OG tags (title, description, VS card image) are in the response.
// Real users still get the 302 so cookies + tracking behave the same.
$ua = (string) $request->userAgent();
$isScraper = (bool) preg_match(
'#(whatsapp|facebookexternalhit|facebot|twitterbot|linkedinbot|slackbot|telegrambot|discordbot|skypeuripreview|pinterest|redditbot|embedly|vkshare|w3c_validator|bingbot|googlebot|applebot|yandex|duckduckbot|iframely|nuzzel|quora link preview|outbrain)#i',
$ua
);
if ($isScraper) {
return $this->show($request, $video);
}
return redirect($dest)
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
/**
* Standalone HTML page that renders the sports-match VS composition
* at 1200×630. Called only by Browsershot from ogImage() below to
* generate the social-share preview PNG for match videos.
*/
public function vsPreview(Video $video)
{
abort_unless($video->type === 'match' && $video->sportsMatch, 404);
// Force http scheme for URLs generated on this page (asset(), route(),
// media thumbnail routes) — the ogImage renderer proxies Chromium at
// this route via loopback (http://127.0.0.1:80), so the app's global
// URL::forceScheme('https') would produce URLs Chromium can't reach.
\Illuminate\Support\Facades\URL::forceScheme('http');
return response()
->view('videos.partials.match.vs-og', ['video' => $video])
->header('X-Robots-Tag', 'noindex, nofollow');
}
public function ogImage(Video $video)
{
// Sports matches get a rendered VS card — the actual render is done
// by a CLI artisan command (Chromium can't launch under PHP-FPM
// reliably), fired detached on cache miss + prefetched on match
// save. If the cached PNG exists, serve it. Otherwise return the
// thumbnail fallback below and let the background job produce the
// PNG for future requests.
if ($video->type === 'match' && $video->sportsMatch) {
$cached = $this->cachedMatchOgPath($video);
if (is_file($cached)) {
return response()->file($cached, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
$this->dispatchMatchOgRender($video);
// Fall through to thumbnail path while the render runs.
}
// If video has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews
if ($video->thumbnail) {
$path = $video->localThumbnailPath();
@@ -4241,5 +4294,231 @@ class VideoController extends Controller
'recent' => $recent,
]);
}
/**
* Absolute path of the cache file for this match's OG PNG. The stamp
* embedded in the filename is sportsMatch.updated_at so a match edit
* automatically produces a fresh URL and stale variants get cleaned
* during the next render.
*/
public function cachedMatchOgPath(Video $video): string
{
$stamp = optional($video->sportsMatch->updated_at ?? $video->updated_at)->timestamp
?? $video->id;
// JPEG (not PNG) — WhatsApp silently rejects OG images above ~300 KB
// and the composed PNG lands at ~700 KB. A quality-85 JPEG of the
// same frame is 100-180 KB and looks identical for a share preview.
return storage_path("app/og-cache/match-{$video->id}-{$stamp}.jpg");
}
/**
* Render the OG PNG synchronously by spawning the artisan CLI and
* waiting for it to finish (~3–5s). Called from SportsMatchController
* on save so the file is guaranteed to exist before the user copies
* the share URL — critical because WhatsApp/Facebook crawl the URL
* once and cache whatever they get, so if the crawler beats the
* render we end up permanently stuck on the fallback thumbnail.
*
* Chrome won't launch under PHP-FPM's process context (missing HOME,
* stripped env). The artisan CLI subprocess is invoked with a full
* env explicitly restored, which sidesteps the FPM restriction.
*/
public function renderMatchOgSync(Video $video, int $timeoutSeconds = 45): bool
{
// Skip if the PNG for this exact stamp already exists.
$cacheFile = $this->cachedMatchOgPath($video);
if (is_file($cacheFile) && filesize($cacheFile) > 0) {
return true;
}
$php = '/usr/bin/php';
$art = base_path('artisan');
$cmd = sprintf(
'/usr/bin/env HOME=/tmp TMPDIR=/tmp PATH=/usr/bin:/bin %s %s og:render-match %d 2>&1',
escapeshellcmd($php),
escapeshellarg($art),
(int) $video->id
);
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$env = ['HOME' => '/tmp', 'TMPDIR' => '/tmp', 'PATH' => '/usr/bin:/bin'];
$proc = @proc_open($cmd, $descriptors, $pipes, '/tmp', $env);
if (! is_resource($proc)) {
\Log::warning('renderMatchOgSync: proc_open failed', ['video' => $video->id]);
return false;
}
stream_set_blocking($pipes[1], false);
$deadline = microtime(true) + $timeoutSeconds;
while (microtime(true) < $deadline) {
$status = proc_get_status($proc);
if (! $status['running']) break;
usleep(200000);
}
$out = @stream_get_contents($pipes[1]);
@fclose($pipes[1]);
@fclose($pipes[2]);
$exit = proc_close($proc);
if (! is_file($cacheFile)) {
\Log::warning('renderMatchOgSync: render finished without a PNG', [
'video' => $video->id,
'exit' => $exit,
'output' => $out,
]);
return false;
}
return true;
}
/**
* Fire the artisan renderer in a detached background process so this
* request can respond immediately with the fallback thumbnail. Used
* as a last-resort backfill when ogImage is hit and no PNG exists
* (e.g. matches that pre-date this feature).
*/
public function dispatchMatchOgRender(Video $video): void
{
// Skip if a render was fired for this stamp very recently — the
// marker file avoids stampeding multiple detached processes for
// repeated hits before the first one finishes.
$marker = $this->cachedMatchOgPath($video) . '.rendering';
if (is_file($marker) && (time() - filemtime($marker)) < 60) {
return;
}
@mkdir(dirname($marker), 0775, true);
@touch($marker);
// PHP_BINARY under FPM is /usr/sbin/php-fpm, which won't run
// artisan — must use the CLI binary explicitly.
$php = '/usr/bin/php';
$art = base_path('artisan');
$log = storage_path('logs/og-render.log');
// `setsid` detaches the child into a new session so it survives
// FPM's request teardown. `< /dev/null` closes stdin so bash
// treats it as fully backgrounded. `env HOME=/tmp` is mandatory:
// FPM clears the environment (clear_env = yes), the artisan
// command inherits that empty env, and Puppeteer/Chromium then
// fail with "Failed to launch the browser process: Code: null"
// because they can't resolve a user-data directory without HOME.
$cmd = sprintf(
'setsid /usr/bin/env HOME=/tmp TMPDIR=/tmp PATH=/usr/bin:/bin %s %s og:render-match %d >> %s 2>&1 < /dev/null &',
escapeshellcmd($php),
escapeshellarg($art),
(int) $video->id,
escapeshellarg($log)
);
@exec($cmd);
}
/**
* Render (or return the cached) 1200×630 PNG of the sports-match VS
* composition. Cached under data/app/og-cache/match-{id}-{stamp}.png
* so a match edit (which bumps updated_at) automatically produces a
* fresh URL for social recrawlers.
*/
public function renderMatchOgImage(Video $video): ?string
{
$cacheFile = $this->cachedMatchOgPath($video);
$cacheDir = dirname($cacheFile);
if (is_file($cacheFile)) {
return @file_get_contents($cacheFile) ?: null;
}
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true);
}
// Purge older stamped variants for this match so the cache dir
// doesn't grow unbounded across edits. Includes the legacy .png
// extension in case any old files are still around.
foreach (glob($cacheDir . "/match-{$video->id}-*.{jpg,png}", GLOB_BRACE) ?: [] as $old) {
if ($old !== $cacheFile) @unlink($old);
}
@unlink($cacheFile . '.rendering');
try {
// Rebuild the URL as plain http on the app's own hostname so
// Chromium can reach it via loopback. --host-resolver-rules
// below points that hostname at 127.0.0.1:80 (nginx). This
// sidesteps Cloudflare (origin isn't reachable from itself
// over its public hostname) without needing a local TLS cert.
// Hit nginx over loopback. server_name is `_` (catch-all) so
// Host header doesn't matter. Chromium can't reach the origin
// over its public hostname (Cloudflare-fronted) and mapping
// the hostname via --host-resolver-rules trips ERR_BLOCKED_BY_CLIENT
// on public TLDs. vsPreview() forces http on generated URLs
// so all subresources (flag CSS, media thumbnails, headshots)
// also resolve to this loopback origin.
$url = 'http://127.0.0.1' . route('videos.vsOgFrame', $video, false);
$chrome = base_path('data/puppeteer-cache/chrome');
$binary = null;
if (is_dir($chrome)) {
$matches = glob($chrome . '/linux-*/chrome-linux64/chrome');
if ($matches) { $binary = $matches[0]; }
}
// PHP-FPM runs without a HOME env var; Chromium's user-data-dir
// resolution fails without one and the browser exits before
// Puppeteer can attach. /tmp is writable by www-data and gets
// cleaned periodically, which is fine for a screenshot job.
$shot = \Spatie\Browsershot\Browsershot::url($url)
->windowSize(1200, 675)
->deviceScaleFactor(1)
->waitUntilNetworkIdle()
// Output JPEG @ q85 (~150 KB) instead of PNG (~700 KB).
// WhatsApp silently drops OG images above roughly 300 KB.
->setScreenshotType('jpeg', 85)
->setNpmBinary('/usr/bin/npm')
// PHP-FPM starts with a stripped env (clear_env = yes) so
// Chromium sees no HOME/TMPDIR/PATH. Without HOME the
// user-data-dir can't be created; without TMPDIR crashpad's
// shmem sockets can't bind; without PATH Chromium can't
// fork its helper processes. Pass all three explicitly.
->setEnvironmentOptions([
'HOME' => '/tmp',
'TMPDIR' => '/tmp',
'PATH' => '/usr/bin:/bin',
])
// PHP-FPM defaults rlimit_files to 1024; Chromium opens
// way more than that on startup and dies silently
// ("Failed to launch the browser process: Code: null").
// The wrapper script re-execs node with a raised limit
// via prlimit, so we don't have to restart php-fpm to
// bump the pool-wide rlimit_files setting.
->setNodeBinary(base_path('bin/browsershot-node.sh'))
->noSandbox()
->addChromiumArguments([
'disable-gpu',
'hide-scrollbars',
'disable-dev-shm-usage',
// FPM's process context confuses Chromium's zygote —
// crashpad fails to fork with its args and chrome
// exits before puppeteer can attach. Isolating the
// user-data-dir per request + turning off crash
// reporting sidesteps the whole handler chain.
'user-data-dir=/tmp/chrome-og-' . getmypid() . '-' . mt_rand(),
'disable-crash-reporter',
'no-crash-upload',
]);
if ($binary) {
$shot->setChromePath($binary);
}
$shot->save($cacheFile);
} catch (\Throwable $e) {
\Log::warning('vs og render failed', [
'video' => $video->id,
'error' => $e->getMessage(),
]);
return null;
}
return is_file($cacheFile) ? (@file_get_contents($cacheFile) ?: null) : null;
}
}