optimizations
This commit is contained in:
350
app/Services/NovaCards/NovaCardRenderService.php
Normal file
350
app/Services/NovaCards/NovaCardRenderService.php
Normal file
@@ -0,0 +1,350 @@
|
||||
<?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\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class NovaCardRenderService
|
||||
{
|
||||
public function render(NovaCard $card): array
|
||||
{
|
||||
if (! function_exists('imagecreatetruecolor')) {
|
||||
throw new RuntimeException('Nova card rendering requires the GD extension.');
|
||||
}
|
||||
|
||||
$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);
|
||||
$project = is_array($card->project_json) ? $card->project_json : [];
|
||||
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
imagealphablending($image, true);
|
||||
imagesavealpha($image, true);
|
||||
|
||||
$this->paintBackground($image, $card, $width, $height, $project);
|
||||
$this->paintOverlay($image, $project, $width, $height);
|
||||
$this->paintText($image, $card, $project, $width, $height);
|
||||
$this->paintDecorations($image, $project, $width, $height);
|
||||
$this->paintAssets($image, $project, $width, $height);
|
||||
|
||||
$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';
|
||||
|
||||
ob_start();
|
||||
imagewebp($image, null, (int) config('nova_cards.render.preview_quality', 86));
|
||||
$webpBinary = (string) ob_get_clean();
|
||||
|
||||
ob_start();
|
||||
imagejpeg($image, null, (int) config('nova_cards.render.og_quality', 88));
|
||||
$jpgBinary = (string) ob_get_clean();
|
||||
|
||||
imagedestroy($image);
|
||||
|
||||
$disk->put($previewPath, $webpBinary);
|
||||
$disk->put($ogPath, $jpgBinary);
|
||||
|
||||
$card->forceFill([
|
||||
'preview_path' => $previewPath,
|
||||
'preview_width' => $width,
|
||||
'preview_height' => $height,
|
||||
])->save();
|
||||
|
||||
return [
|
||||
'preview_path' => $previewPath,
|
||||
'og_path' => $ogPath,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
];
|
||||
}
|
||||
|
||||
private function paintBackground($image, NovaCard $card, int $width, int $height, array $project): void
|
||||
{
|
||||
$background = Arr::get($project, 'background', []);
|
||||
$type = (string) Arr::get($background, 'type', $card->background_type ?: 'gradient');
|
||||
|
||||
if ($type === 'solid') {
|
||||
$color = $this->allocateHex($image, (string) Arr::get($background, 'solid_color', '#111827'));
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, $color);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'upload' && $card->backgroundImage?->processed_path) {
|
||||
$this->paintImageBackground($image, $card->backgroundImage->processed_path, $width, $height);
|
||||
} else {
|
||||
$colors = Arr::wrap(Arr::get($background, 'gradient_colors', ['#0f172a', '#1d4ed8']));
|
||||
$from = (string) Arr::get($colors, 0, '#0f172a');
|
||||
$to = (string) Arr::get($colors, 1, '#1d4ed8');
|
||||
$this->paintVerticalGradient($image, $width, $height, $from, $to);
|
||||
}
|
||||
}
|
||||
|
||||
private function paintImageBackground($image, string $path, int $width, int $height): void
|
||||
{
|
||||
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
|
||||
if (! $disk->exists($path)) {
|
||||
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$blob = $disk->get($path);
|
||||
$background = @imagecreatefromstring($blob);
|
||||
if ($background === false) {
|
||||
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$focalPosition = (string) Arr::get($card->project_json, 'background.focal_position', 'center');
|
||||
[$srcX, $srcY] = $this->resolveFocalSourceOrigin($focalPosition, imagesx($background), imagesy($background));
|
||||
|
||||
imagecopyresampled(
|
||||
$image,
|
||||
$background,
|
||||
0,
|
||||
0,
|
||||
$srcX,
|
||||
$srcY,
|
||||
$width,
|
||||
$height,
|
||||
max(1, imagesx($background) - $srcX),
|
||||
max(1, imagesy($background) - $srcY)
|
||||
);
|
||||
|
||||
$blurLevel = (int) Arr::get($card->project_json, 'background.blur_level', 0);
|
||||
for ($index = 0; $index < (int) floor($blurLevel / 4); $index++) {
|
||||
imagefilter($image, IMG_FILTER_GAUSSIAN_BLUR);
|
||||
}
|
||||
|
||||
imagedestroy($background);
|
||||
}
|
||||
|
||||
private function paintOverlay($image, array $project, int $width, int $height): void
|
||||
{
|
||||
$style = (string) Arr::get($project, 'background.overlay_style', 'dark-soft');
|
||||
$alpha = match ($style) {
|
||||
'dark-strong' => 72,
|
||||
'dark-soft' => 92,
|
||||
'light-soft' => 108,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($alpha === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rgb = $style === 'light-soft' ? [255, 255, 255] : [0, 0, 0];
|
||||
$overlay = imagecolorallocatealpha($image, $rgb[0], $rgb[1], $rgb[2], $alpha);
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, $overlay);
|
||||
}
|
||||
|
||||
private function paintText($image, NovaCard $card, array $project, int $width, int $height): void
|
||||
{
|
||||
$textColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.text_color', '#ffffff'));
|
||||
$authorColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', Arr::get($project, 'typography.text_color', '#ffffff')));
|
||||
$alignment = (string) Arr::get($project, 'layout.alignment', 'center');
|
||||
$lineHeightMultiplier = (float) Arr::get($project, 'typography.line_height', 1.2);
|
||||
$shadowPreset = (string) Arr::get($project, 'typography.shadow_preset', 'soft');
|
||||
$paddingRatio = match ((string) Arr::get($project, 'layout.padding', 'comfortable')) {
|
||||
'tight' => 0.08,
|
||||
'airy' => 0.15,
|
||||
default => 0.11,
|
||||
};
|
||||
$xPadding = (int) round($width * $paddingRatio);
|
||||
$maxLineWidth = match ((string) Arr::get($project, 'layout.max_width', 'balanced')) {
|
||||
'compact' => (int) round($width * 0.5),
|
||||
'wide' => (int) round($width * 0.78),
|
||||
default => (int) round($width * 0.64),
|
||||
};
|
||||
|
||||
$textBlocks = $this->resolveTextBlocks($card, $project);
|
||||
$charWidth = imagefontwidth(5);
|
||||
$lineHeight = max(imagefontheight(5) + 4, (int) round((imagefontheight(5) + 2) * $lineHeightMultiplier));
|
||||
$charsPerLine = max(14, (int) floor($maxLineWidth / max(1, $charWidth)));
|
||||
$textBlockHeight = 0;
|
||||
foreach ($textBlocks as $block) {
|
||||
$font = $this->fontForBlockType((string) ($block['type'] ?? 'body'));
|
||||
$wrapped = preg_split('/\r\n|\r|\n/', wordwrap((string) ($block['text'] ?? ''), max(10, $charsPerLine - ($font === 3 ? 4 : 0)), "\n", true)) ?: [(string) ($block['text'] ?? '')];
|
||||
$textBlockHeight += count($wrapped) * max(imagefontheight($font) + 4, (int) round((imagefontheight($font) + 2) * $lineHeightMultiplier));
|
||||
$textBlockHeight += 18;
|
||||
}
|
||||
$position = (string) Arr::get($project, 'layout.position', 'center');
|
||||
$startY = match ($position) {
|
||||
'top' => (int) round($height * 0.14),
|
||||
'upper-middle' => (int) round($height * 0.26),
|
||||
'lower-middle' => (int) round($height * 0.58),
|
||||
'bottom' => max($xPadding, $height - $textBlockHeight - (int) round($height * 0.12)),
|
||||
default => (int) round(($height - $textBlockHeight) / 2),
|
||||
};
|
||||
|
||||
foreach ($textBlocks as $block) {
|
||||
$type = (string) ($block['type'] ?? 'body');
|
||||
$font = $this->fontForBlockType($type);
|
||||
$color = in_array($type, ['author', 'source', 'title'], true) ? $authorColor : $textColor;
|
||||
$prefix = $type === 'author' ? '— ' : '';
|
||||
$value = $prefix . (string) ($block['text'] ?? '');
|
||||
$wrapped = preg_split('/\r\n|\r|\n/', wordwrap($type === 'title' ? strtoupper($value) : $value, max(10, $charsPerLine - ($font === 3 ? 4 : 0)), "\n", true)) ?: [$value];
|
||||
$blockLineHeight = max(imagefontheight($font) + 4, (int) round((imagefontheight($font) + 2) * $lineHeightMultiplier));
|
||||
|
||||
foreach ($wrapped as $line) {
|
||||
$lineWidth = imagefontwidth($font) * strlen($line);
|
||||
$x = $this->resolveAlignedX($alignment, $width, $xPadding, $lineWidth);
|
||||
$this->drawText($image, $font, $x, $startY, $line, $color, $shadowPreset);
|
||||
$startY += $blockLineHeight;
|
||||
}
|
||||
|
||||
$startY += 18;
|
||||
}
|
||||
}
|
||||
|
||||
private function paintDecorations($image, array $project, int $width, int $height): void
|
||||
{
|
||||
$decorations = Arr::wrap(Arr::get($project, 'decorations', []));
|
||||
$accent = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', '#ffffff'));
|
||||
|
||||
foreach (array_slice($decorations, 0, (int) config('nova_cards.validation.max_decorations', 6)) as $index => $decoration) {
|
||||
$x = (int) Arr::get($decoration, 'x', ($index % 2 === 0 ? 0.12 : 0.82) * $width);
|
||||
$y = (int) Arr::get($decoration, 'y', (0.14 + ($index * 0.1)) * $height);
|
||||
$size = max(2, (int) Arr::get($decoration, 'size', 6));
|
||||
imagefilledellipse($image, $x, $y, $size, $size, $accent);
|
||||
}
|
||||
}
|
||||
|
||||
private function paintAssets($image, array $project, int $width, int $height): void
|
||||
{
|
||||
$items = Arr::wrap(Arr::get($project, 'assets.items', []));
|
||||
if ($items === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$accent = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', '#ffffff'));
|
||||
|
||||
foreach (array_slice($items, 0, 6) as $index => $item) {
|
||||
$type = (string) Arr::get($item, 'type', 'glyph');
|
||||
if ($type === 'glyph') {
|
||||
$glyph = (string) Arr::get($item, 'glyph', Arr::get($item, 'label', '✦'));
|
||||
imagestring($image, 5, (int) round($width * (0.08 + (($index % 3) * 0.28))), (int) round($height * (0.08 + (intdiv($index, 3) * 0.74))), $glyph, $accent);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'frame') {
|
||||
$y = $index % 2 === 0 ? (int) round($height * 0.08) : (int) round($height * 0.92);
|
||||
imageline($image, (int) round($width * 0.12), $y, (int) round($width * 0.88), $y, $accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveTextBlocks(NovaCard $card, array $project): array
|
||||
{
|
||||
$blocks = collect(Arr::wrap(Arr::get($project, 'text_blocks', [])))
|
||||
->filter(fn ($block): bool => is_array($block) && (bool) Arr::get($block, 'enabled', true) && trim((string) Arr::get($block, 'text', '')) !== '')
|
||||
->values();
|
||||
|
||||
if ($blocks->isNotEmpty()) {
|
||||
return $blocks->all();
|
||||
}
|
||||
|
||||
return [
|
||||
['type' => 'title', 'text' => trim((string) $card->title)],
|
||||
['type' => 'quote', 'text' => trim((string) $card->quote_text)],
|
||||
['type' => 'author', 'text' => trim((string) $card->quote_author)],
|
||||
['type' => 'source', 'text' => trim((string) $card->quote_source)],
|
||||
];
|
||||
}
|
||||
|
||||
private function fontForBlockType(string $type): int
|
||||
{
|
||||
return match ($type) {
|
||||
'title', 'source' => 3,
|
||||
'author', 'body' => 4,
|
||||
'caption' => 2,
|
||||
default => 5,
|
||||
};
|
||||
}
|
||||
|
||||
private function paintVerticalGradient($image, int $width, int $height, string $fromHex, string $toHex): void
|
||||
{
|
||||
[$r1, $g1, $b1] = $this->hexToRgb($fromHex);
|
||||
[$r2, $g2, $b2] = $this->hexToRgb($toHex);
|
||||
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
$ratio = $height > 1 ? $y / ($height - 1) : 0;
|
||||
$red = (int) round($r1 + (($r2 - $r1) * $ratio));
|
||||
$green = (int) round($g1 + (($g2 - $g1) * $ratio));
|
||||
$blue = (int) round($b1 + (($b2 - $b1) * $ratio));
|
||||
$color = imagecolorallocate($image, $red, $green, $blue);
|
||||
imageline($image, 0, $y, $width, $y, $color);
|
||||
}
|
||||
}
|
||||
|
||||
private function allocateHex($image, string $hex)
|
||||
{
|
||||
[$r, $g, $b] = $this->hexToRgb($hex);
|
||||
|
||||
return imagecolorallocate($image, $r, $g, $b);
|
||||
}
|
||||
|
||||
private function hexToRgb(string $hex): array
|
||||
{
|
||||
$normalized = ltrim($hex, '#');
|
||||
if (strlen($normalized) === 3) {
|
||||
$normalized = preg_replace('/(.)/', '$1$1', $normalized) ?: 'ffffff';
|
||||
}
|
||||
|
||||
if (strlen($normalized) !== 6) {
|
||||
$normalized = 'ffffff';
|
||||
}
|
||||
|
||||
return [
|
||||
hexdec(substr($normalized, 0, 2)),
|
||||
hexdec(substr($normalized, 2, 2)),
|
||||
hexdec(substr($normalized, 4, 2)),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveAlignedX(string $alignment, int $width, int $padding, int $lineWidth): int
|
||||
{
|
||||
return match ($alignment) {
|
||||
'left' => $padding,
|
||||
'right' => max($padding, $width - $padding - $lineWidth),
|
||||
default => max($padding, (int) round(($width - $lineWidth) / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveFocalSourceOrigin(string $focalPosition, int $sourceWidth, int $sourceHeight): array
|
||||
{
|
||||
$x = match ($focalPosition) {
|
||||
'left', 'top-left', 'bottom-left' => 0,
|
||||
'right', 'top-right', 'bottom-right' => max(0, (int) round($sourceWidth * 0.18)),
|
||||
default => max(0, (int) round($sourceWidth * 0.09)),
|
||||
};
|
||||
|
||||
$y = match ($focalPosition) {
|
||||
'top', 'top-left', 'top-right' => 0,
|
||||
'bottom', 'bottom-left', 'bottom-right' => max(0, (int) round($sourceHeight * 0.18)),
|
||||
default => max(0, (int) round($sourceHeight * 0.09)),
|
||||
};
|
||||
|
||||
return [$x, $y];
|
||||
}
|
||||
|
||||
private function drawText($image, int $font, int $x, int $y, string $text, int $color, string $shadowPreset): void
|
||||
{
|
||||
if ($shadowPreset !== 'none') {
|
||||
$offset = $shadowPreset === 'strong' ? 3 : 1;
|
||||
$shadow = imagecolorallocatealpha($image, 2, 6, 23, $shadowPreset === 'strong' ? 46 : 78);
|
||||
imagestring($image, $font, $x + $offset, $y + $offset, $text, $shadow);
|
||||
}
|
||||
|
||||
imagestring($image, $font, $x, $y, $text, $color);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user