- 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>
256 lines
9.4 KiB
PHP
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());
|
|
}
|
|
}
|
|
}
|