apiKey = (string) config('services.gemini.api_key', ''); $this->baseUrl = rtrim((string) config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta'), '/'); $this->defaultModel = (string) config('services.gemini.veo_model', 'veo-3.1-generate-preview'); $this->fastModel = (string) config('services.gemini.veo_fast_model', 'veo-3.1-fast-generate-preview'); } public function isAvailable(): bool { return $this->apiKey !== ''; } public function submit(?string $imageUrl, string $prompt, array $options = []): array { if (! $this->isAvailable()) { throw new RuntimeException('GEMINI_API_KEY belum diset.'); } $payload = $this->buildPayload($imageUrl, $prompt, $options); $endpoint = sprintf('%s/models/%s:predictLongRunning', $this->baseUrl, $payload['model']); $response = Http::withHeaders([ 'x-goog-api-key' => $this->apiKey, 'Content-Type' => 'application/json', ])->timeout((int) config('services.gemini.timeout', 180)) ->post($endpoint, $payload['body']); if ($response->failed()) { $message = $response->json('error.message') ?? $response->json('error.status') ?? $response->body(); throw new RuntimeException('Gemini submit gagal: ' . Str::limit((string) $message, 240)); } $operationName = (string) $response->json('name', ''); if ($operationName === '') { throw new RuntimeException('Gemini tidak mengembalikan operation name.'); } Log::info('GeminiVideoService: submitted', [ 'operation' => $operationName, 'model' => $payload['model'], ]); return [ 'prediction_id' => $operationName, 'applied_settings' => $payload['applied_settings'], ]; } public function checkStatus(string $operationName): array { if (! $this->isAvailable()) { return ['status' => 'failed', 'error' => 'GEMINI_API_KEY belum diset.']; } $response = Http::withHeaders([ 'x-goog-api-key' => $this->apiKey, ])->timeout(30)->get(sprintf('%s/%s', $this->baseUrl, ltrim($operationName, '/'))); if ($response->failed()) { $message = $response->json('error.message') ?? $response->json('error.status') ?? 'Gagal mengambil status dari Gemini.'; return ['status' => 'failed', 'error' => Str::limit((string) $message, 240)]; } $data = $response->json(); if (! (bool) ($data['done'] ?? false)) { return ['status' => 'processing']; } $operationError = data_get($data, 'error.message') ?? data_get($data, 'error.status'); if ($operationError) { return ['status' => 'failed', 'error' => Str::limit((string) $operationError, 240)]; } $videoUri = (string) data_get($data, 'response.generateVideoResponse.generatedSamples.0.video.uri', ''); if ($videoUri === '') { return ['status' => 'failed', 'error' => 'Gemini selesai tetapi URI video kosong.']; } try { $localUrl = $this->downloadVideo($videoUri); return [ 'status' => 'succeeded', 'url' => $localUrl, 'remote_url' => $videoUri, 'provider' => 'gemini-veo', ]; } catch (\Throwable $e) { return ['status' => 'failed', 'error' => $e->getMessage()]; } } private function buildPayload(?string $imageUrl, string $prompt, array $options): array { $instance = [ 'prompt' => trim($prompt), ]; $hasImageInput = false; if ($imageUrl !== null && trim($imageUrl) !== '') { $instance['image'] = $this->toImageObject($imageUrl); $hasImageInput = true; } $endFrameUrl = trim((string) ($options['end_frame_url'] ?? '')); if ($endFrameUrl !== '' && $hasImageInput) { $instance['lastFrame'] = $this->toImageObject($endFrameUrl); } $parameters = [ 'sampleCount' => 1, 'aspectRatio' => $this->normalizeAspectRatio((string) ($options['aspect_ratio'] ?? '16:9')), 'resolution' => $this->normalizeResolution((string) ($options['video_quality'] ?? '1080p')), ]; $effectiveDuration = $this->resolveDurationSeconds($parameters['resolution'], $hasImageInput, $endFrameUrl !== '', (string) ($options['video_duration'] ?? '8s')); if ($effectiveDuration !== null) { $parameters['durationSeconds'] = $effectiveDuration; } $negativePrompt = trim((string) ($options['video_negative_prompt'] ?? '')); if ($negativePrompt !== '') { $parameters['negativePrompt'] = $negativePrompt; } $model = $this->resolveModelCode((string) ($options['veo_variant'] ?? 'veo-3-fast')); $audioSetting = str_contains($model, 'veo-3.1') ? 'on' : 'off'; return [ 'model' => $model, 'body' => [ 'instances' => [$instance], 'parameters' => $parameters, ], 'applied_settings' => [ 'aspect_ratio' => $parameters['aspectRatio'], 'video_quality' => $parameters['resolution'], 'video_duration' => ($effectiveDuration ?? 8) . 's', 'veo_variant' => $model === $this->defaultModel ? 'veo-3' : 'veo-3-fast', 'video_audio' => $audioSetting, 'gemini_model' => $model, ], ]; } private function resolveModelCode(string $variant): string { return trim($variant) === 'veo-3' ? $this->defaultModel : $this->fastModel; } private function resolveDurationSeconds(string $resolution, bool $hasImageInput, bool $hasLastFrame, string $requestedDuration): ?int { if ($resolution === '1080p' || $hasLastFrame) { return 8; } if (! $hasImageInput && $requestedDuration === '5s') { return 6; } return $requestedDuration === '5s' ? 6 : 8; } private function normalizeAspectRatio(string $aspectRatio): string { return trim($aspectRatio) === '9:16' ? '9:16' : '16:9'; } private function normalizeResolution(string $resolution): string { return trim($resolution) === '720p' ? '720p' : '1080p'; } private function toImageObject(string $url): array { [$binary, $mimeType] = $this->loadBinaryAndMimeType($url); if (! str_starts_with($mimeType, 'image/')) { throw new RuntimeException('Gemini hanya menerima input image untuk mode image-to-video ini.'); } return [ 'bytesBase64Encoded' => base64_encode($binary), 'mimeType' => $mimeType, ]; } private function loadBinaryAndMimeType(string $url): array { $path = parse_url($url, PHP_URL_PATH) ?: $url; if (is_string($path) && str_starts_with($path, '/storage/')) { $relative = ltrim(substr($path, strlen('/storage/')), '/'); if (Storage::disk('public')->exists($relative)) { $binary = Storage::disk('public')->get($relative); if ($binary !== '') { return [$binary, $this->detectMimeType($binary, $relative)]; } } } $response = Http::timeout(90)->get($url); if ($response->failed()) { throw new RuntimeException('Gagal mengambil image source untuk Gemini video (HTTP ' . $response->status() . ').'); } $binary = (string) $response->body(); $mimeType = trim((string) $response->header('Content-Type')); return [$binary, $this->normalizeMimeType($mimeType !== '' ? $mimeType : $this->detectMimeType($binary, $path))]; } private function detectMimeType(string $binary, string $pathHint = ''): string { $mimeType = ''; if (class_exists('finfo')) { $finfo = new \finfo(FILEINFO_MIME_TYPE); $mimeType = (string) $finfo->buffer($binary); } if ($mimeType === '' && $pathHint !== '') { $extension = strtolower((string) pathinfo($pathHint, PATHINFO_EXTENSION)); $mimeType = match ($extension) { 'jpg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', default => '', }; } return $this->normalizeMimeType($mimeType); } private function normalizeMimeType(string $mimeType): string { $normalized = strtolower(trim(explode(';', $mimeType)[0] ?? '')); return match ($normalized) { 'image/jpg' => 'image/jpeg', 'image/jpeg', 'image/png', 'image/webp' => $normalized, default => $normalized !== '' ? $normalized : 'image/png', }; } private function downloadVideo(string $remoteUrl): string { $response = Http::withHeaders([ 'x-goog-api-key' => $this->apiKey, ])->timeout(180)->get($remoteUrl); if ($response->failed()) { throw new RuntimeException('Gagal download video dari Gemini.'); } $relPath = 'videos/gemini_' . now()->format('Ymd_His') . '_' . Str::lower(Str::random(8)) . '.mp4'; $absPath = Storage::disk('public')->path($relPath); $outputDir = dirname($absPath); if (! is_dir($outputDir) && ! @mkdir($outputDir, 0775, true) && ! is_dir($outputDir)) { throw new RuntimeException('Gagal menyiapkan folder output video Gemini.'); } file_put_contents($absPath, $response->body()); if (! is_file($absPath) || filesize($absPath) < 1024) { throw new RuntimeException('File video Gemini kosong atau rusak setelah download.'); } if ($this->hfMediaStorage !== null) { try { return $this->hfMediaStorage->offloadPublicRelativePath($relPath); } catch (\Throwable $e) { Log::warning('GeminiVideoService: HF offload failed', ['error' => $e->getMessage()]); } } return Storage::disk('public')->url($relPath); } }