teknolis / app /Services /GeminiVideoService.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
11 kB
<?php
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);
}
}