feat:튜토리얼 영상 멀티스텝 개선 (8초 → 30초~2분)

- ScreenAnalysisService: Gemini 프롬프트를 멀티스텝(3~5 steps) 출력으로 변경 + 하위 호환 fallback
- SlideAnnotationService: 스포트라이트 효과(annotateSlideWithSpotlight), 인트로/아웃트로 슬라이드 생성
- TutorialVideoJob: screen→steps 중첩 루프 + 인트로/아웃트로 씬 추가
- index.blade.php: 단계별 나레이션 편집 UI + 예상 시간 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-15 16:36:56 +09:00
parent 973ab6c95b
commit 46f1577d65
4 changed files with 516 additions and 76 deletions

View File

@@ -78,6 +78,12 @@ public function handle(
$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 분석 완료');
@@ -90,41 +96,84 @@ public function handle(
$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;
}
$outputSlide = "{$workDir}/slide_{$screenNum}.png";
foreach ($steps as $step) {
$globalStepNum++;
$stepNum = $step['step_number'] ?? $globalStepNum;
$sceneNumber = $screenNum * 100 + $stepNum;
$result = $slideAnnotation->annotateSlide(
$imagePath,
$screen['ui_elements'] ?? [],
$screenNum,
$screen['narration'] ?? "화면 {$screenNum}",
$outputSlide
);
$outputSlide = "{$workDir}/slide_{$sceneNumber}.png";
if ($result) {
$slidePaths[] = $result;
$duration = $screen['duration'] ?? 8;
$durations[] = $duration;
$scenes[] = [
'scene_number' => $screenNum,
'narration' => $screen['narration'] ?? '',
'duration' => $duration,
];
$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) * 15);
$tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_SLIDES, $progress, "슬라이드 {$screenNum} 생성 완료");
$progress = 15 + (int) (($i + 1) / count($analysisData) * 13);
$tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_SLIDES, $progress, "화면 {$screenNum} 슬라이드 생성 완료");
}
if (empty($slidePaths)) {
// 아웃트로 슬라이드 생성
$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;
}