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'), ]; } }