tutorialVideoId = $tutorialVideoId; } public function handle( ScreenAnalysisService $screenAnalysis, SlideAnnotationService $slideAnnotation, TtsService $tts, BgmService $bgm, VideoAssemblyService $videoAssembly, TutorialAssemblyService $tutorialAssembly, GoogleCloudStorageService $gcs ): void { $tutorial = TutorialVideo::withoutGlobalScopes()->find($this->tutorialVideoId); if (! $tutorial) { Log::error('TutorialVideoJob: 레코드를 찾을 수 없음', ['id' => $this->tutorialVideoId]); return; } $workDir = storage_path("app/tutorial_gen/{$tutorial->id}"); if (! is_dir($workDir)) { mkdir($workDir, 0755, true); } $totalCost = 0.0; try { // === Step 1: AI 스크린샷 분석 (10%) === $tutorial->updateProgress(TutorialVideo::STATUS_ANALYZING, 5, 'AI 스크린샷 분석 중...'); $analysisData = $tutorial->analysis_data; if (empty($analysisData)) { $screenshots = $tutorial->screenshots ?? []; if (empty($screenshots)) { $tutorial->markFailed('업로드된 스크린샷이 없습니다'); return; } $analysisData = $screenAnalysis->analyzeScreenshots($screenshots); if (empty($analysisData)) { $tutorial->markFailed('스크린샷 분석에 실패했습니다'); return; } $tutorial->update(['analysis_data' => $analysisData]); $totalCost += 0.01; // Gemini Vision } $tutorial->updateProgress(TutorialVideo::STATUS_ANALYZING, 10, 'AI 분석 완료'); // === Step 2: 어노테이션 슬라이드 생성 (30%) === $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_SLIDES, 15, '슬라이드 생성 중...'); $slidePaths = []; $durations = []; $scenes = []; $screenshots = $tutorial->screenshots ?? []; foreach ($analysisData as $i => $screen) { $screenNum = $screen['screen_number'] ?? ($i + 1); $imagePath = $screenshots[$i] ?? null; if (! $imagePath || ! file_exists($imagePath)) { Log::warning("TutorialVideoJob: 스크린샷 없음 - index {$i}"); continue; } $outputSlide = "{$workDir}/slide_{$screenNum}.png"; $result = $slideAnnotation->annotateSlide( $imagePath, $screen['ui_elements'] ?? [], $screenNum, $screen['narration'] ?? "화면 {$screenNum}", $outputSlide ); if ($result) { $slidePaths[] = $result; $duration = $screen['duration'] ?? 8; $durations[] = $duration; $scenes[] = [ 'scene_number' => $screenNum, 'narration' => $screen['narration'] ?? '', 'duration' => $duration, ]; } $progress = 15 + (int) (($i + 1) / count($analysisData) * 15); $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_SLIDES, $progress, "슬라이드 {$screenNum} 생성 완료"); } if (empty($slidePaths)) { $tutorial->markFailed('슬라이드 생성에 실패했습니다'); return; } $tutorial->update(['slides_data' => [ 'slide_paths' => $slidePaths, 'durations' => $durations, 'scenes' => $scenes, ]]); $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_SLIDES, 30, '슬라이드 생성 완료'); // === Step 3: TTS 나레이션 생성 (50%) === $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_TTS, 35, '나레이션 생성 중...'); $narrationPaths = $tts->synthesizeScenes($scenes, $workDir); $totalCost += 0.01; // TTS $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_TTS, 50, '나레이션 생성 완료'); // 실제 나레이션 길이 측정 + duration 보정 $narrationDurations = []; foreach ($narrationPaths as $sceneNum => $path) { $actualDuration = $videoAssembly->getAudioDuration($path); if ($actualDuration > 0) { $narrationDurations[$sceneNum] = $actualDuration; // 나레이션이 슬라이드보다 길면 duration 보정 foreach ($durations as $di => &$dur) { if (($scenes[$di]['scene_number'] ?? 0) === $sceneNum) { if ($actualDuration > $dur) { $dur = ceil($actualDuration) + 1; $scenes[$di]['duration'] = $dur; } } } unset($dur); } } // 나레이션 합치기 $concatNarrationPath = "{$workDir}/narration_full.mp3"; $narrationPath = $videoAssembly->concatNarrations($narrationPaths, $scenes, $concatNarrationPath); // === Step 4: BGM 생성 (65%) === $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_BGM, 55, 'BGM 생성 중...'); $totalDuration = array_sum($durations); $bgmPath = "{$workDir}/bgm.mp3"; // 튜토리얼용 차분한 BGM $bgmResult = $bgm->generateWithLyria('calm, instructional, soft background', $totalDuration + 5, $bgmPath); if (! $bgmResult) { $bgmResult = $bgm->select('calm', $bgmPath); } if (! $bgmResult) { $bgmResult = $bgm->generateAmbient('calm', $totalDuration + 5, $bgmPath); } $totalCost += 0.06; // Lyria $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_BGM, 65, 'BGM 생성 완료'); // === Step 5: 최종 합성 (80%) === $tutorial->updateProgress(TutorialVideo::STATUS_ASSEMBLING, 70, '영상 합성 중...'); // ASS 자막 생성 $subtitlePath = "{$workDir}/subtitle.ass"; $videoAssembly->generateAssSubtitle($scenes, $subtitlePath, $narrationDurations); // 최종 MP4 합성 $finalOutputPath = "{$workDir}/final_tutorial.mp4"; $result = $tutorialAssembly->assembleFromImages( $slidePaths, $durations, $narrationPath, $bgmResult, $subtitlePath, $finalOutputPath ); if (! $result || ! file_exists($finalOutputPath)) { $tutorial->markFailed('영상 합성에 실패했습니다'); return; } $tutorial->updateProgress(TutorialVideo::STATUS_ASSEMBLING, 80, '영상 합성 완료'); // === Step 6: GCS 업로드 (95%) === $tutorial->updateProgress(TutorialVideo::STATUS_ASSEMBLING, 85, 'GCS 업로드 중...'); $gcsPath = null; if ($gcs->isAvailable()) { $objectName = "tutorials/{$tutorial->tenant_id}/{$tutorial->id}/tutorial_" . date('Ymd_His') . '.mp4'; $gcsPath = $gcs->upload($finalOutputPath, $objectName); } $tutorial->updateProgress(TutorialVideo::STATUS_ASSEMBLING, 95, '업로드 완료'); // === Step 7: 완료 (100%) === $tutorial->update([ 'status' => TutorialVideo::STATUS_COMPLETED, 'progress' => 100, 'current_step' => '완료', 'output_path' => $finalOutputPath, 'gcs_path' => $gcsPath, 'cost_usd' => $totalCost, ]); Log::info('TutorialVideoJob: 완료', [ 'id' => $tutorial->id, 'output' => $finalOutputPath, 'gcs' => $gcsPath, 'cost' => $totalCost, ]); } catch (\Exception $e) { Log::error('TutorialVideoJob: 예외 발생', [ 'id' => $tutorial->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); $tutorial->markFailed($e->getMessage()); } } }