| 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 OpenAiVideoService | |
| { | |
| public function __construct( | |
| private OpenAiImageService $openAiImage, | |
| private MotionVideoService $motionVideo, | |
| private ?HuggingFaceMediaStorageService $hfMediaStorage = null, | |
| ) { | |
| } | |
| public function generate(?string $sourceImageUrl, string $prompt, array $options = []): array | |
| { | |
| $cleanPrompt = trim($prompt); | |
| $imageUrl = trim((string) $sourceImageUrl); | |
| $usesGeneratedFrame = false; | |
| if ($cleanPrompt === '') { | |
| throw new RuntimeException('Prompt video OpenAI wajib diisi.'); | |
| } | |
| if ($this->isAvailable()) { | |
| return $imageUrl !== '' | |
| ? $this->submit($imageUrl, $cleanPrompt, $options) | |
| : $this->submit(null, $cleanPrompt, $options); | |
| } | |
| if ($imageUrl === '') { | |
| $images = $this->openAiImage->generate($cleanPrompt, $this->resolveAspect($options), 1); | |
| $imageUrl = trim((string) ($images[0] ?? '')); | |
| $usesGeneratedFrame = true; | |
| } | |
| $video = $this->motionVideo->createFromImageUrl($imageUrl, $cleanPrompt, array_merge([ | |
| 'video_quality' => '720p', | |
| 'video_duration' => '4s', | |
| ], $options)); | |
| return [ | |
| 'url' => $video['url'] ?? null, | |
| 'provider' => 'openai-motion', | |
| 'aspect' => $video['aspect'] ?? ($options['aspect_ratio'] ?? '16:9'), | |
| 'prompt' => $cleanPrompt, | |
| 'duration_seconds' => $video['duration_seconds'] ?? 4, | |
| 'source_image_url' => $imageUrl, | |
| 'applied_settings' => array_merge((array) ($video['applied_settings'] ?? []), [ | |
| 'video_provider' => 'openai', | |
| 'openai_video_model' => 'motion-render-fallback', | |
| ]), | |
| 'notice' => $usesGeneratedFrame | |
| ? 'Start frame dibuat lewat OpenAI image, lalu dianimasikan dengan motion renderer lokal karena OpenAI Video API belum tersedia.' | |
| : 'Source frame dipakai langsung, lalu dirender ke video dengan fallback motion lokal.', | |
| 'async' => false, | |
| ]; | |
| } | |
| public function submit(?string $sourceImageUrl, string $prompt, array $options = []): array | |
| { | |
| if (! $this->isAvailable()) { | |
| throw new RuntimeException('OPENAI_API_KEY belum diisi di file .env'); | |
| } | |
| $payload = $this->buildPayload($sourceImageUrl, $prompt, $options); | |
| $response = Http::timeout($this->resolveTimeout()) | |
| ->withToken($this->resolveApiKey()) | |
| ->acceptJson() | |
| ->post($this->endpoint('/videos'), $payload['body']); | |
| if (! $response->successful()) { | |
| $errorBody = trim((string) data_get($response->json(), 'error.message', $response->body())); | |
| throw new RuntimeException('OpenAI video submit gagal: '.$response->status().' - '.$errorBody); | |
| } | |
| $videoId = trim((string) $response->json('id', '')); | |
| if ($videoId === '') { | |
| throw new RuntimeException('OpenAI video tidak mengembalikan video id.'); | |
| } | |
| Log::info('OpenAiVideoService: submitted', [ | |
| 'video_id' => $videoId, | |
| 'model' => $payload['applied_settings']['openai_video_model'] ?? 'sora-2', | |
| ]); | |
| return [ | |
| 'provider' => 'openai-sora', | |
| 'prediction_id' => $videoId, | |
| 'source_image_url' => $sourceImageUrl, | |
| 'prompt' => trim($prompt), | |
| 'applied_settings' => $payload['applied_settings'], | |
| 'notice' => 'Render OpenAI Sora aktif memakai '.$payload['applied_settings']['openai_video_model'].' dengan output '.$payload['applied_settings']['video_quality'].' dan durasi '.$payload['applied_settings']['video_duration'].'.', | |
| 'async' => true, | |
| ]; | |
| } | |
| public function checkStatus(string $videoId): array | |
| { | |
| if (! $this->isAvailable()) { | |
| return ['status' => 'failed', 'error' => 'OPENAI_API_KEY belum diisi di file .env']; | |
| } | |
| $response = Http::timeout(30) | |
| ->withToken($this->resolveApiKey()) | |
| ->acceptJson() | |
| ->get($this->endpoint('/videos/'.ltrim($videoId, '/'))); | |
| if (! $response->successful()) { | |
| $message = trim((string) data_get($response->json(), 'error.message', $response->body())); | |
| return ['status' => 'failed', 'error' => 'Gagal mengambil status video OpenAI: '.$message]; | |
| } | |
| $data = $response->json(); | |
| $status = (string) ($data['status'] ?? ''); | |
| if (in_array($status, ['queued', 'in_progress'], true)) { | |
| return ['status' => 'processing']; | |
| } | |
| if ($status === 'failed') { | |
| $errorMessage = (string) data_get($data, 'error.message', 'OpenAI video gagal diproses.'); | |
| return ['status' => 'failed', 'error' => $errorMessage]; | |
| } | |
| if ($status !== 'completed') { | |
| return ['status' => 'failed', 'error' => 'Status video OpenAI tidak dikenali: '.$status]; | |
| } | |
| try { | |
| $download = $this->downloadVideo($videoId); | |
| return [ | |
| 'status' => 'succeeded', | |
| 'url' => $download['url'], | |
| 'remote_url' => $download['remote_url'], | |
| 'provider' => 'openai-sora', | |
| ]; | |
| } catch (\Throwable $e) { | |
| return ['status' => 'failed', 'error' => $e->getMessage()]; | |
| } | |
| } | |
| public function isAvailable(): bool | |
| { | |
| return trim($this->resolveApiKey()) !== ''; | |
| } | |
| private function buildPayload(?string $sourceImageUrl, string $prompt, array $options): array | |
| { | |
| $model = trim((string) ($options['openai_video_model'] ?? config('services.openai.video_model', 'sora-2'))); | |
| $seconds = $this->normalizeDuration((string) ($options['video_duration'] ?? '4s')); | |
| $size = $this->normalizeSize((string) ($options['aspect_ratio'] ?? '16:9'), (string) ($options['video_quality'] ?? '720p')); | |
| $body = [ | |
| 'model' => $model !== '' ? $model : 'sora-2', | |
| 'prompt' => trim($prompt), | |
| 'seconds' => $seconds, | |
| 'size' => $size, | |
| ]; | |
| if (trim((string) $sourceImageUrl) !== '') { | |
| $body['input_reference'] = [ | |
| [ | |
| 'image_url' => trim((string) $sourceImageUrl), | |
| ], | |
| ]; | |
| } | |
| return [ | |
| 'body' => $body, | |
| 'applied_settings' => [ | |
| 'video_provider' => 'openai', | |
| 'video_quality' => str_starts_with($size, '720x') || str_ends_with($size, 'x720') ? '720p' : '1024p', | |
| 'video_duration' => $seconds.'s', | |
| 'aspect_ratio' => trim((string) ($options['aspect_ratio'] ?? '16:9')) === '9:16' ? '9:16' : '16:9', | |
| 'openai_video_model' => $body['model'], | |
| 'openai_video_size' => $size, | |
| ], | |
| ]; | |
| } | |
| private function normalizeDuration(string $duration): string | |
| { | |
| return match (trim($duration)) { | |
| '5s', '4s' => '4', | |
| '12s' => '12', | |
| default => '8', | |
| }; | |
| } | |
| private function normalizeSize(string $aspectRatio, string $quality): string | |
| { | |
| $portrait = trim($aspectRatio) === '9:16'; | |
| $hd = trim($quality) !== '720p'; | |
| if ($hd) { | |
| return $portrait ? '1024x1792' : '1792x1024'; | |
| } | |
| return $portrait ? '720x1280' : '1280x720'; | |
| } | |
| private function resolveAspect(array $options): string | |
| { | |
| return (($options['aspect_ratio'] ?? '16:9') === '9:16') ? 'portrait' : 'landscape'; | |
| } | |
| private function resolveApiKey(): string | |
| { | |
| $primaryKey = trim((string) config('services.openai.api_key')); | |
| $fallbackKey = trim((string) config('services.openai.chat_api_key')); | |
| return $primaryKey !== '' ? $primaryKey : $fallbackKey; | |
| } | |
| private function resolveTimeout(): int | |
| { | |
| return max(30, (int) config('services.openai.timeout', 40)); | |
| } | |
| private function endpoint(string $path): string | |
| { | |
| return rtrim((string) config('services.openai.base_url', 'https://api.openai.com/v1'), '/').$path; | |
| } | |
| private function downloadVideo(string $videoId): array | |
| { | |
| $response = Http::timeout(180) | |
| ->withToken($this->resolveApiKey()) | |
| ->withOptions(['stream' => false]) | |
| ->get($this->endpoint('/videos/'.ltrim($videoId, '/').'/content')); | |
| if ($response->failed()) { | |
| $message = trim((string) data_get($response->json(), 'error.message', $response->body())); | |
| throw new RuntimeException('Gagal download video OpenAI: '.$message); | |
| } | |
| $relPath = 'videos/openai_sora_'.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 OpenAI.'); | |
| } | |
| file_put_contents($absPath, $response->body()); | |
| if (! is_file($absPath) || filesize($absPath) < 1024) { | |
| throw new RuntimeException('File video OpenAI kosong atau rusak setelah download.'); | |
| } | |
| $publicUrl = Storage::disk('public')->url($relPath); | |
| if ($this->hfMediaStorage !== null) { | |
| try { | |
| $publicUrl = $this->hfMediaStorage->offloadPublicRelativePath($relPath); | |
| } catch (\Throwable $e) { | |
| Log::warning('OpenAiVideoService: HF offload failed', ['error' => $e->getMessage()]); | |
| } | |
| } | |
| return [ | |
| 'url' => $publicUrl, | |
| 'remote_url' => $this->endpoint('/videos/'.ltrim($videoId, '/').'/content'), | |
| ]; | |
| } | |
| } | |