| <?php |
|
|
| namespace App\Services; |
|
|
| use Illuminate\Support\Facades\Storage; |
| use Illuminate\Support\Str; |
| use RuntimeException; |
| use function imagecreatetruecolor; |
| use function imageantialias; |
| use function imagepng; |
| use function imagedestroy; |
| use function imageline; |
| use function imagecolorallocate; |
| use function imagecolorallocatealpha; |
| use function imagefilledellipse; |
| use function imagefilledrectangle; |
| use function imagettftext; |
| use function imagettfbbox; |
| use function imagestring; |
| use function imagearc; |
| use function imagefilledpolygon; |
|
|
| class PosterImageService |
| { |
| public function __construct(private ?HuggingFaceMediaStorageService $hfMediaStorage = null) |
| { |
| } |
|
|
| public function generate(string $prompt, string $aspect = 'portrait', int $count = 1): array |
| { |
| if (! extension_loaded('gd')) { |
| throw new RuntimeException('GD extension belum aktif, jadi fallback poster image belum bisa dipakai.'); |
| } |
|
|
| $count = max(1, min($count, 4)); |
| [$width, $height] = $this->sizeFromAspect($aspect); |
| $urls = []; |
|
|
| for ($variant = 1; $variant <= $count; $variant++) { |
| $image = imagecreatetruecolor($width, $height); |
| if ($image === false) { |
| throw new RuntimeException('Gagal menyiapkan canvas poster image.'); |
| } |
|
|
| if (function_exists('imageantialias')) { |
| @imageantialias($image, true); |
| } |
|
|
| $palette = $this->paletteFromPrompt($prompt, $variant); |
| $this->drawBackground($image, $width, $height, $palette); |
| $this->drawGlassPanels($image, $width, $height, $palette); |
| $this->drawHeadline($image, $width, $height, $prompt); |
|
|
| ob_start(); |
| imagepng($image); |
| $binary = ob_get_clean(); |
| imagedestroy($image); |
|
|
| if (! is_string($binary) || $binary === '') { |
| throw new RuntimeException('Gagal mengekspor poster image ke PNG.'); |
| } |
|
|
| $filename = 'poster_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)).'.png'; |
| $path = 'images/'.$filename; |
| Storage::disk('public')->put($path, $binary); |
| $urls[] = $this->offloadPublicPath($path); |
| } |
|
|
| return $urls; |
| } |
|
|
| private function offloadPublicPath(string $relativePath): string |
| { |
| try { |
| return $this->hfMedia()->offloadPublicRelativePath($relativePath); |
| } catch (\Throwable) { |
| return Storage::url($relativePath); |
| } |
| } |
|
|
| private function hfMedia(): HuggingFaceMediaStorageService |
| { |
| if ($this->hfMediaStorage instanceof HuggingFaceMediaStorageService) { |
| return $this->hfMediaStorage; |
| } |
|
|
| $service = app(HuggingFaceMediaStorageService::class); |
| if (! $service instanceof HuggingFaceMediaStorageService) { |
| throw new RuntimeException('Service HuggingFaceMediaStorageService tidak tersedia.'); |
| } |
|
|
| $this->hfMediaStorage = $service; |
|
|
| return $this->hfMediaStorage; |
| } |
|
|
| private function sizeFromAspect(string $aspect): array |
| { |
| return match ($aspect) { |
| 'square' => [1080, 1080], |
| 'landscape' => [1536, 1024], |
| default => [1080, 1920], |
| }; |
| } |
|
|
| private function paletteFromPrompt(string $prompt, int $variant): array |
| { |
| $seed = abs(crc32(Str::lower($prompt).'|'.$variant)); |
| $palettes = [ |
| ['#07111a', '#102338', '#43d9bd', '#f5b96b'], |
| ['#10131f', '#1b2940', '#8de2ff', '#ff7a59'], |
| ['#0b1220', '#1e1730', '#9d8cff', '#43d9bd'], |
| ['#120e19', '#27324b', '#f07cab', '#f5b96b'], |
| ]; |
|
|
| return $palettes[$seed % count($palettes)]; |
| } |
|
|
| private function drawBackground($image, int $width, int $height, array $palette): void |
| { |
| [$top, $bottom, $accentA, $accentB] = $palette; |
|
|
| for ($y = 0; $y < $height; $y++) { |
| $ratio = $height > 1 ? $y / ($height - 1) : 0; |
| [$r, $g, $b] = $this->mixHex($top, $bottom, $ratio); |
| imageline($image, 0, $y, $width, $y, imagecolorallocate($image, $r, $g, $b)); |
| } |
|
|
| $this->drawGlow($image, (int) ($width * 0.2), (int) ($height * 0.14), (int) ($width * 0.38), $accentA, 88); |
| $this->drawGlow($image, (int) ($width * 0.82), (int) ($height * 0.18), (int) ($width * 0.3), $accentB, 72); |
| $this->drawGlow($image, (int) ($width * 0.76), (int) ($height * 0.78), (int) ($width * 0.35), '#ffffff', 24); |
| } |
|
|
| private function drawGlassPanels($image, int $width, int $height, array $palette): void |
| { |
| $stroke = imagecolorallocatealpha($image, 255, 255, 255, 90); |
| $glass = imagecolorallocatealpha($image, 255, 255, 255, 112); |
| $accent = $this->allocateHex($image, $palette[2], 45); |
| $soft = imagecolorallocatealpha($image, 255, 255, 255, 118); |
|
|
| $this->roundedRectangle($image, 72, 96, $width - 72, $height - 136, 42, $glass, true); |
| $this->roundedRectangle($image, 72, 96, $width - 72, $height - 136, 42, $stroke, false); |
| $this->roundedRectangle($image, 118, 162, $width - 118, (int) ($height * 0.55), 36, $soft, true); |
| $this->roundedRectangle($image, 118, 162, $width - 118, (int) ($height * 0.55), 36, $stroke, false); |
| $this->roundedRectangle($image, 118, (int) ($height * 0.64), (int) ($width * 0.7), (int) ($height * 0.73), 28, $accent, true); |
| $this->roundedRectangle($image, 118, (int) ($height * 0.78), (int) ($width * 0.58), (int) ($height * 0.845), 22, $soft, true); |
|
|
| imagefilledellipse( |
| $image, |
| (int) ($width * 0.24), |
| (int) ($height * 0.34), |
| (int) ($width * 0.22), |
| (int) ($width * 0.22), |
| $this->allocateHex($image, $palette[3], 28) |
| ); |
| } |
|
|
| private function drawHeadline($image, int $width, int $height, string $prompt): void |
| { |
| $textColor = imagecolorallocate($image, 246, 251, 255); |
| $muted = imagecolorallocate($image, 194, 207, 225); |
| $fontBold = $this->resolveFont([ |
| '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', |
| '/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf', |
| ]); |
| $fontRegular = $this->resolveFont([ |
| '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', |
| '/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf', |
| ]); |
|
|
| $title = Str::title(Str::limit(trim($prompt), 90, '')); |
| $subtitle = 'VERTICAL STORY ASSET 9:16'; |
|
|
| if ($fontBold !== null && $fontRegular !== null && function_exists('imagettftext')) { |
| $lines = $this->wrapText($title, $fontBold, 64, $width - 260, 4); |
| $y = (int) ($height * 0.64); |
|
|
| foreach ($lines as $line) { |
| imagettftext($image, 64, 0, 120, $y, $textColor, $fontBold, $line); |
| $y += 82; |
| } |
|
|
| imagettftext($image, 28, 0, 120, $height - 150, $muted, $fontRegular, $subtitle); |
| imagettftext($image, 20, 0, 120, $height - 112, $muted, $fontRegular, 'Generated locally as premium fallback media for social automation.'); |
|
|
| return; |
| } |
|
|
| imagestring($image, 5, 120, (int) ($height * 0.65), Str::limit($title, 36, ''), $textColor); |
| imagestring($image, 3, 120, $height - 140, $subtitle, $muted); |
| } |
|
|
| private function wrapText(string $text, string $font, int $size, int $maxWidth, int $maxLines): array |
| { |
| $words = preg_split('/\s+/', trim($text)) ?: []; |
| $lines = []; |
| $current = ''; |
|
|
| $hasFreeType = function_exists('imagettfbbox'); |
| foreach ($words as $word) { |
| $candidate = trim($current.' '.$word); |
| if ($hasFreeType) { |
| $box = imagettfbbox($size, 0, $font, $candidate); |
| $lineWidth = is_array($box) ? abs($box[2] - $box[0]) : 0; |
| } else { |
| $lineWidth = (int) (strlen($candidate) * $size * 0.55); |
| } |
|
|
| if ($current !== '' && $lineWidth > $maxWidth) { |
| $lines[] = $current; |
| $current = $word; |
| } else { |
| $current = $candidate; |
| } |
|
|
| if (count($lines) >= $maxLines - 1) { |
| break; |
| } |
| } |
|
|
| if ($current !== '') { |
| $remaining = array_slice($words, count(explode(' ', trim(implode(' ', $lines).' '.$current)))); |
| if ($remaining !== []) { |
| $current = trim($current.' '.implode(' ', $remaining)); |
| } |
|
|
| $lines[] = Str::limit($current, 42, '...'); |
| } |
|
|
| return array_slice($lines, 0, $maxLines); |
| } |
|
|
| private function roundedRectangle($image, int $x1, int $y1, int $x2, int $y2, int $radius, int $color, bool $filled): void |
| { |
| $diameter = $radius * 2; |
|
|
| if ($filled) { |
| imagefilledrectangle($image, $x1 + $radius, $y1, $x2 - $radius, $y2, $color); |
| imagefilledrectangle($image, $x1, $y1 + $radius, $x2, $y2 - $radius, $color); |
| imagefilledellipse($image, $x1 + $radius, $y1 + $radius, $diameter, $diameter, $color); |
| imagefilledellipse($image, $x2 - $radius, $y1 + $radius, $diameter, $diameter, $color); |
| imagefilledellipse($image, $x1 + $radius, $y2 - $radius, $diameter, $diameter, $color); |
| imagefilledellipse($image, $x2 - $radius, $y2 - $radius, $diameter, $diameter, $color); |
|
|
| return; |
| } |
|
|
| imageline($image, $x1 + $radius, $y1, $x2 - $radius, $y1, $color); |
| imageline($image, $x1 + $radius, $y2, $x2 - $radius, $y2, $color); |
| imageline($image, $x1, $y1 + $radius, $x1, $y2 - $radius, $color); |
| imageline($image, $x2, $y1 + $radius, $x2, $y2 - $radius, $color); |
| imagearc($image, $x1 + $radius, $y1 + $radius, $diameter, $diameter, 180, 270, $color); |
| imagearc($image, $x2 - $radius, $y1 + $radius, $diameter, $diameter, 270, 360, $color); |
| imagearc($image, $x1 + $radius, $y2 - $radius, $diameter, $diameter, 90, 180, $color); |
| imagearc($image, $x2 - $radius, $y2 - $radius, $diameter, $diameter, 0, 90, $color); |
| } |
|
|
| private function drawGlow($image, int $centerX, int $centerY, int $radius, string $hex, int $maxAlpha): void |
| { |
| for ($i = $radius; $i > 0; $i -= max(6, (int) ($radius / 12))) { |
| $alpha = max(0, min(127, (int) ($maxAlpha + (($radius - $i) / max(1, $radius)) * 34))); |
| imagefilledellipse( |
| $image, |
| $centerX, |
| $centerY, |
| $i * 2, |
| $i * 2, |
| $this->allocateHex($image, $hex, $alpha) |
| ); |
| } |
| } |
|
|
| private function mixHex(string $from, string $to, float $ratio): array |
| { |
| $ratio = max(0, min(1, $ratio)); |
| [$fr, $fg, $fb] = $this->hexToRgb($from); |
| [$tr, $tg, $tb] = $this->hexToRgb($to); |
|
|
| return [ |
| (int) round($fr + (($tr - $fr) * $ratio)), |
| (int) round($fg + (($tg - $fg) * $ratio)), |
| (int) round($fb + (($tb - $fb) * $ratio)), |
| ]; |
| } |
|
|
| private function allocateHex($image, string $hex, int $alpha = 0): int |
| { |
| [$r, $g, $b] = $this->hexToRgb($hex); |
|
|
| return imagecolorallocatealpha($image, $r, $g, $b, max(0, min(127, $alpha))); |
| } |
|
|
| private function hexToRgb(string $hex): array |
| { |
| $value = ltrim($hex, '#'); |
| if (strlen($value) === 3) { |
| $value = preg_replace('/(.)/', '$1$1', $value) ?? $value; |
| } |
|
|
| return [ |
| hexdec(substr($value, 0, 2)), |
| hexdec(substr($value, 2, 2)), |
| hexdec(substr($value, 4, 2)), |
| ]; |
| } |
|
|
| private function resolveFont(array $candidates): ?string |
| { |
| foreach ($candidates as $candidate) { |
| if (is_file($candidate)) { |
| return $candidate; |
| } |
| } |
|
|
| return null; |
| } |
| } |
|
|