| <?php |
|
|
| 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 FreepikVideoService |
| { |
| public function __construct(private ?HuggingFaceMediaStorageService $hfMediaStorage = null) |
| { |
| } |
|
|
| public function generate(string $prompt, int $duration = 5): array |
| { |
| $apiKey = trim((string) config('services.freepik.api_key', '')); |
| $timeout = max(20, (int) config('services.freepik.timeout', 90)); |
| $cleanPrompt = trim($prompt); |
| $duration = in_array($duration, [5, 8, 10], true) ? $duration : 5; |
|
|
| if ($apiKey === '') { |
| throw new RuntimeException($this->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, |
| ]); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private function videoEndpoint(): string |
| { |
| |
| $configured = trim((string) config('services.freepik.video_base_url', config('services.freepik.base_url', ''))); |
|
|
| if ($configured === '' || Str::contains($configured, 'text-to-video')) { |
| |
| 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'; |
| } |
|
|
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
| } |
|
|