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:
ghassan 2026-08-11 12:34:40 +03:00
parent 665bad76bf
commit 3e18bd2150
14 changed files with 3562 additions and 21 deletions

View 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;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Controllers\VideoController;
use App\Models\SportsMatch; use App\Models\SportsMatch;
use App\Services\NasSyncService; use App\Services\NasSyncService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -39,6 +40,18 @@ class SportsMatchController extends Controller
$match->video->save(); $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 ~35s 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([ return response()->json([
'ok' => true, 'ok' => true,
'message' => 'Match saved as draft.', 'message' => 'Match saved as draft.',
@ -64,6 +77,13 @@ class SportsMatchController extends Controller
$sportsMatch->video->save(); $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([ return response()->json([
'ok' => true, 'ok' => true,
'message' => 'Match updated.', 'message' => 'Match updated.',

View File

@ -26,7 +26,7 @@ class VideoController extends Controller
{ {
public function __construct() 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() public function index()
@ -3126,12 +3126,65 @@ class VideoController extends Controller
$dest .= '?track=' . (int) $request->input('track'); $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) return redirect($dest)
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5)); ->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) 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 has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews
if ($video->thumbnail) { if ($video->thumbnail) {
$path = $video->localThumbnailPath(); $path = $video->localThumbnailPath();
@ -4241,5 +4294,231 @@ class VideoController extends Controller
'recent' => $recent, '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 (~35s). 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
View 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 "$@"

View File

@ -14,7 +14,8 @@
"laravel/tinker": "^2.8", "laravel/tinker": "^2.8",
"p7h/nas-file-manager": "dev-main", "p7h/nas-file-manager": "dev-main",
"php-ffmpeg/php-ffmpeg": "^1.4", "php-ffmpeg/php-ffmpeg": "^1.4",
"pragmarx/google2fa-laravel": "^3.0" "pragmarx/google2fa-laravel": "^3.0",
"spatie/browsershot": "^3.61"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.9.1", "fakerphp/faker": "^1.9.1",

341
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "40fa327b55e9b6fafab4b2da3f763724", "content-hash": "bdb64417cd2491642a0712fe40bade22",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -1457,6 +1457,90 @@
], ],
"time": "2025-08-22T14:27:06+00:00" "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", "name": "laravel/framework",
"version": "10.50.2", "version": "10.50.2",
@ -2237,6 +2321,71 @@
}, },
"time": "2026-01-23T15:30:45+00:00" "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", "name": "league/mime-type-detection",
"version": "1.16.0", "version": "1.16.0",
@ -4021,6 +4170,196 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "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", "name": "spatie/temporary-directory",
"version": "2.3.1", "version": "2.3.1",

2537
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,5 +9,8 @@
"axios": "^1.6.4", "axios": "^1.6.4",
"laravel-vite-plugin": "^1.0.0", "laravel-vite-plugin": "^1.0.0",
"vite": "^5.0.0" "vite": "^5.0.0"
},
"dependencies": {
"puppeteer": "^24.43.1"
} }
} }

View 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 &middot;
{{ \Carbon\Carbon::createFromTimestamp($f['mtime'])->diffForHumans() }}
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
@endsection

View File

@ -15,6 +15,28 @@
flex-direction: column; 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 { .yt-video-card .yt-video-thumb {
position: relative; position: relative;
aspect-ratio: 16/9; aspect-ratio: 16/9;

View File

@ -205,6 +205,68 @@
background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%); background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%);
overflow: hidden; overflow: hidden;
will-change: transform; 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 + /* Animations only run while the .vs-mini-run class is set (removed +
@ -489,6 +551,9 @@
const sw = portrait ? 1080 : 1920; const sw = portrait ? 1080 : 1920;
const sh = portrait ? 1920 : 1080; const sh = portrait ? 1920 : 1080;
el.style.setProperty('--vs-mini-scale', Math.min(w / sw, h / sh)); 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 => { const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
@ -518,19 +583,22 @@
}).observe(document.body, { childList: true, subtree: true }); }).observe(document.body, { childList: true, subtree: true });
// Restart entrance animations whenever the mouse leaves a card thumb // Restart entrance animations whenever the mouse leaves a card thumb
// that has a VS mini in it. removing + re-adding the class in the // that has a VS mini in it. Only bind on real hover devices — on touch,
// next frame forces the CSS animation to play from the start. // Chrome synthesizes mouseleave on tap-lift and during scroll, which
document.addEventListener('mouseleave', (e) => { // kept restarting animations and leaving cards frozen mid-intro
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null; // (elements at opacity:0 during the 0.51.3s animation-delay window).
if (!thumb) return; const hasHover = window.matchMedia && window.matchMedia('(hover: hover)').matches;
const vsm = thumb.querySelector(':scope > .vs-mini'); if (hasHover) {
if (!vsm) return; document.addEventListener('mouseleave', (e) => {
vsm.classList.remove('vs-mini-run'); const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
// Reflow-then-reapply — CSS animations only restart when the if (!thumb) return;
// running class is removed and reintroduced across a frame. const vsm = thumb.querySelector(':scope > .vs-mini');
void vsm.offsetWidth; if (!vsm) return;
vsm.classList.add('vs-mini-run'); vsm.classList.remove('vs-mini-run');
}, true); void vsm.offsetWidth;
vsm.classList.add('vs-mini-run');
}, true);
}
})(); })();
</script> </script>
@endonce @endonce

View 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>

View File

@ -3,18 +3,87 @@
@section('title', ($video->title ?? 'Match Video') . ' | ' . config('app.name')) @section('title', ($video->title ?? 'Match Video') . ' | ' . config('app.name'))
@push('head') @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:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}"> <meta property="og:description" content="{{ $ogDescription }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}"> <meta property="og:image" content="{{ $ogUrl }}">
<meta property="og:image:width" content="1200"> <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:url" content="{{ $video->share_url }}">
<meta property="og:type" content="video.other"> <meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}"> <meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}"> <meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}"> <meta name="twitter:description" content="{{ $ogDescription }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}"> <meta name="twitter:image" content="{{ $ogUrl }}">
@endpush @endpush
@section('extra_styles') @section('extra_styles')

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/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::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'); 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('/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', [VideoController::class, 'insights'])->name('videos.insights')->middleware('auth');
Route::get('/videos/{video}/insights/country/{country}', [VideoController::class, 'insightsCountry'])->name('videos.insights.country')->middleware('auth'); Route::get('/videos/{video}/insights/country/{country}', [VideoController::class, 'insightsCountry'])->name('videos.insights.country')->middleware('auth');