| namespace App\Services; | |
| use Illuminate\Http\Client\Response; | |
| use Illuminate\Support\Facades\Http; | |
| use Illuminate\Support\Facades\Storage; | |
| use Illuminate\Support\Str; | |
| use RuntimeException; | |
| class FreepikImageService | |
| { | |
| public function __construct(private ?HuggingFaceMediaStorageService $hfMediaStorage = null) | |
| { | |
| } | |
| public function generateWithHuggingFace(string $prompt, string $aspect = 'landscape', int $count = 1): array | |
| { | |
| $huggingFaceToken = $this->huggingFaceToken(); | |
| $timeout = max(20, (int) ( | |
| config('services.huggingface.timeout') | |
| ?: $this->readEnv('HF_TIMEOUT') | |
| ?: config('services.freepik.timeout', 90) | |
| )); | |
| $cleanPrompt = trim($prompt); | |
| $count = max(1, min(4, $count)); | |
| if ($huggingFaceToken === '') { | |
| throw new RuntimeException('HF_TOKEN belum diisi di file .env'); | |
| } | |
| if ($cleanPrompt === '') { | |
| throw new RuntimeException('Prompt image tidak boleh kosong.'); | |
| } | |
| return $this->generateViaHuggingFace($huggingFaceToken, $timeout, $cleanPrompt, $aspect, $count); | |
| } | |
| public function generate(string $prompt, string $aspect = 'landscape', int $count = 1): array | |
| { | |
| $apiKey = trim((string) config('services.freepik.api_key', '')); | |
| $huggingFaceToken = $this->huggingFaceToken(); | |
| $timeout = max(20, (int) ( | |
| config('services.freepik.timeout', 90) | |
| ?: config('services.huggingface.timeout', 60) | |
| )); | |
| $cleanPrompt = trim($prompt); | |
| $count = max(1, min(4, $count)); | |
| if ($cleanPrompt === '') { | |
| throw new RuntimeException('Prompt image tidak boleh kosong.'); | |
| } | |
| if ($this->isHfOnlyMode() || $apiKey === '') { | |
| if ($huggingFaceToken !== '') { | |
| return $this->generateWithHuggingFace($cleanPrompt, $aspect, $count); | |
| } | |
| throw new RuntimeException('HF_TOKEN belum diisi. Workspace ini sekarang pakai Hugging Face untuk generate image.'); | |
| } | |
| $legacyError = null; | |
| if ($this->shouldUseLegacyEndpoint()) { | |
| try { | |
| return $this->generateViaLegacyEndpoint($apiKey, $timeout, $cleanPrompt, $aspect, $count); | |
| } catch (\Throwable $legacyFailure) { | |
| $legacyError = $legacyFailure; | |
| } | |
| } | |
| $urls = []; | |
| for ($index = 0; $index < $count; $index++) { | |
| $seed = random_int(1, 4294967295); | |
| try { | |
| $ticket = $this->createTask($apiKey, $timeout, [ | |
| 'prompt' => $cleanPrompt, | |
| 'aspect_ratio' => $this->aspectRatio($aspect), | |
| 'resolution' => '1k', | |
| 'output_format' => 'jpeg', | |
| 'safety_tolerance' => 2, | |
| 'seed' => $seed, | |
| ]); | |
| } catch (\Throwable $asyncError) { | |
| if ($legacyError !== null) { | |
| if ($huggingFaceToken !== '') { | |
| return $this->generateWithHuggingFace($cleanPrompt, $aspect, $count); | |
| } | |
| throw new RuntimeException($legacyError->getMessage().' Fallback async Freepik juga gagal: '.$asyncError->getMessage()); | |
| } | |
| throw $asyncError; | |
| } | |
| if ($ticket['direct_url'] !== null) { | |
| $urls[] = $ticket['direct_url']; | |
| continue; | |
| } | |
| $urls[] = $this->awaitGeneratedUrl($apiKey, $timeout, $ticket['task_id']); | |
| } | |
| if ($urls === []) { | |
| if ($huggingFaceToken !== '') { | |
| return $this->generateWithHuggingFace($cleanPrompt, $aspect, $count); | |
| } | |
| throw new RuntimeException('Freepik image response kosong atau belum menghasilkan URL gambar.'); | |
| } | |
| return $urls; | |
| } | |
| private function generateViaHuggingFace(string $token, int $timeout, string $prompt, string $aspect, int $count): array | |
| { | |
| $model = trim((string) ( | |
| config('services.huggingface.image_model') | |
| ?: $this->readEnv('HUGGINGFACE_IMAGE_MODEL') | |
| ?: $this->readEnv('HF_MODEL') | |
| ?: 'black-forest-labs/FLUX.1-schnell' | |
| )); | |
| $endpoint = trim((string) ( | |
| config('services.huggingface.image_endpoint') | |
| ?: $this->readEnv('HUGGINGFACE_IMAGE_ENDPOINT') | |
| ?: '' | |
| )); | |
| $baseUrl = trim((string) ($this->readEnv('HF_BASE_URL') ?: '')); | |
| if ($endpoint === '' && $baseUrl !== '') { | |
| $endpoint = rtrim($baseUrl, '/'); | |
| if (Str::endsWith($endpoint, '/models')) { | |
| $endpoint .= '/'.$model; | |
| } | |
| } | |
| if ($endpoint === '') { | |
| $endpoint = 'https://router.huggingface.co/hf-inference/models/'.$model; | |
| } | |
| [$width, $height] = $this->legacySize($aspect); | |
| $urls = []; | |
| for ($index = 0; $index < $count; $index++) { | |
| $seed = random_int(1, 999999999); | |
| $payload = [ | |
| 'inputs' => $count > 1 ? $prompt.' [variation '.($index + 1).']' : $prompt, | |
| 'parameters' => [ | |
| 'width' => $width, | |
| 'height' => $height, | |
| 'seed' => $seed, | |
| ], | |
| 'options' => [ | |
| 'wait_for_model' => true, | |
| 'use_cache' => false, | |
| ], | |
| ]; | |
| $response = $this->runHuggingFaceRequest($endpoint, $token, $timeout, $payload); | |
| $contentType = strtolower((string) $response->header('content-type', '')); | |
| if (Str::startsWith($contentType, 'image/')) { | |
| $urls[] = $this->persistBinaryImage($response->body(), $contentType); | |
| continue; | |
| } | |
| $json = $response->json(); | |
| $base64 = data_get($json, 'image') ?? data_get($json, 'images.0'); | |
| if (is_string($base64) && trim($base64) !== '') { | |
| $urls[] = $this->persistBase64Image($base64); | |
| continue; | |
| } | |
| throw new RuntimeException('Hugging Face image response tidak dikenali.'); | |
| } | |
| if ($urls === []) { | |
| throw new RuntimeException('Hugging Face tidak mengembalikan output image.'); | |
| } | |
| return $urls; | |
| } | |
| private function runHuggingFaceRequest(string $endpoint, string $token, int $timeout, array $payload): Response | |
| { | |
| $maxAttempts = 6; | |
| $attempt = 0; | |
| $lastResponse = null; | |
| $acceptTypes = ['image/png', 'application/json']; | |
| while ($attempt < $maxAttempts) { | |
| $attempt++; | |
| $response = null; | |
| foreach ($acceptTypes as $acceptType) { | |
| $probe = Http::timeout(max(30, $timeout)) | |
| ->withToken($token) | |
| ->withHeaders(['Accept' => $acceptType]) | |
| ->post($endpoint, $payload); | |
| if ($probe->successful()) { | |
| $response = $probe; | |
| break; | |
| } | |
| $errorBody = strtolower((string) $probe->body()); | |
| $isAcceptHeaderError = $probe->status() === 400 && Str::contains($errorBody, 'accept type'); | |
| if ($isAcceptHeaderError) { | |
| $lastResponse = $probe; | |
| continue; | |
| } | |
| $response = $probe; | |
| break; | |
| } | |
| if (! $response instanceof Response) { | |
| throw new RuntimeException('Hugging Face image request gagal: response kosong.'); | |
| } | |
| $lastResponse = $response; | |
| if ($response->successful()) { | |
| return $response; | |
| } | |
| if (in_array($response->status(), [503, 524], true)) { | |
| $wait = (float) (data_get($response->json(), 'estimated_time') ?? 2.0); | |
| usleep((int) (max(1, min(8, $wait)) * 1000000)); | |
| continue; | |
| } | |
| throw new RuntimeException( | |
| 'Hugging Face image request gagal: '.$response->status().' - '.$this->responseError($response) | |
| ); | |
| } | |
| if ($lastResponse instanceof Response) { | |
| throw new RuntimeException( | |
| 'Hugging Face image timeout: '.$lastResponse->status().' - '.$this->responseError($lastResponse) | |
| ); | |
| } | |
| throw new RuntimeException('Hugging Face image timeout: request gagal diulang.'); | |
| } | |
| private function persistBinaryImage(string $binary, string $contentType): string | |
| { | |
| if ($binary === '') { | |
| throw new RuntimeException('Hugging Face mengembalikan binary image kosong.'); | |
| } | |
| $ext = 'jpg'; | |
| if (Str::contains($contentType, 'png')) { | |
| $ext = 'png'; | |
| } elseif (Str::contains($contentType, 'webp')) { | |
| $ext = 'webp'; | |
| } | |
| $relativePath = 'images/hf_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)).'.'.$ext; | |
| Storage::disk('public')->put($relativePath, $binary); | |
| return $this->offloadPublicPath($relativePath); | |
| } | |
| private function huggingFaceToken(): string | |
| { | |
| return trim((string) ( | |
| config('services.huggingface.api_token') | |
| ?: $this->readEnv('HUGGINGFACE_API_TOKEN') | |
| ?: $this->readEnv('HF_TOKEN') | |
| ?: $this->readEnv('HF_API_TOKEN') | |
| ?: $this->readEnv('HUGGINGFACEHUB_API_TOKEN') | |
| ?: '' | |
| )); | |
| } | |
| private function readEnv(string $key): ?string | |
| { | |
| $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); | |
| if (! is_string($value)) { | |
| return null; | |
| } | |
| $trimmed = trim($value); | |
| return $trimmed === '' ? null : $trimmed; | |
| } | |
| private function isHfOnlyMode(): bool | |
| { | |
| $value = strtolower(trim((string) ( | |
| $this->readEnv('TOOLS_HF_ONLY') | |
| ?? config('services.huggingface.hf_only') | |
| ?? 'false' | |
| ))); | |
| return in_array($value, ['1', 'true', 'yes', 'on'], true); | |
| } | |
| private function generateViaLegacyEndpoint(string $apiKey, int $timeout, string $prompt, string $aspect, int $count): array | |
| { | |
| [$width, $height] = $this->legacySize($aspect); | |
| $response = $this->client($apiKey, $timeout) | |
| ->withHeaders([ | |
| 'content-type' => 'application/json', | |
| ]) | |
| ->post($this->legacyEndpoint(), [ | |
| 'prompt' => $prompt, | |
| 'num_images' => $count, | |
| 'width' => $width, | |
| 'height' => $height, | |
| ]); | |
| $data = $this->extractData($response, 'Freepik image request gagal'); | |
| $urls = $this->extractGeneratedUrls($data); | |
| if ($urls === []) { | |
| throw new RuntimeException('Freepik image legacy endpoint tidak mengembalikan output gambar.'); | |
| } | |
| return array_values(array_slice($urls, 0, $count)); | |
| } | |
| private function createTask(string $apiKey, int $timeout, array $payload): array | |
| { | |
| $response = $this->client($apiKey, $timeout) | |
| ->post($this->imageEndpoint(), $payload); | |
| $data = $this->extractData($response, 'Freepik image request gagal'); | |
| $directUrl = $this->extractGeneratedUrl($data); | |
| if ($directUrl !== null) { | |
| return [ | |
| 'task_id' => null, | |
| 'direct_url' => $directUrl, | |
| ]; | |
| } | |
| $taskId = trim((string) data_get($data, 'task_id', '')); | |
| if ($taskId === '') { | |
| throw new RuntimeException('Freepik image tidak mengembalikan task_id.'); | |
| } | |
| return [ | |
| 'task_id' => $taskId, | |
| 'direct_url' => null, | |
| ]; | |
| } | |
| private function awaitGeneratedUrl(string $apiKey, int $timeout, string $taskId): string | |
| { | |
| $deadline = microtime(true) + max(25, min(120, $timeout)); | |
| do { | |
| $response = $this->client($apiKey, min(30, $timeout)) | |
| ->get($this->imageEndpoint().'/'.$taskId); | |
| $data = $this->extractData($response, 'Gagal membaca status task Freepik image'); | |
| $generatedUrl = $this->extractGeneratedUrl($data); | |
| if ($generatedUrl !== null) { | |
| return $generatedUrl; | |
| } | |
| $status = strtoupper(trim((string) data_get($data, 'status', ''))); | |
| if (in_array($status, ['FAILED', 'ERROR', 'REJECTED', 'CANCELLED'], true)) { | |
| throw new RuntimeException('Freepik image task berakhir dengan status '.$status.'.'); | |
| } | |
| usleep(800000); | |
| } while (microtime(true) < $deadline); | |
| throw new RuntimeException('Timeout saat menunggu hasil image dari Freepik.'); | |
| } | |
| private function client(string $apiKey, int $timeout) | |
| { | |
| return Http::timeout($timeout) | |
| ->acceptJson() | |
| ->withHeaders([ | |
| 'x-freepik-api-key' => $apiKey, | |
| ]); | |
| } | |
| private function imageEndpoint(): string | |
| { | |
| $configured = trim((string) config('services.freepik.base_url', '')); | |
| if ($configured === '') { | |
| return 'https://api.freepik.com/v1/ai/text-to-image/flux-2-klein'; | |
| } | |
| $configured = rtrim($configured, '/'); | |
| if (Str::contains($configured, '/text-to-image/') && ! Str::endsWith($configured, '/text-to-image')) { | |
| return $configured; | |
| } | |
| if (Str::endsWith($configured, '/text-to-image')) { | |
| return $configured.'/flux-2-klein'; | |
| } | |
| if (preg_match('#/v1/ai$#', $configured) === 1) { | |
| return $configured.'/text-to-image/flux-2-klein'; | |
| } | |
| return rtrim($this->apiOrigin($configured), '/').'/v1/ai/text-to-image/flux-2-klein'; | |
| } | |
| private function legacyEndpoint(): string | |
| { | |
| $configured = trim((string) config('services.freepik.base_url', 'https://api.freepik.com/v1/ai/text-to-image')); | |
| return rtrim($configured, '/'); | |
| } | |
| private function shouldUseLegacyEndpoint(): bool | |
| { | |
| $configured = trim((string) config('services.freepik.base_url', '')); | |
| if ($configured === '') { | |
| return true; | |
| } | |
| return Str::endsWith(rtrim($configured, '/'), '/text-to-image'); | |
| } | |
| private function apiOrigin(string $url): string | |
| { | |
| $scheme = parse_url($url, PHP_URL_SCHEME) ?: 'https'; | |
| $host = parse_url($url, PHP_URL_HOST); | |
| $port = parse_url($url, PHP_URL_PORT); | |
| if (! is_string($host) || $host === '') { | |
| return 'https://api.freepik.com'; | |
| } | |
| return $scheme.'://'.$host.($port ? ':'.$port : ''); | |
| } | |
| private function aspectRatio(string $aspect): string | |
| { | |
| return match ($aspect) { | |
| 'portrait' => 'social_story_9_16', | |
| 'square' => 'square_1_1', | |
| default => 'widescreen_16_9', | |
| }; | |
| } | |
| private function legacySize(string $aspect): array | |
| { | |
| return match ($aspect) { | |
| 'portrait' => [768, 1344], | |
| 'square' => [1024, 1024], | |
| default => [1344, 768], | |
| }; | |
| } | |
| private function extractData(Response $response, string $prefix): array | |
| { | |
| if (! $response->successful()) { | |
| throw new RuntimeException($prefix.': '.$response->status().' - '.$this->responseError($response)); | |
| } | |
| $json = $response->json(); | |
| $data = data_get($json, 'data', $json); | |
| if (! is_array($data)) { | |
| throw new RuntimeException($prefix.': format response Freepik tidak dikenali.'); | |
| } | |
| return $data; | |
| } | |
| private function responseError(Response $response): string | |
| { | |
| $message = data_get($response->json(), 'message') | |
| ?? data_get($response->json(), 'error.message') | |
| ?? data_get($response->json(), 'detail') | |
| ?? $response->body(); | |
| return Str::limit(trim((string) $message), 240); | |
| } | |
| private function extractGeneratedUrl(array $data): ?string | |
| { | |
| return $this->extractGeneratedUrls($data)[0] ?? null; | |
| } | |
| private function extractGeneratedUrls(array $data): array | |
| { | |
| $candidates = data_get($data, 'generated', data_get($data, 'images', $data)); | |
| if (! is_array($candidates) || $candidates === []) { | |
| return []; | |
| } | |
| $urls = []; | |
| foreach ($candidates as $candidate) { | |
| if (is_string($candidate) && Str::startsWith($candidate, ['http://', 'https://'])) { | |
| $urls[] = trim($candidate); | |
| continue; | |
| } | |
| if (is_array($candidate)) { | |
| $url = data_get($candidate, 'url', data_get($candidate, 'image_url')); | |
| if (is_string($url) && trim($url) !== '') { | |
| $urls[] = trim($url); | |
| continue; | |
| } | |
| $base64 = data_get($candidate, 'base64', data_get($candidate, 'b64_json')); | |
| if (is_string($base64) && trim($base64) !== '') { | |
| $urls[] = $this->persistBase64Image($base64); | |
| } | |
| } | |
| } | |
| return $urls; | |
| } | |
| private function persistBase64Image(string $base64): string | |
| { | |
| $binary = base64_decode($base64, true); | |
| if ($binary === false) { | |
| throw new RuntimeException('Freepik image mengirim base64 yang tidak valid.'); | |
| } | |
| $relativePath = 'images/freepik_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)).'.jpg'; | |
| Storage::disk('public')->put($relativePath, $binary); | |
| return $this->offloadPublicPath($relativePath); | |
| } | |
| 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; | |
| } | |
| } | |