128 lines
4.3 KiB
PHP
128 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\NovaCards;
|
|
|
|
use App\Models\NovaCard;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\URL;
|
|
use RuntimeException;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
/**
|
|
* Renders a Nova Card by loading the editor's React canvas preview inside a
|
|
* headless Chromium (via Playwright) and screenshotting it.
|
|
*
|
|
* This produces a pixel-perfect image that matches what users see in the editor,
|
|
* including web fonts, CSS container queries, and CSS gradients/blend modes.
|
|
*
|
|
* Prerequisites:
|
|
* - Node.js must be in PATH.
|
|
* - Playwright browsers must be installed (`npx playwright install chromium`).
|
|
* - NOVA_CARDS_PLAYWRIGHT_RENDER=true in .env (default: false).
|
|
* - APP_URL must be reachable from the queue worker (used for the signed URL).
|
|
*/
|
|
class NovaCardPlaywrightRenderService
|
|
{
|
|
public function isAvailable(): bool
|
|
{
|
|
if (! config('nova_cards.playwright_render', false)) {
|
|
return false;
|
|
}
|
|
|
|
// Quick check: verify `node` is executable.
|
|
$probe = new Process(['node', '--version']);
|
|
$probe->run();
|
|
|
|
return $probe->isSuccessful();
|
|
}
|
|
|
|
public function render(NovaCard $card): array
|
|
{
|
|
if (! $this->isAvailable()) {
|
|
throw new RuntimeException('Playwright rendering is disabled or Node.js is not available.');
|
|
}
|
|
|
|
$format = Arr::get(config('nova_cards.formats'), $card->format, config('nova_cards.formats.square'));
|
|
$width = (int) Arr::get($format, 'width', 1080);
|
|
$height = (int) Arr::get($format, 'height', 1080);
|
|
|
|
// Signed URL is valid for 10 minutes — enough for the queue job.
|
|
$signedUrl = URL::temporarySignedRoute(
|
|
'nova-cards.render-frame',
|
|
now()->addMinutes(10),
|
|
['uuid' => $card->uuid],
|
|
);
|
|
|
|
$tmpDir = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'nova-render-' . $card->uuid;
|
|
$pngPath = $tmpDir . DIRECTORY_SEPARATOR . 'canvas.png';
|
|
|
|
if (! is_dir($tmpDir)) {
|
|
mkdir($tmpDir, 0755, true);
|
|
}
|
|
|
|
$script = base_path('scripts/render-nova-card.cjs');
|
|
$process = new Process(
|
|
['node', $script, "--url={$signedUrl}", "--out={$pngPath}", "--width={$width}", "--height={$height}"],
|
|
null,
|
|
null,
|
|
null,
|
|
60, // seconds
|
|
);
|
|
|
|
$process->run();
|
|
|
|
if (! $process->isSuccessful()) {
|
|
throw new RuntimeException('Playwright render failed: ' . trim($process->getErrorOutput()));
|
|
}
|
|
|
|
if (! file_exists($pngPath) || filesize($pngPath) < 100) {
|
|
throw new RuntimeException('Playwright render produced no output or an empty file.');
|
|
}
|
|
|
|
$pngBlob = (string) file_get_contents($pngPath);
|
|
@unlink($pngPath);
|
|
@rmdir($tmpDir);
|
|
|
|
// Convert the PNG to WebP + JPEG using GD (already a project dependency).
|
|
$img = @imagecreatefromstring($pngBlob);
|
|
if ($img === false) {
|
|
throw new RuntimeException('Could not decode Playwright PNG output with GD.');
|
|
}
|
|
|
|
ob_start();
|
|
imagewebp($img, null, (int) config('nova_cards.render.preview_quality', 86));
|
|
$webpBinary = (string) ob_get_clean();
|
|
|
|
ob_start();
|
|
imagejpeg($img, null, (int) config('nova_cards.render.og_quality', 88));
|
|
$jpgBinary = (string) ob_get_clean();
|
|
|
|
imagedestroy($img);
|
|
|
|
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
|
|
$basePath = trim((string) config('nova_cards.storage.preview_prefix', 'cards/previews'), '/') . '/' . $card->user_id;
|
|
$previewPath = $basePath . '/' . $card->uuid . '.webp';
|
|
$ogPath = $basePath . '/' . $card->uuid . '-og.jpg';
|
|
|
|
$disk->put($previewPath, $webpBinary, 'public');
|
|
$disk->put($ogPath, $jpgBinary, 'public');
|
|
|
|
$card->forceFill([
|
|
'preview_path' => $previewPath,
|
|
'preview_width' => $width,
|
|
'preview_height' => $height,
|
|
'last_rendered_at' => now(),
|
|
])->save();
|
|
|
|
return [
|
|
'preview_path' => $previewPath,
|
|
'og_path' => $ogPath,
|
|
'width' => $width,
|
|
'height' => $height,
|
|
];
|
|
}
|
|
}
|