| <?php |
|
|
| namespace App\Services; |
|
|
| 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; |
|
|
| class FreepikStockAssetService |
| { |
| public function importForEdit(string $kind, int $assetId): array |
| { |
| $normalizedKind = $this->normalizeKind($kind); |
|
|
| return match ($normalizedKind) { |
| 'images' => $this->importImagePreview($assetId), |
| 'videos' => $this->importVideoPreview($assetId), |
| 'icons' => $this->importIconPng($assetId), |
| default => throw new RuntimeException('Asset jenis ini belum didukung untuk AI edit di dalam project.'), |
| }; |
| } |
|
|
| public function downloadToProject(string $kind, int $assetId): array |
| { |
| $normalizedKind = $this->normalizeKind($kind); |
|
|
| return match ($normalizedKind) { |
| 'images' => $this->downloadImageAsset($assetId), |
| 'videos' => $this->downloadVideoAsset($assetId), |
| 'icons' => $this->downloadIconAsset($assetId), |
| 'audio' => $this->downloadMusicAsset($assetId), |
| default => throw new RuntimeException('Jenis asset Freepik tidak valid untuk didownload.'), |
| }; |
| } |
|
|
| private function importImagePreview(int $assetId): array |
| { |
| $detail = $this->detail('images', $assetId); |
| $sourceUrl = trim((string) data_get($detail, 'image.source.url', '')); |
|
|
| if ($sourceUrl === '') { |
| $download = $this->downloadImageAsset($assetId); |
|
|
| return [ |
| 'type' => 'image', |
| 'provider' => 'freepik-stock', |
| 'title' => (string) ($download['title'] ?? 'Freepik image'), |
| 'url' => $download['public_url'], |
| 'thumbnail_url' => $download['public_url'], |
| 'width' => null, |
| 'height' => null, |
| 'kind' => 'images', |
| 'remote_id' => $assetId, |
| ]; |
| } |
|
|
| $stored = $this->storeRemoteFile($sourceUrl, [ |
| 'folder' => 'freepik_stock/imports/images', |
| 'filename' => $this->slugFilename((string) data_get($detail, 'title', 'freepik-image'), $sourceUrl), |
| ]); |
|
|
| return [ |
| 'type' => 'image', |
| 'provider' => 'freepik-stock', |
| 'title' => (string) data_get($detail, 'title', 'Freepik image'), |
| 'url' => $stored['public_url'], |
| 'thumbnail_url' => $stored['public_url'], |
| 'width' => $this->numericOrNull(data_get($detail, 'image.source.width')), |
| 'height' => $this->numericOrNull(data_get($detail, 'image.source.height')), |
| 'kind' => 'images', |
| 'remote_id' => $assetId, |
| ]; |
| } |
|
|
| private function importVideoPreview(int $assetId): array |
| { |
| $detail = $this->detail('videos', $assetId); |
| $previewUrl = trim((string) data_get($detail, 'previews.0.url', '')); |
| $thumbnailUrl = trim((string) data_get($detail, 'thumbnails.0.url', '')); |
|
|
| if ($previewUrl === '') { |
| throw new RuntimeException('Freepik video preview tidak tersedia untuk AI edit.'); |
| } |
|
|
| $stored = $this->storeRemoteFile($previewUrl, [ |
| 'folder' => 'freepik_stock/imports/videos', |
| 'filename' => $this->slugFilename((string) data_get($detail, 'name', 'freepik-video'), $previewUrl), |
| ]); |
|
|
| if ($thumbnailUrl !== '') { |
| try { |
| $thumbnailStored = $this->storeRemoteFile($thumbnailUrl, [ |
| 'folder' => 'freepik_stock/imports/video_thumbs', |
| 'filename' => $this->slugFilename((string) data_get($detail, 'name', 'freepik-video-thumb'), $thumbnailUrl), |
| ]); |
| $thumbnailUrl = $thumbnailStored['public_url']; |
| } catch (\Throwable) { |
| |
| } |
| } |
|
|
| return [ |
| 'type' => 'video', |
| 'provider' => 'freepik-stock', |
| 'title' => (string) data_get($detail, 'name', 'Freepik video'), |
| 'url' => $stored['public_url'], |
| 'thumbnail_url' => $thumbnailUrl, |
| 'width' => $this->numericOrNull(data_get($detail, 'thumbnails.0.width')), |
| 'height' => $this->numericOrNull(data_get($detail, 'thumbnails.0.height')), |
| 'duration_seconds' => $this->durationToSeconds((string) data_get($detail, 'duration', '')), |
| 'kind' => 'videos', |
| 'remote_id' => $assetId, |
| ]; |
| } |
|
|
| private function importIconPng(int $assetId): array |
| { |
| $download = $this->downloadIconAsset($assetId); |
|
|
| return [ |
| 'type' => 'image', |
| 'provider' => 'freepik-stock', |
| 'title' => (string) ($download['title'] ?? 'Freepik icon'), |
| 'url' => $download['public_url'], |
| 'thumbnail_url' => $download['public_url'], |
| 'width' => 512, |
| 'height' => 512, |
| 'kind' => 'icons', |
| 'remote_id' => $assetId, |
| ]; |
| } |
|
|
| private function downloadImageAsset(int $assetId): array |
| { |
| $detail = $this->detail('images', $assetId); |
| $download = $this->authorizedGet($this->endpoint("/v1/resources/{$assetId}/download"), [ |
| 'image_size' => 'original', |
| ], 'Freepik resource download gagal'); |
|
|
| $data = $this->extractData($download, 'Freepik resource download gagal'); |
| $remoteUrl = trim((string) data_get($data, 'signed_url', data_get($data, 'url', ''))); |
|
|
| if ($remoteUrl === '') { |
| throw new RuntimeException('Freepik resource download tidak mengembalikan URL file.'); |
| } |
|
|
| $stored = $this->storeRemoteFile($remoteUrl, [ |
| 'folder' => 'freepik_stock/downloads/images', |
| 'filename' => (string) data_get($data, 'filename', $this->slugFilename((string) data_get($detail, 'title', 'freepik-image'), $remoteUrl)), |
| ]); |
|
|
| return [ |
| ...$stored, |
| 'title' => (string) data_get($detail, 'title', 'Freepik image'), |
| 'kind' => 'images', |
| 'remote_id' => $assetId, |
| ]; |
| } |
|
|
| private function downloadVideoAsset(int $assetId): array |
| { |
| $detail = $this->detail('videos', $assetId); |
| $download = $this->authorizedGet($this->endpoint("/v1/videos/{$assetId}/download"), [], 'Freepik video download gagal'); |
| $data = $this->extractData($download, 'Freepik video download gagal'); |
| $remoteUrl = trim((string) data_get($data, 'url', '')); |
|
|
| if ($remoteUrl === '') { |
| throw new RuntimeException('Freepik video download tidak mengembalikan URL file.'); |
| } |
|
|
| $stored = $this->storeRemoteFile($remoteUrl, [ |
| 'folder' => 'freepik_stock/downloads/videos', |
| 'filename' => (string) data_get($data, 'filename', $this->slugFilename((string) data_get($detail, 'name', 'freepik-video'), $remoteUrl)), |
| ]); |
|
|
| return [ |
| ...$stored, |
| 'title' => (string) data_get($detail, 'name', 'Freepik video'), |
| 'kind' => 'videos', |
| 'remote_id' => $assetId, |
| ]; |
| } |
|
|
| private function downloadIconAsset(int $assetId): array |
| { |
| $detail = $this->detail('icons', $assetId); |
| $download = $this->authorizedGet($this->endpoint("/v1/icons/{$assetId}/download"), [ |
| 'format' => 'png', |
| 'png_size' => 512, |
| ], 'Freepik icon download gagal'); |
| $data = $this->extractData($download, 'Freepik icon download gagal'); |
| $remoteUrl = trim((string) data_get($data, 'url', '')); |
|
|
| if ($remoteUrl === '') { |
| throw new RuntimeException('Freepik icon download tidak mengembalikan URL file.'); |
| } |
|
|
| $stored = $this->storeRemoteFile($remoteUrl, [ |
| 'folder' => 'freepik_stock/downloads/icons', |
| 'filename' => (string) data_get($data, 'filename', $this->slugFilename((string) data_get($detail, 'name', 'freepik-icon'), $remoteUrl)), |
| ]); |
|
|
| return [ |
| ...$stored, |
| 'title' => (string) data_get($detail, 'name', 'Freepik icon'), |
| 'kind' => 'icons', |
| 'remote_id' => $assetId, |
| ]; |
| } |
|
|
| private function downloadMusicAsset(int $assetId): array |
| { |
| $detail = $this->detail('audio', $assetId); |
| $remoteUrl = trim((string) data_get($detail, 'download_url', data_get($detail, 'file_url', ''))); |
|
|
| if ($remoteUrl === '') { |
| throw new RuntimeException('Freepik music download URL tidak tersedia.'); |
| } |
|
|
| $stored = $this->storeRemoteFile($remoteUrl, [ |
| 'folder' => 'freepik_stock/downloads/audio', |
| 'filename' => $this->slugFilename((string) data_get($detail, 'title', 'freepik-audio'), $remoteUrl), |
| ]); |
|
|
| return [ |
| ...$stored, |
| 'title' => (string) data_get($detail, 'title', 'Freepik audio'), |
| 'kind' => 'audio', |
| 'remote_id' => $assetId, |
| ]; |
| } |
|
|
| private function detail(string $kind, int $assetId): array |
| { |
| return match ($this->normalizeKind($kind)) { |
| 'images' => $this->extractData( |
| $this->authorizedGet($this->endpoint("/v1/resources/{$assetId}"), [], 'Freepik resource detail gagal'), |
| 'Freepik resource detail gagal' |
| ), |
| 'videos' => $this->extractData( |
| $this->authorizedGet($this->endpoint("/v1/videos/{$assetId}"), [], 'Freepik video detail gagal'), |
| 'Freepik video detail gagal' |
| ), |
| 'icons' => $this->extractData( |
| $this->authorizedGet($this->endpoint("/v1/icons/{$assetId}"), [], 'Freepik icon detail gagal'), |
| 'Freepik icon detail gagal' |
| ), |
| 'audio' => $this->extractRawOrData( |
| $this->authorizedGet($this->endpoint("/v1/music/{$assetId}"), [], 'Freepik music detail gagal'), |
| 'Freepik music detail gagal' |
| ), |
| default => throw new RuntimeException('Jenis asset tidak didukung.'), |
| }; |
| } |
|
|
| private function authorizedGet(string $url, array $query, string $prefix): Response |
| { |
| try { |
| $response = $this->client()->get($url, $query); |
| } catch (ConnectionException $e) { |
| throw new RuntimeException($prefix.': koneksi ke Freepik sempat terputus (network reset/TLS). Coba ulangi beberapa detik lagi.'); |
| } |
|
|
| if (! $response->successful()) { |
| throw new RuntimeException($prefix.': '.$response->status().' - '.$this->responseError($response)); |
| } |
|
|
| return $response; |
| } |
|
|
| private function client() |
| { |
| $apiKey = trim((string) config('services.freepik.api_key', '')); |
|
|
| if ($apiKey === '') { |
| throw new RuntimeException($this->missingApiKeyMessage()); |
| } |
|
|
| return Http::connectTimeout(20) |
| ->timeout(90) |
| ->retry(3, 600, fn ($exception) => $exception instanceof ConnectionException, throw: false) |
| ->acceptJson() |
| ->withOptions([ |
| 'force_ip_resolve' => 'v4', |
| ]) |
| ->withHeaders([ |
| 'x-freepik-api-key' => $apiKey, |
| 'User-Agent' => 'TeknoMedia/1.0 (+Laravel Freepik Client)', |
| ]); |
| } |
|
|
| 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 |
| { |
| $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 extractRawOrData(Response $response, string $prefix): array |
| { |
| $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 storeRemoteFile(string $remoteUrl, array $options = []): array |
| { |
| $folder = trim((string) ($options['folder'] ?? 'freepik_stock/downloads')); |
| $preferredName = trim((string) ($options['filename'] ?? '')); |
| $tempPath = tempnam(sys_get_temp_dir(), 'fp_'); |
|
|
| if ($tempPath === false) { |
| throw new RuntimeException('Gagal membuat file sementara untuk download Freepik.'); |
| } |
|
|
| try { |
| try { |
| $response = Http::connectTimeout(20) |
| ->timeout(180) |
| ->retry(3, 600, fn ($exception) => $exception instanceof ConnectionException, throw: false) |
| ->withOptions([ |
| 'sink' => $tempPath, |
| 'allow_redirects' => true, |
| 'force_ip_resolve' => 'v4', |
| ]) |
| ->withHeaders([ |
| 'User-Agent' => 'TeknoMedia/1.0 (+Laravel Freepik Client)', |
| ]) |
| ->get($remoteUrl); |
| } catch (ConnectionException $e) { |
| throw new RuntimeException('Koneksi download asset Freepik sempat reset. Coba ulangi sekali lagi.'); |
| } |
|
|
| if (! $response->successful()) { |
| throw new RuntimeException('Gagal mengambil file dari Freepik download URL: '.$response->status()); |
| } |
|
|
| $pathSource = $preferredName !== '' |
| ? $preferredName |
| : (parse_url($remoteUrl, PHP_URL_PATH) ?: ''); |
| $extension = strtolower(pathinfo($pathSource, PATHINFO_EXTENSION)); |
| if ($extension === '') { |
| $extension = $this->extensionFromMime((string) $response->header('Content-Type')); |
| } |
|
|
| $baseName = $preferredName !== '' |
| ? pathinfo($preferredName, PATHINFO_FILENAME) |
| : 'freepik_'.Str::lower(Str::random(10)); |
|
|
| $safeFilename = Str::slug($baseName); |
| if ($safeFilename === '') { |
| $safeFilename = 'freepik_'.Str::lower(Str::random(10)); |
| } |
|
|
| $finalFilename = $safeFilename.($extension !== '' ? '.'.$extension : ''); |
| $relativePath = trim($folder, '/').'/'.now()->format('Ymd_His').'_'.$finalFilename; |
|
|
| $stream = fopen($tempPath, 'rb'); |
| if ($stream === false) { |
| throw new RuntimeException('Gagal membaca file sementara Freepik.'); |
| } |
|
|
| Storage::disk('public')->writeStream($relativePath, $stream); |
|
|
| if (is_resource($stream)) { |
| fclose($stream); |
| } |
|
|
| return [ |
| 'relative_path' => $relativePath, |
| 'public_url' => Storage::url($relativePath), |
| 'url' => Storage::url($relativePath), |
| 'path' => Storage::disk('public')->path($relativePath), |
| 'filename' => $finalFilename, |
| 'mime' => (string) $response->header('Content-Type', 'application/octet-stream'), |
| ]; |
| } finally { |
| if (is_file($tempPath)) { |
| @unlink($tempPath); |
| } |
| } |
| } |
|
|
| private function slugFilename(string $title, string $remoteUrl): string |
| { |
| $baseName = Str::slug($title); |
| if ($baseName === '') { |
| $baseName = 'freepik_'.Str::lower(Str::random(10)); |
| } |
|
|
| $extension = strtolower(pathinfo(parse_url($remoteUrl, PHP_URL_PATH) ?: $remoteUrl, PATHINFO_EXTENSION)); |
|
|
| return $baseName.($extension !== '' ? '.'.$extension : ''); |
| } |
|
|
| private function extensionFromMime(string $mime): string |
| { |
| return match (Str::before($mime, ';')) { |
| 'image/jpeg' => 'jpg', |
| 'image/png' => 'png', |
| 'image/webp' => 'webp', |
| 'image/svg+xml' => 'svg', |
| 'video/mp4' => 'mp4', |
| 'audio/mpeg' => 'mp3', |
| 'audio/mp3' => 'mp3', |
| 'audio/wav' => 'wav', |
| 'application/zip' => 'zip', |
| default => '', |
| }; |
| } |
|
|
| private function normalizeKind(string $kind): string |
| { |
| return match ($kind) { |
| 'image', 'images', 'resource', 'resources' => 'images', |
| 'video', 'videos' => 'videos', |
| 'icon', 'icons' => 'icons', |
| 'audio', 'music' => 'audio', |
| default => 'unknown', |
| }; |
| } |
|
|
| private function numericOrNull(mixed $value): ?int |
| { |
| return is_numeric($value) ? (int) $value : null; |
| } |
|
|
| private function durationToSeconds(string $duration): ?int |
| { |
| $parts = array_map('intval', explode(':', $duration)); |
|
|
| if (count($parts) === 3) { |
| return ($parts[0] * 3600) + ($parts[1] * 60) + $parts[2]; |
| } |
|
|
| if (count($parts) === 2) { |
| return ($parts[0] * 60) + $parts[1]; |
| } |
|
|
| return null; |
| } |
|
|
| private function missingApiKeyMessage(): string |
| { |
| $value = strtolower(trim((string) ( |
| config('services.huggingface.hf_only') |
| ?? $_ENV['TOOLS_HF_ONLY'] |
| ?? $_SERVER['TOOLS_HF_ONLY'] |
| ?? getenv('TOOLS_HF_ONLY') |
| ?? 'false' |
| ))); |
|
|
| if (in_array($value, ['1', 'true', 'yes', 'on'], true)) { |
| return 'Fitur stock import/download dimatikan di mode Hugging Face-only.'; |
| } |
|
|
| return 'FREEPIK_API_KEY belum diisi di file .env'; |
| } |
| } |
|
|