teknolis / app /Services /PosterImageService.php
adwandw's picture
Fix: Space Restarting loop
d4d07bc
Raw
History Blame Contribute Delete
12.1 kB
<?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;
}
}