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) <noreply@anthropic.com>
This commit is contained in:
parent
665bad76bf
commit
3e18bd2150
47
app/Console/Commands/RenderMatchOgImage.php
Normal file
47
app/Console/Commands/RenderMatchOgImage.php
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
bin/browsershot-node.sh
Executable file
7
bin/browsershot-node.sh
Executable file
@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Wraps `node` with a raised file-descriptor limit. PHP-FPM starts with
|
||||
# rlimit_files=1024; Chromium opens ~2000 fds on startup and dies before
|
||||
# Puppeteer can attach, surfacing as "Failed to launch the browser
|
||||
# process: Code: null". Bumping just this subprocess tree avoids
|
||||
# restarting FPM to raise the pool-wide limit.
|
||||
exec /usr/bin/prlimit --nofile=65536 /usr/bin/node "$@"
|
||||
@ -14,7 +14,8 @@
|
||||
"laravel/tinker": "^2.8",
|
||||
"p7h/nas-file-manager": "dev-main",
|
||||
"php-ffmpeg/php-ffmpeg": "^1.4",
|
||||
"pragmarx/google2fa-laravel": "^3.0"
|
||||
"pragmarx/google2fa-laravel": "^3.0",
|
||||
"spatie/browsershot": "^3.61"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.9.1",
|
||||
|
||||
341
composer.lock
generated
341
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "40fa327b55e9b6fafab4b2da3f763724",
|
||||
"content-hash": "bdb64417cd2491642a0712fe40bade22",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@ -1457,6 +1457,90 @@
|
||||
],
|
||||
"time": "2025-08-22T14:27:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/image",
|
||||
"version": "2.7.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/image.git",
|
||||
"reference": "04be355f8d6734c826045d02a1079ad658322dad"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/04be355f8d6734c826045d02a1079ad658322dad",
|
||||
"reference": "04be355f8d6734c826045d02a1079ad658322dad",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"guzzlehttp/psr7": "~1.1 || ^2.0",
|
||||
"php": ">=5.4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "~0.9.2",
|
||||
"phpunit/phpunit": "^4.8 || ^5.7 || ^7.5.15"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "to use GD library based image processing.",
|
||||
"ext-imagick": "to use Imagick based image processing.",
|
||||
"intervention/imagecache": "Caching extension for the Intervention Image library"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Image": "Intervention\\Image\\Facades\\Image"
|
||||
},
|
||||
"providers": [
|
||||
"Intervention\\Image\\ImageServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "2.4-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Intervention\\Image\\": "src/Intervention/Image"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Oliver Vogel",
|
||||
"email": "oliver@intervention.io",
|
||||
"homepage": "https://intervention.io/"
|
||||
}
|
||||
],
|
||||
"description": "Image handling and manipulation library with support for Laravel integration",
|
||||
"homepage": "http://image.intervention.io/",
|
||||
"keywords": [
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"laravel",
|
||||
"thumbnail",
|
||||
"watermark"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/image/issues",
|
||||
"source": "https://github.com/Intervention/image/tree/2.7.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/interventionio",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Intervention",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2022-05-21T17:30:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "10.50.2",
|
||||
@ -2237,6 +2321,71 @@
|
||||
},
|
||||
"time": "2026-01-23T15:30:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/glide",
|
||||
"version": "2.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/glide.git",
|
||||
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/glide/zipball/b8e946dd87c79a9dce3290707ab90b5b52602813",
|
||||
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"intervention/image": "^2.7",
|
||||
"league/flysystem": "^2.0|^3.0",
|
||||
"php": "^7.2|^8.0",
|
||||
"psr/http-message": "^1.0|^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.3.3",
|
||||
"phpunit/php-token-stream": "^3.1|^4.0",
|
||||
"phpunit/phpunit": "^8.5|^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Glide\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jonathan Reinink",
|
||||
"email": "jonathan@reinink.ca",
|
||||
"homepage": "http://reinink.ca"
|
||||
},
|
||||
{
|
||||
"name": "Titouan Galopin",
|
||||
"email": "galopintitouan@gmail.com",
|
||||
"homepage": "https://titouangalopin.com"
|
||||
}
|
||||
],
|
||||
"description": "Wonderfully easy on-demand image manipulation library with an HTTP based API.",
|
||||
"homepage": "http://glide.thephpleague.com",
|
||||
"keywords": [
|
||||
"ImageMagick",
|
||||
"editing",
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"league",
|
||||
"manipulation",
|
||||
"processing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/glide/issues",
|
||||
"source": "https://github.com/thephpleague/glide/tree/2.3.2"
|
||||
},
|
||||
"time": "2025-03-21T13:48:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/mime-type-detection",
|
||||
"version": "1.16.0",
|
||||
@ -4021,6 +4170,196 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/browsershot",
|
||||
"version": "3.61.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/browsershot.git",
|
||||
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/browsershot/zipball/14d75679390b8b84a71b3a17dc5905928deeb887",
|
||||
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": "^8.0",
|
||||
"spatie/image": "^1.5.3|^2.0",
|
||||
"spatie/temporary-directory": "^1.1|^2.0",
|
||||
"symfony/process": "^4.2|^5.0|^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.20",
|
||||
"spatie/phpunit-snapshot-assertions": "^4.2.3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Browsershot\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"homepage": "https://github.com/freekmurze",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Convert a webpage to an image or pdf using headless Chrome",
|
||||
"homepage": "https://github.com/spatie/browsershot",
|
||||
"keywords": [
|
||||
"chrome",
|
||||
"convert",
|
||||
"headless",
|
||||
"image",
|
||||
"pdf",
|
||||
"puppeteer",
|
||||
"screenshot",
|
||||
"webpage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/browsershot/tree/3.61.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-12-21T10:00:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/image",
|
||||
"version": "2.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/image.git",
|
||||
"reference": "2f802853aab017aa615224daae1588054b5ab20e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/image/zipball/2f802853aab017aa615224daae1588054b5ab20e",
|
||||
"reference": "2f802853aab017aa615224daae1588054b5ab20e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-exif": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"league/glide": "^2.2.2",
|
||||
"php": "^8.0",
|
||||
"spatie/image-optimizer": "^1.7",
|
||||
"spatie/temporary-directory": "^1.0|^2.0",
|
||||
"symfony/process": "^3.0|^4.0|^5.0|^6.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.22",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"symfony/var-dumper": "^4.0|^5.0|^6.0",
|
||||
"vimeo/psalm": "^4.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Image\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Manipulate images with an expressive API",
|
||||
"homepage": "https://github.com/spatie/image",
|
||||
"keywords": [
|
||||
"image",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/image/tree/2.2.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-07-24T13:54:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/image-optimizer",
|
||||
"version": "1.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/image-optimizer.git",
|
||||
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/image-optimizer/zipball/333c03952289dc2df0a91874636a0dffeb5b6aec",
|
||||
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"php": "^7.4|^8.0",
|
||||
"psr/log": "^1.0 | ^2.0 | ^3.0",
|
||||
"symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^1.21|^2.0|^3.0|^4.0",
|
||||
"phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0",
|
||||
"symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\ImageOptimizer\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Easily optimize images using PHP",
|
||||
"homepage": "https://github.com/spatie/image-optimizer",
|
||||
"keywords": [
|
||||
"image-optimizer",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/image-optimizer/issues",
|
||||
"source": "https://github.com/spatie/image-optimizer/tree/1.10.0"
|
||||
},
|
||||
"time": "2026-06-29T08:28:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/temporary-directory",
|
||||
"version": "2.3.1",
|
||||
|
||||
2537
package-lock.json
generated
Normal file
2537
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -9,5 +9,8 @@
|
||||
"axios": "^1.6.4",
|
||||
"laravel-vite-plugin": "^1.0.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"puppeteer": "^24.43.1"
|
||||
}
|
||||
}
|
||||
|
||||
40
resources/views/admin/og-previews.blade.php
Normal file
40
resources/views/admin/og-previews.blade.php
Normal file
@ -0,0 +1,40 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'OG Previews')
|
||||
|
||||
@section('content')
|
||||
<div style="padding: 24px;">
|
||||
<h1 style="margin: 0 0 8px; color: #fff;">Generated VS Cards</h1>
|
||||
<p style="color: #aaa; margin: 0 0 20px;">
|
||||
These are the 1200×630 PNGs that WhatsApp, Facebook, and Twitter show
|
||||
when a sports match video is shared. Generated on save; regenerated on
|
||||
edit. Filename is <code>match-{video_id}-{updated_at}.png</code>.
|
||||
</p>
|
||||
|
||||
@if($files->isEmpty())
|
||||
<p style="color: #aaa;">No previews cached yet. Save a match video to generate one.</p>
|
||||
@else
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 20px;">
|
||||
@foreach($files as $f)
|
||||
<div style="background: #1a1a1a; border: 1px solid #333; border-radius: 12px; overflow: hidden;">
|
||||
<a href="{{ route('admin.og-previews.file', $f['name']) }}" target="_blank" style="display: block; background: #000;">
|
||||
<img src="{{ route('admin.og-previews.file', $f['name']) }}"
|
||||
alt="{{ $f['name'] }}"
|
||||
style="display: block; width: 100%; height: auto;">
|
||||
</a>
|
||||
<div style="padding: 12px 14px; color: #ccc; font-size: 13px;">
|
||||
<div style="font-weight: 600; color: #fff;">Video #{{ $f['id'] }}</div>
|
||||
<div style="color: #888; margin-top: 4px; font-family: monospace; font-size: 11px; word-break: break-all;">
|
||||
{{ $f['name'] }}
|
||||
</div>
|
||||
<div style="color: #888; margin-top: 4px;">
|
||||
{{ number_format($f['size'] / 1024, 1) }} KB ·
|
||||
{{ \Carbon\Carbon::createFromTimestamp($f['mtime'])->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@ -15,6 +15,28 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* When the 3-dot menu is open, promote this card above its siblings so
|
||||
the dropdown-menu (Bootstrap default z-index 1000) isn't visually
|
||||
covered by later cards in the grid. Cards are position:static by
|
||||
default, so siblings later in the DOM naturally paint on top even
|
||||
though the dropdown itself has a high z-index — the stacking is
|
||||
compared at the card level, not the dropdown level. `:has()` triggers
|
||||
only while a menu is `.show`ing, so this doesn't affect the normal
|
||||
grid layout. */
|
||||
.yt-video-card:has(.dropdown-menu.show) {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Same fix for the ⋮ menu container — .yt-video-info has overflow:hidden
|
||||
(to hard-cap card height for grid parity), which was clipping the
|
||||
dropdown's bottom edge. Allow the dropdown to escape by giving its
|
||||
wrapper a stacking context and letting overflow leak upward from that
|
||||
ancestor only when a menu is open. */
|
||||
.yt-video-card .yt-video-info:has(.dropdown-menu.show) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
|
||||
@ -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
|
||||
|
||||
80
resources/views/videos/partials/match/vs-og.blade.php
Normal file
80
resources/views/videos/partials/match/vs-og.blade.php
Normal file
@ -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>
|
||||
@ -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')
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user