missingApiKeyMessage('Video generator')); } if ($cleanPrompt === '') { throw new RuntimeException('Prompt video Freepik tidak boleh kosong.'); } $response = $this->client($apiKey, $timeout) ->post($this->videoEndpoint(), [ 'prompt' => $cleanPrompt, 'ratio' => '720:1280', 'duration' => $duration, ]); $data = $this->extractData($response, 'Freepik video request gagal'); $videoUrl = $this->extractGeneratedUrl($data); if ($videoUrl === null) { $taskId = trim((string) data_get($data, 'task_id', '')); if ($taskId === '') { throw new RuntimeException('Freepik video tidak mengembalikan task_id.'); } $videoUrl = $this->awaitGeneratedUrl($apiKey, $timeout, $taskId); } $relativePath = $this->persistVideo($videoUrl, $timeout); return [ 'url' => $this->offloadPublicPath($relativePath), 'provider' => 'freepik-runway', 'aspect' => '9:16', 'duration_seconds' => $duration, ]; } private function awaitGeneratedUrl(string $apiKey, int $timeout, string $taskId): string { $deadline = microtime(true) + max(35, min(240, $timeout * 2)); $lastError = null; do { try { $response = $this->client($apiKey, min(25, $timeout)) ->get($this->videoEndpoint().'/'.$taskId); $data = $this->extractData($response, 'Gagal membaca status task Freepik video'); $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 video task berakhir dengan status '.$status.'.'); } } catch (\Throwable $pollError) { $lastError = $pollError; } usleep(1500000); } while (microtime(true) < $deadline); if ($lastError !== null) { throw new RuntimeException('Timeout saat menunggu hasil video dari Freepik. Detail terakhir: '.$lastError->getMessage()); } throw new RuntimeException('Timeout saat menunggu hasil video dari Freepik.'); } private function persistVideo(string $videoUrl, int $timeout): string { $response = Http::connectTimeout(20) ->timeout(max(45, min(120, $timeout * 2))) ->withHeaders(['Accept' => 'video/*']) ->get($videoUrl); if (! $response->successful()) { throw new RuntimeException('Gagal mengambil hasil video dari Freepik: '.$response->status()); } $binary = $response->body(); if (! is_string($binary) || $binary === '') { throw new RuntimeException('Binary video dari Freepik kosong.'); } $relativePath = 'videos/freepik_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)).'.mp4'; Storage::disk('public')->makeDirectory('videos'); if (! Storage::disk('public')->put($relativePath, $binary)) { throw new RuntimeException('Gagal menyimpan hasil video Freepik ke storage public.'); } return $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; } private function client(string $apiKey, int $timeout) { return Http::connectTimeout(20) ->timeout($timeout) ->acceptJson() ->withHeaders([ 'x-freepik-api-key' => $apiKey, ]); } /** * Build the Freepik video generation endpoint. * * The original implementation concatenated the configured `services.freepik.base_url` * (which defaults to the *text‑to‑image* endpoint) with the video path, resulting in * an invalid URL such as `https://api.freepik.com/v1/ai/text-to-image/v1/ai/text-to-video/...`. * This caused the remote API to return a 500 error which propagated to the Space UI. * * The logic now: * 1. If the user explicitly sets a `FREEPIK_VIDEO_BASE_URL` env variable (via the config * key `services.freepik.video_base_url` – fallback to the generic `base_url`), use it * directly. * 2. If the configured base URL already contains the segment `text-to-video`, assume it * is a proper video endpoint and return it unchanged. * 3. Otherwise fall back to the official default video endpoint. */ private function videoEndpoint(): string { // Allow an explicit video base URL; otherwise reuse the generic base_url. $configured = trim((string) config('services.freepik.video_base_url', config('services.freepik.base_url', ''))); if ($configured === '' || Str::contains($configured, 'text-to-video')) { // Empty config or already a video endpoint – use the default video URL. return $configured !== '' && Str::contains($configured, 'text-to-video') ? rtrim($configured, '/').'/runway-4-5' : 'https://api.freepik.com/v1/ai/text-to-video/runway-4-5'; } // If a non‑video base URL is provided (e.g., the default text‑to‑image URL), ignore it // and return the canonical video endpoint. return 'https://api.freepik.com/v1/ai/text-to-video/runway-4-5'; } 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 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); } /** * Return a helpful message when the Freepik API key is missing. * * In a Hugging Face Space the environment variables are defined in the Space * settings. If the key is absent we guide the user to add * `FREEPIK_API_KEY` there. When the space runs in HF‑only mode we explain * that the video feature is disabled. */ private function missingApiKeyMessage(string $feature): string { $hfOnly = strtolower(trim((string) ( config('services.huggingface.hf_only') ?? $_ENV['TOOLS_HF_ONLY'] ?? $_SERVER['TOOLS_HF_ONLY'] ?? getenv('TOOLS_HF_ONLY') ?? 'false' ))); if (in_array($hfOnly, ['1', 'true', 'yes', 'on'], true)) { return $feature.' belum tersedia di mode Hugging Face‑only.'; } return 'FREEPIK_API_KEY belum diisi di file .env. Tambahkan variabel `FREEPIK_API_KEY` di Settings → Secrets of the Hugging Face Space dan redeploy.'; } private function extractGeneratedUrl(array $data): ?string { $candidates = data_get($data, 'generated', []); if (! is_array($candidates) || $candidates === []) { return null; } foreach ($candidates as $candidate) { if (is_string($candidate) && Str::startsWith($candidate, ['http://', 'https://'])) { return trim($candidate); } if (is_array($candidate)) { $url = data_get($candidate, 'url', data_get($candidate, 'video_url')); if (is_string($url) && trim($url) !== '') { return trim($url); } } } return null; } }