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; } }