Files
SkinbaseNova/app/Services/NovaCards/NovaCardPlaywrightRenderService.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,
];
}
}