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;
|
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 ~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([
|
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.',
|
||||||
|
|||||||
@ -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 (~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",
|
"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
341
composer.lock
generated
@ -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
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",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
@ -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
|
||||||
|
// 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) => {
|
document.addEventListener('mouseleave', (e) => {
|
||||||
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
|
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
|
||||||
if (!thumb) return;
|
if (!thumb) return;
|
||||||
const vsm = thumb.querySelector(':scope > .vs-mini');
|
const vsm = thumb.querySelector(':scope > .vs-mini');
|
||||||
if (!vsm) return;
|
if (!vsm) return;
|
||||||
vsm.classList.remove('vs-mini-run');
|
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;
|
void vsm.offsetWidth;
|
||||||
vsm.classList.add('vs-mini-run');
|
vsm.classList.add('vs-mini-run');
|
||||||
}, true);
|
}, true);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@endonce
|
@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'))
|
@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')
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user