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) { // Keep the original remote thumbnail if the local copy fails. } } 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'; } }