- 각 step별 검증 결과 저장 (accurate/corrected + 원본 좌표) - 스크린 단위 검증 통계 저장 (정확/보정 수, 검증 시각) - 영상 완료 시 _output 메타데이터 저장 (경로, GCS, 비용, 슬라이드수, 총 재생시간)
324 lines
12 KiB
PHP
324 lines
12 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;
|
|
$this->onQueue('mng');
|
|
}
|
|
|
|
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 자막 불필요
|
|
|
|
// 최종 MP4 합성
|
|
$finalOutputPath = "{$workDir}/final_tutorial.mp4";
|
|
$result = $tutorialAssembly->assembleFromImages(
|
|
$slidePaths,
|
|
$durations,
|
|
$narrationPath,
|
|
$bgmResult,
|
|
null,
|
|
$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%) ===
|
|
// analysis_data에 영상 저장 경로 메타데이터 추가
|
|
$analysisWithMeta = $tutorial->analysis_data ?? $analysisData;
|
|
if (is_array($analysisWithMeta)) {
|
|
$analysisWithMeta['_output'] = [
|
|
'completed_at' => now()->toIso8601String(),
|
|
'output_path' => $finalOutputPath,
|
|
'gcs_path' => $gcsPath,
|
|
'cost_usd' => round($totalCost, 4),
|
|
'total_slides' => count($slidePaths),
|
|
'total_duration' => $totalDuration,
|
|
];
|
|
}
|
|
|
|
$tutorial->update([
|
|
'status' => TutorialVideo::STATUS_COMPLETED,
|
|
'progress' => 100,
|
|
'current_step' => '완료',
|
|
'output_path' => $finalOutputPath,
|
|
'gcs_path' => $gcsPath,
|
|
'cost_usd' => $totalCost,
|
|
'analysis_data' => $analysisWithMeta,
|
|
]);
|
|
|
|
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());
|
|
}
|
|
}
|
|
}
|