| namespace App\Services; | |
| use Illuminate\Support\Facades\Http; | |
| use Illuminate\Support\Facades\Log; | |
| use Illuminate\Support\Facades\Storage; | |
| use Illuminate\Support\Str; | |
| use RuntimeException; | |
| class GeminiVideoService | |
| { | |
| private string $apiKey; | |
| private string $baseUrl; | |
| private string $defaultModel; | |
| private string $fastModel; | |
| public function __construct(private ?HuggingFaceMediaStorageService $hfMediaStorage = null) | |
| { | |
| $this->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); | |
| } | |
| } |