| <?php |
|
|
| namespace App\Services; |
|
|
| use App\Exceptions\TaskStillProcessingException; |
| use App\Traits\Retryable; |
| use Illuminate\Http\Client\ConnectionException; |
| use Illuminate\Http\Client\Response; |
| use Illuminate\Support\Facades\Http; |
| use Illuminate\Support\Facades\Storage; |
| use Illuminate\Support\Str; |
| use RuntimeException; |
| use Throwable; |
|
|
| class FreepikAssetRemixService |
| { |
| use Retryable; |
|
|
| public function __construct(private ?HuggingFaceMediaStorageService $hfMediaStorage = null) |
| { |
| } |
|
|
| public function remixImage(array $source, string $prompt, string $mode): array |
| { |
| $data = $this->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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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.'; |
| } |
|
|
| |
| 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.'; |
| } |
| } |
|
|