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:
1 parent
665bad76bf
commit
3e18bd2150
14 files changed
+3562
-21
No files matched your search
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Http\Controllers\VideoController;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Renders the 1200×630 VS composition PNG for a match video's social-share
|
||||
* preview. Runs the same helper used by VideoController@ogImage but from a
|
||||
* CLI process context, because Chromium reliably fails to launch under
|
||||
* PHP-FPM ("Failed to launch the browser process: Code: null") on this
|
||||
* host. Invoke detached via `nohup` from either the ogImage endpoint (on
|
||||
* cache miss) or SportsMatchController on save.
|
||||
*/
|
||||
class RenderMatchOgImage extends Command
|
||||
{
|
||||
protected $signature = 'og:render-match {video}';
|
||||
protected $description = 'Render the OG social-share PNG for a match video';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$video = Video::find($this->argument('video'));
|
||||
if (! $video) {
|
||||
$this->error("Video {$this->argument('video')} not found.");
|
||||
return 1;
|
||||
}
|
||||
if ($video->type !== 'match' || ! $video->sportsMatch) {
|
||||
$this->error("Video {$video->id} is not a match with a sports record.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$controller = new VideoController();
|
||||
$r = new \ReflectionMethod($controller, 'renderMatchOgImage');
|
||||
$r->setAccessible(true);
|
||||
$png = $r->invoke($controller, $video);
|
||||
|
||||
if ($png === null) {
|
||||
$this->error("Render failed. See laravel.log.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Rendered " . strlen($png) . " bytes for video {$video->id}.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\VideoController;
|
||||
use App\Models\SportsMatch;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -39,6 +40,18 @@ class SportsMatchController extends Controller
|
||||
$match->video->save();
|
||||
}
|
||||
|
||||
// Prefetch the OG social-share PNG so WhatsApp/Facebook crawlers
|
||||
// get the composed VS card on first share, not the fallback frame.
|
||||
if ($match->video) {
|
||||
// Blocks the response by ~3–5s while the VS PNG renders. That
|
||||
// wait is intentional: the user typically shares the URL right
|
||||
// after saving, and WhatsApp/Facebook cache whatever they see
|
||||
// on first crawl. If the render hasn't finished when the
|
||||
// crawler hits, we'd be stuck on the thumbnail fallback in
|
||||
// every future share.
|
||||
(new VideoController())->renderMatchOgSync($match->video->fresh(['sportsMatch']));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Match saved as draft.',
|
||||
@@ -64,6 +77,13 @@ class SportsMatchController extends Controller
|
||||
$sportsMatch->video->save();
|
||||
}
|
||||
|
||||
if ($sportsMatch->video) {
|
||||
// Synchronous re-render on edit: guarantees the fresh VS card
|
||||
// exists at the new versioned URL before the JSON response
|
||||
// returns and the user can share the link. See renderMatchOgSync.
|
||||
(new VideoController())->renderMatchOgSync($sportsMatch->video->fresh(['sportsMatch']));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Match updated.',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user