videoGenerationId = $videoGenerationId; $this->selectedTitle = $selectedTitle; $this->customScenario = $customScenario; } public function handle( GeminiScriptService $gemini, VeoVideoService $veo, TtsService $tts, BgmService $bgm, VideoAssemblyService $assembly ): void { $video = VideoGeneration::withoutGlobalScopes()->find($this->videoGenerationId); if (! $video) { Log::error('VideoGenerationJob: 레코드를 찾을 수 없음', ['id' => $this->videoGenerationId]); return; } $workDir = storage_path("app/video_gen/{$video->id}"); if (! is_dir($workDir)) { mkdir($workDir, 0755, true); } $totalCost = 0.0; try { // === Step 1: 시나리오 생성 === $video->updateProgress(VideoGeneration::STATUS_SCENARIO_READY, 5, '시나리오 생성 중...'); if ($this->customScenario) { $scenario = $this->customScenario; } else { $title = $this->selectedTitle ?? $video->title; $scenario = $gemini->generateScenario($title, $video->keyword); if (empty($scenario) || empty($scenario['scenes'])) { $video->markFailed('시나리오 생성 실패'); return; } } $video->update([ 'scenario' => $scenario, 'title' => $scenario['title'] ?? $video->title, ]); $scenes = $scenario['scenes'] ?? []; $totalCost += 0.001; // Gemini 비용 // === Step 2: 나레이션 생성 === $video->updateProgress(VideoGeneration::STATUS_GENERATING_TTS, 15, '나레이션 생성 중...'); $narrationPaths = $tts->synthesizeScenes($scenes, $workDir); if (empty($narrationPaths)) { $video->markFailed('나레이션 생성 실패'); return; } $totalCost += 0.01; // TTS 비용 // === Step 3: 영상 클립 생성 === $video->updateProgress(VideoGeneration::STATUS_GENERATING_CLIPS, 20, '영상 클립 생성 요청 중...'); $clipPaths = []; $operations = []; // 모든 장면의 영상 생성 요청 (비동기) foreach ($scenes as $scene) { $sceneNum = $scene['scene_number']; $prompt = $scene['visual_prompt'] ?? ''; $duration = $scene['duration'] ?? 8; $video->updateProgress( VideoGeneration::STATUS_GENERATING_CLIPS, 20 + (int) (($sceneNum / count($scenes)) * 10), "영상 클립 생성 요청 중 ({$sceneNum}/" . count($scenes) . ')' ); $result = $veo->generateClip($prompt, $duration); if (! $result) { $video->markFailed("장면 {$sceneNum} 영상 생성 요청 실패"); return; } $operations[$sceneNum] = $result['operationName']; } $totalCost += count($scenes) * 1.20; // Veo 비용 (Fast 기준 8초당 $1.20) // 모든 영상 클립 완료 대기 foreach ($operations as $sceneNum => $operationName) { $video->updateProgress( VideoGeneration::STATUS_GENERATING_CLIPS, 30 + (int) (($sceneNum / count($scenes)) * 40), "영상 클립 생성 대기 중 ({$sceneNum}/" . count($scenes) . ')' ); $clipPath = $veo->waitAndSave( $operationName, "{$workDir}/clip_{$sceneNum}.mp4" ); if (! $clipPath) { $video->markFailed("장면 {$sceneNum} 영상 생성 실패 (타임아웃)"); return; } $clipPaths[$sceneNum] = $clipPath; } // 장면 순서로 정렬 ksort($clipPaths); $video->update(['clips_data' => $clipPaths]); // === Step 4: BGM 생성/선택 === $video->updateProgress(VideoGeneration::STATUS_GENERATING_BGM, 75, 'BGM 준비 중...'); $bgmMood = $scenario['bgm_mood'] ?? 'upbeat'; $bgmPath = $bgm->select($bgmMood, "{$workDir}/bgm.mp3"); // BGM 파일이 없으면 무음 BGM 생성 if (! $bgmPath) { $totalDuration = array_sum(array_column($scenes, 'duration')); $bgmPath = $bgm->generateSilence($totalDuration, "{$workDir}/bgm.mp3"); } // === Step 5: 최종 합성 === $video->updateProgress(VideoGeneration::STATUS_ASSEMBLING, 80, '영상 합성 중...'); // 5-1. 클립 결합 $concatPath = "{$workDir}/concat.mp4"; $concatResult = $assembly->concatClips(array_values($clipPaths), $concatPath); if (! $concatResult) { $video->markFailed('영상 클립 결합 실패'); return; } // 5-2. 나레이션 결합 $narrationConcatPath = "{$workDir}/narration_full.mp3"; $assembly->concatNarrations($narrationPaths, $scenes, $narrationConcatPath); // 5-3. 자막 생성 $subtitlePath = "{$workDir}/subtitles.ass"; $assembly->generateAssSubtitle($scenes, $subtitlePath); // 5-4. 최종 합성 $video->updateProgress(VideoGeneration::STATUS_ASSEMBLING, 90, '최종 합성 중...'); $finalPath = "{$workDir}/final_{$video->id}.mp4"; $result = $assembly->assemble( $concatPath, file_exists($narrationConcatPath) ? $narrationConcatPath : null, $bgmPath, $subtitlePath, $finalPath ); if (! $result) { $video->markFailed('최종 영상 합성 실패'); return; } // === 완료 === $video->update([ 'status' => VideoGeneration::STATUS_COMPLETED, 'progress' => 100, 'current_step' => '완료', 'output_path' => $finalPath, 'cost_usd' => $totalCost, ]); // 중간 파일 정리 $assembly->cleanup($workDir); Log::info('VideoGenerationJob: 영상 생성 완료', [ 'id' => $video->id, 'output' => $finalPath, 'cost' => $totalCost, ]); } catch (\Exception $e) { Log::error('VideoGenerationJob: 예외 발생', [ 'id' => $video->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); $video->markFailed($e->getMessage()); } } public function failed(\Throwable $exception): void { $video = VideoGeneration::withoutGlobalScopes()->find($this->videoGenerationId); $video?->markFailed('Job 실패: ' . $exception->getMessage()); } }