Files
sam-manage/app/Jobs/TutorialVideoJob.php
김보곤 768bc30a6d feat:사용자 매뉴얼 영상 자동 생성 기능 구현
- TutorialVideo 모델 (상태 관리, TenantScope)
- GeminiScriptService에 callGeminiWithParts() 멀티모달 지원 추가
- ScreenAnalysisService: Gemini Vision 스크린샷 AI 분석
- SlideAnnotationService: PHP GD 이미지 어노테이션 (마커, 캡션)
- TutorialAssemblyService: FFmpeg 이미지→영상 합성 (crossfade)
- TutorialVideoJob: 분석→슬라이드→TTS→BGM→합성 파이프라인
- TutorialVideoController: 업로드/분석/생성/상태/다운로드/이력 API
- React-in-Blade UI: 3단계 (업로드→분석확인→생성모니터링) + 이력

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:56:39 +09:00

256 lines
9.4 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\TutorialVideo;
use App\Services\GoogleCloudStorageService;
use App\Services\Video\BgmService;
use App\Services\Video\ScreenAnalysisService;
use App\Services\Video\SlideAnnotationService;
use App\Services\Video\TtsService;
use App\Services\Video\TutorialAssemblyService;
use App\Services\Video\VideoAssemblyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class TutorialVideoJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 900; // 15분
public int $tries = 1;
private int $tutorialVideoId;
public function __construct(int $tutorialVideoId)
{
$this->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());
}
}
}