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 } else { // DB 캐시된 기존 형식도 steps[] 형식으로 변환 $analysisData = array_map( fn ($screen) => $screenAnalysis->ensureStepsFormatPublic($screen), $analysisData ); } $tutorial->updateProgress(TutorialVideo::STATUS_ANALYZING, 10, 'AI 분석 완료'); // === Step 2: 어노테이션 슬라이드 생성 (30%) === $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_SLIDES, 15, '슬라이드 생성 중...'); $slidePaths = []; $durations = []; $scenes = []; $screenshots = $tutorial->screenshots ?? []; // 전체 step 수 계산 (진행 배지용) $totalSteps = 0; foreach ($analysisData as $screen) { $totalSteps += count($screen['steps'] ?? []); } // 인트로 슬라이드 생성 $introPath = "{$workDir}/slide_intro.png"; $introTitle = $tutorial->title ?? 'SAM 사용자 매뉴얼'; $introResult = $slideAnnotation->createIntroSlide($introTitle, $introPath); if ($introResult) { $slidePaths[] = $introResult; $durations[] = 3; $scenes[] = [ 'scene_number' => 1, 'narration' => "{$introTitle}. SAM 사용법을 안내합니다.", 'duration' => 3, ]; } // 중첩 루프: screen → steps $globalStepNum = 0; foreach ($analysisData as $i => $screen) { $screenNum = $screen['screen_number'] ?? ($i + 1); $imagePath = $screenshots[$i] ?? null; $steps = $screen['steps'] ?? []; if (! $imagePath || ! file_exists($imagePath)) { Log::warning("TutorialVideoJob: 스크린샷 없음 - index {$i}"); continue; } foreach ($steps as $step) { $globalStepNum++; $stepNum = $step['step_number'] ?? $globalStepNum; $sceneNumber = $screenNum * 100 + $stepNum; $outputSlide = "{$workDir}/slide_{$sceneNumber}.png"; $result = $slideAnnotation->annotateSlideWithSpotlight( $imagePath, $step['focused_element'] ?? null, $globalStepNum, $totalSteps, $step['narration'] ?? "단계 {$stepNum}", $outputSlide ); if ($result) { $slidePaths[] = $result; $duration = $step['duration'] ?? 6; $durations[] = $duration; $scenes[] = [ 'scene_number' => $sceneNumber, 'narration' => $step['narration'] ?? '', 'duration' => $duration, ]; } } $progress = 15 + (int) (($i + 1) / count($analysisData) * 13); $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_SLIDES, $progress, "화면 {$screenNum} 슬라이드 생성 완료"); } // 아웃트로 슬라이드 생성 $outroPath = "{$workDir}/slide_outro.png"; $outroResult = $slideAnnotation->createOutroSlide($introTitle, $outroPath); if ($outroResult) { $slidePaths[] = $outroResult; $durations[] = 3; $scenes[] = [ 'scene_number' => 999, 'narration' => '이상으로 안내를 마칩니다. 감사합니다.', 'duration' => 3, ]; } if (count($slidePaths) <= 2) { // 인트로+아웃트로만 있으면 실패 $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()); } } }