submitTask( '/v1/ai/text-to-image/seedream-v4-5-edit', [ 'prompt' => $this->composePrompt($prompt, $mode, 'image'), 'aspect_ratio' => 'social_story_9_16', 'reference_images' => [$this->referenceImageBase64($source)], 'seed' => random_int(1, 2147483647), 'enable_safety_checker' => true, ], 'Freepik asset image edit gagal' ); return [ 'urls' => $this->awaitGeneratedUrls('/v1/ai/text-to-image/seedream-v4-5-edit', $data, 'Freepik asset image edit gagal'), 'provider' => 'freepik-seedream-v45-edit', ]; } public function remixVideo(array $source, string $prompt, string $mode, int $duration = 5): array { $duration = in_array($duration, [5, 10], true) ? $duration : 5; $submission = $this->submitRemixVideo($source, $prompt, $mode, $duration); $status = $this->checkRemixVideoStatus((string) ($submission['prediction_id'] ?? '')); if (($status['status'] ?? '') !== 'succeeded' || ! is_string($status['url'] ?? null) || trim((string) $status['url']) === '') { throw new RuntimeException((string) ($status['error'] ?? 'Freepik asset video remix tidak mengembalikan URL video.')); } return [ 'url' => (string) $status['url'], 'provider' => 'freepik-runway-i2v', 'aspect' => '9:16', 'duration_seconds' => $duration, ]; } public function submitRemixVideo(array $source, string $prompt, string $mode, int $duration = 5): array { $duration = in_array($duration, [5, 10], true) ? $duration : 5; $data = $this->submitTask( '/v1/ai/image-to-video/runway-4-5', [ 'prompt' => $this->composePrompt($prompt, $mode, 'video'), 'image' => $this->referenceImagePayload($source), 'ratio' => '720:1280', 'duration' => $duration, ], 'Freepik asset video remix gagal' ); $taskId = trim((string) data_get($data, 'task_id', '')); if ($taskId === '') { throw new RuntimeException('Freepik asset video remix tidak mengembalikan task_id.'); } return [ 'provider' => 'freepik-runway-i2v', 'prediction_id' => $taskId, 'source_image_url' => $this->referenceImagePayload($source), 'async' => true, ]; } public function checkRemixVideoStatus(string $taskId): array { $taskId = trim($taskId); if ($taskId === '') { return ['status' => 'failed', 'error' => 'task_id kosong untuk Freepik Runway.']; } try { $response = $this->client(60)->get($this->endpoint('/v1/ai/image-to-video/runway-4-5/'.$taskId)); $task = $this->extractData($response, 'Freepik asset video remix gagal membaca status'); $generated = $this->extractGeneratedUrls($task); if ($generated !== []) { $videoUrl = trim((string) ($generated[0] ?? '')); if ($videoUrl === '') { return ['status' => 'failed', 'error' => 'Freepik asset video remix tidak mengembalikan URL video.']; } $relativePath = $this->persistVideo($videoUrl); return [ 'status' => 'succeeded', 'url' => $this->offloadPublicPath($relativePath), 'remote_url' => $videoUrl, 'provider' => 'freepik-runway-i2v', ]; } $status = strtoupper(trim((string) data_get($task, 'status', ''))); if (in_array($status, ['FAILED', 'ERROR', 'REJECTED', 'CANCELLED'], true)) { return ['status' => 'failed', 'error' => 'Freepik asset video remix berakhir dengan status '.$status.'.']; } return ['status' => 'processing']; } catch (Throwable $error) { return ['status' => 'failed', 'error' => $error->getMessage()]; } } private function submitTask(string $path, array $payload, string $prefix): array { $response = $this->client() ->withHeaders(['Content-Type' => 'application/json']) ->post($this->endpoint($path), $payload); return $this->extractData($response, $prefix); } private function awaitGeneratedUrls(string $path, array $initialData, string $prefix): array { $taskId = trim((string) data_get($initialData, 'task_id', '')); if ($taskId === '') { $generated = $this->extractGeneratedUrls($initialData); if ($generated !== []) { return $generated; } throw new RuntimeException($prefix.': task_id tidak ditemukan pada respons awal.'); } $callable = function () use ($path, $taskId, $prefix) { $response = $this->client(60)->get($this->endpoint(rtrim($path, '/').'/'.$taskId)); $task = $this->extractData($response, $prefix.' status gagal'); $generated = $this->extractGeneratedUrls($task); if ($generated !== []) { return $generated; } $status = strtoupper(trim((string) data_get($task, 'status', ''))); if (in_array($status, ['FAILED', 'ERROR', 'REJECTED', 'CANCELLED'], true)) { throw new RuntimeException($prefix.': task berakhir dengan status '.$status.'.'); } throw new TaskStillProcessingException('Tugas '.$taskId.' masih dalam proses (status: '.$status.').'); }; try { return $this->retry( $callable, 15, 1500, 0.2, [ConnectionException::class, TaskStillProcessingException::class] ); } catch (Throwable $e) { if ($e instanceof TaskStillProcessingException) { throw new RuntimeException($prefix.': timeout saat menunggu hasil dari Freepik.'); } throw $e; } } private function referenceImageBase64(array $source): string { return base64_encode($this->referenceImageBinary($source)); } private function referenceImagePayload(array $source): string { $candidate = $this->referenceImageLocation($source); if (Str::startsWith($candidate, ['https://', 'http://'])) { return $candidate; } $appUrl = rtrim((string) config('app.url', ''), '/'); if ($appUrl !== '' && Str::startsWith($candidate, '/storage/')) { return $appUrl.$candidate; } $binary = $this->referenceImageBinary($source); return base64_encode($binary); } private function referenceImageBinary(array $source): string { $candidate = $this->referenceImageLocation($source); $binary = $this->readBinary($candidate); $binary = $this->optimizeReferenceImage($binary); if ($binary === '') { throw new RuntimeException('Gagal membaca binary source asset untuk AI edit.'); } return $binary; } private function referenceImageLocation(array $source): string { $type = (string) ($source['type'] ?? 'image'); $candidate = ''; if ($type === 'video') { $candidate = trim((string) ($source['thumbnail_url'] ?? '')); if ($candidate === '') { throw new RuntimeException('Source video belum punya thumbnail/reference image untuk AI edit.'); } } else { $candidate = trim((string) ($source['url'] ?? '')); } if ($candidate === '') { throw new RuntimeException('Source asset tidak punya file referensi yang bisa diproses.'); } return $candidate; } private function readBinary(string $location): string { if (Str::startsWith($location, 'data:')) { $parts = explode(',', $location, 2); $binary = isset($parts[1]) ? base64_decode($parts[1], true) : false; return $binary === false ? '' : $binary; } $localPath = $this->publicStoragePath($location); if ($localPath !== null && is_file($localPath)) { return (string) file_get_contents($localPath); } if (is_file($location)) { return (string) file_get_contents($location); } if (Str::startsWith($location, ['http://', 'https://'])) { $response = Http::connectTimeout(20) ->timeout(180) ->get($location); if (! $response->successful()) { throw new RuntimeException('Gagal mengambil reference asset: '.$response->status()); } return (string) $response->body(); } throw new RuntimeException('Reference asset tidak bisa dibaca dari lokasi '.$location); } private function publicStoragePath(string $url): ?string { $path = null; if (Str::startsWith($url, '/storage/')) { $path = $url; } else { $appUrl = rtrim((string) config('app.url', ''), '/'); if ($appUrl !== '' && Str::startsWith($url, $appUrl.'/storage/')) { $path = Str::replaceFirst($appUrl, '', $url); } } if (! is_string($path) || $path === '') { return null; } $relative = ltrim(Str::replaceFirst('/storage/', '', $path), '/'); return $relative === '' ? null : Storage::disk('public')->path($relative); } private function persistVideo(string $videoUrl): string { $response = Http::connectTimeout(20) ->timeout(240) ->retry(3, 1500) ->withHeaders(['Accept' => 'video/*']) ->get($videoUrl); if (! $response->successful()) { throw new RuntimeException('Gagal mengambil hasil video remix Freepik: '.$response->status()); } $binary = $response->body(); if (! is_string($binary) || $binary === '') { throw new RuntimeException('Binary video remix Freepik kosong.'); } $relativePath = 'videos/freepik_remix_'.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 remix 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 composePrompt(string $prompt, string $mode, string $outputType): string { $modeInstruction = match ($mode) { 'mockup' => 'Transform the reference into a polished premium mockup while preserving the main subject and core composition cues.', 'face_swap' => 'Keep the main identity strongly aligned with the reference while adapting the styling and scene to the new prompt.', 'strict_face_swap' => 'Preserve the primary identity and facial structure from the reference as strictly as possible while following the prompt.', default => 'Restyle the reference while preserving the main subject and overall visual intent.', }; $outputInstruction = $outputType === 'video' ? 'Create a cinematic vertical 9:16 motion result that feels ready for social story posting.' : 'Create a premium vertical 9:16 still image ready for social story posting.'; return trim($modeInstruction.' '.$outputInstruction.' '.$prompt); } private function optimizeReferenceImage(string $binary): string { $maxBytes = 9_500_000; if (strlen($binary) <= $maxBytes) { return $binary; } if (! function_exists('\imagecreatefromstring')) { throw new RuntimeException('Reference image lebih besar dari 10MB dan GD tidak tersedia untuk kompres otomatis.'); } $image = @\imagecreatefromstring($binary); if (! $image) { throw new RuntimeException('Reference image terlalu besar dan gagal dikompres untuk Freepik edit.'); } try { $width = \imagesx($image); $height = \imagesy($image); $longestEdge = max($width, $height, 1); $scale = min(1, 1800 / $longestEdge); $targetWidth = max(1, (int) round($width * $scale)); $targetHeight = max(1, (int) round($height * $scale)); $canvas = \imagecreatetruecolor($targetWidth, $targetHeight); if (! $canvas) { throw new RuntimeException('Gagal membuat canvas kompresi untuk reference image.'); } try { $background = \imagecolorallocate($canvas, 255, 255, 255); \imagefill($canvas, 0, 0, $background); \imagecopyresampled($canvas, $image, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height); $quality = 88; $encoded = ''; do { ob_start(); \imagejpeg($canvas, null, $quality); $encoded = (string) ob_get_clean(); $quality -= 8; } while ($encoded !== '' && strlen($encoded) > $maxBytes && $quality >= 56); if ($encoded === '' || strlen($encoded) > $maxBytes) { throw new RuntimeException('Reference image masih terlalu besar setelah kompresi otomatis.'); } return $encoded; } finally { \imagedestroy($canvas); } } finally { \imagedestroy($image); } } private function detectImageMime(string $binary): string { if (function_exists('finfo_buffer')) { $finfo = finfo_open(FILEINFO_MIME_TYPE); if ($finfo !== false) { try { $mime = finfo_buffer($finfo, $binary); if (is_string($mime) && Str::startsWith($mime, 'image/')) { return $mime; } } finally { finfo_close($finfo); } } } return 'image/jpeg'; } private function extractGeneratedUrls(array $data): array { $candidates = data_get($data, 'generated', []); 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, 'video_url', data_get($candidate, 'image_url'))); if (is_string($url) && trim($url) !== '') { $urls[] = trim($url); } } } return array_values(array_filter($urls)); } private function client(int $timeout = 120) { $apiKey = trim((string) config('services.freepik.api_key', '')); if ($apiKey === '') { throw new RuntimeException($this->missingApiKeyMessage('Remix lab')); } return Http::connectTimeout(20) ->timeout($timeout) ->retry(3, 1500) ->acceptJson() ->withHeaders([ 'x-freepik-api-key' => $apiKey, ]); } private function endpoint(string $path): string { return rtrim($this->apiOrigin(), '/').$path; } private function apiOrigin(): string { $configured = trim((string) config('services.freepik.base_url', 'https://api.freepik.com/v1/ai/text-to-image')); $scheme = parse_url($configured, PHP_URL_SCHEME) ?: 'https'; $host = parse_url($configured, PHP_URL_HOST); $port = parse_url($configured, 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), 260); } /** * Generate a helpful error message when the Freepik API key is missing. * * The service can operate in two modes: normal (Freepik API enabled) or * "Hugging Face‑only" mode, which disables Freepik integration. In the * latter case we return a clear indication that the feature is unavailable. * Otherwise we point the developer to the required environment variable. */ 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. Gunakan image generator atau upload asset biasa.'; } // Provide explicit guidance on where to set the key. return 'FREEPIK_API_KEY belum diisi di file .env. Tambahkan baris `FREEPIK_API_KEY=your_key` ke .env dan pastikan konfigurasi layanan Freepik di config/services.php mengarah ke variabel ini.'; } }