diff --git a/app/Jobs/TutorialVideoJob.php b/app/Jobs/TutorialVideoJob.php index 3e709ce9..c300da82 100644 --- a/app/Jobs/TutorialVideoJob.php +++ b/app/Jobs/TutorialVideoJob.php @@ -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; } diff --git a/app/Services/Video/ScreenAnalysisService.php b/app/Services/Video/ScreenAnalysisService.php index 7d5b29ca..fa4d4b11 100644 --- a/app/Services/Video/ScreenAnalysisService.php +++ b/app/Services/Video/ScreenAnalysisService.php @@ -33,15 +33,21 @@ public function analyzeScreenshots(array $imagePaths): array $result = $this->analyzeSingleScreen($imagePath, $screenNumber, count($imagePaths)); if ($result) { + $result = $this->ensureStepsFormat($result); $results[] = $result; } else { Log::warning("ScreenAnalysis: 스크린샷 {$screenNumber} 분석 실패, 기본값 사용"); $results[] = [ 'screen_number' => $screenNumber, 'title' => "화면 {$screenNumber}", - 'narration' => "이 화면에서는 주요 기능을 확인할 수 있습니다.", - 'ui_elements' => [], - 'duration' => 8, + 'steps' => [ + [ + 'step_number' => 1, + 'narration' => '이 화면에서는 주요 기능을 확인할 수 있습니다.', + 'focused_element' => null, + 'duration' => 6, + ], + ], ]; } } @@ -49,6 +55,63 @@ public function analyzeScreenshots(array $imagePaths): array return $results; } + /** + * 기존 형식(narration/ui_elements) → steps[] 형식으로 변환 (하위 호환) + * Job에서 DB 캐시된 기존 형식 변환에도 사용 + */ + public function ensureStepsFormatPublic(array $screen): array + { + return $this->ensureStepsFormat($screen); + } + + /** + * 기존 형식(narration/ui_elements) → steps[] 형식으로 변환 (하위 호환) + */ + private function ensureStepsFormat(array $screen): array + { + if (! empty($screen['steps'])) { + return $screen; + } + + $narration = $screen['narration'] ?? '이 화면의 기능을 확인할 수 있습니다.'; + $uiElements = $screen['ui_elements'] ?? []; + $duration = $screen['duration'] ?? 8; + + if (empty($uiElements)) { + $screen['steps'] = [ + [ + 'step_number' => 1, + 'narration' => $narration, + 'focused_element' => null, + 'duration' => $duration, + ], + ]; + } else { + $steps = []; + $sentences = preg_split('/(?<=[.。])\s*/u', $narration, -1, PREG_SPLIT_NO_EMPTY); + foreach ($uiElements as $i => $el) { + $steps[] = [ + 'step_number' => $i + 1, + 'narration' => $sentences[$i] ?? $narration, + 'focused_element' => [ + 'type' => $el['type'] ?? 'other', + 'label' => $el['label'] ?? '', + 'x' => $el['x'] ?? 0.5, + 'y' => $el['y'] ?? 0.5, + 'w' => 0.2, + 'h' => 0.15, + ], + 'duration' => max(5, (int) ($duration / count($uiElements))), + ]; + } + $screen['steps'] = $steps; + } + + unset($screen['narration'], $screen['ui_elements'], $screen['duration']); + + return $screen; + } + /** * 단일 스크린샷 분석 */ @@ -67,40 +130,53 @@ private function analyzeSingleScreen(string $imagePath, int $screenNumber, int $ 이 스크린샷은 SAM 시스템의 화면입니다. (화면 {$screenNumber}/{$totalScreens}) -이 화면을 분석하고 사용자 튜토리얼 나레이션을 작성하세요. +이 화면을 분석하고, 사용자가 따라할 수 있는 **3~5개 단계(steps)**로 나누어 튜토리얼을 작성하세요. +각 단계마다 화면에서 집중해야 할 UI 영역(focused_element)을 지정합니다. === 분석 요구사항 === 1. 화면의 주요 목적/기능을 파악 -2. 주요 UI 요소 식별 (버튼, 입력폼, 테이블, 메뉴, 탭 등) -3. 각 UI 요소의 대략적인 위치를 화면 비율(0~1)로 표시 -4. 사용자가 이 화면에서 수행할 작업 순서를 안내하는 나레이션 작성 +2. 사용자가 수행할 작업을 3~5단계로 분해 +3. 각 단계마다 집중할 UI 영역의 위치와 크기를 비율(0~1)로 표시 +4. 단계별 나레이션은 독립적인 문장으로 작성 === 나레이션 작성 규칙 === - 친근한 존댓말 사용 (예: "~하실 수 있습니다", "~을 클릭하세요") - TTS로 읽을 것이므로 이모지/특수기호 금지 - 순수 한글 텍스트만 작성 - 문장은 마침표로 끝내기 -- 전체 나레이션은 50~120자 (약 5~10초 분량) +- 각 단계 나레이션은 30~80자 (약 3~7초 분량) 반드시 아래 JSON 형식으로만 응답하세요: { "screen_number": {$screenNumber}, "title": "이 화면의 제목 (10자 이내)", - "narration": "사용자 안내 나레이션 (50~120자)", - "ui_elements": [ + "steps": [ { - "type": "button|input|table|menu|tab|label|icon|other", - "label": "UI 요소에 표시된 텍스트", - "x": 0.5, - "y": 0.3, - "description": "이 요소의 기능 설명 (20자 이내)" + "step_number": 1, + "narration": "이 단계에서 할 일을 안내하는 나레이션 (30~80자)", + "focused_element": { + "type": "button|input|table|menu|tab|sidebar|header|form|other", + "label": "UI 영역에 표시된 텍스트 (10자 이내)", + "x": 0.1, + "y": 0.3, + "w": 0.2, + "h": 0.4 + }, + "duration": 6 } - ], - "duration": 8 + ] } -ui_elements의 x, y는 화면 좌상단(0,0) ~ 우하단(1,1) 기준 비율 좌표입니다. -duration은 이 화면을 보여줄 권장 시간(초)입니다 (5~12초). +=== focused_element 좌표 설명 === +- x, y: 영역의 좌상단 꼭짓점 위치 (화면 비율 0~1) +- w, h: 영역의 너비와 높이 (화면 비율 0~1) +- 예) 왼쪽 사이드바: x=0, y=0.1, w=0.15, h=0.8 +- 예) 상단 헤더: x=0, y=0, w=1.0, h=0.08 +- 예) 메인 테이블: x=0.2, y=0.2, w=0.7, h=0.6 + +=== duration 규칙 === +- 각 단계는 5~8초 (나레이션 길이에 비례) +- 전체 steps의 duration 합계가 20~40초가 되도록 조절 PROMPT; $parts = [ diff --git a/app/Services/Video/SlideAnnotationService.php b/app/Services/Video/SlideAnnotationService.php index 9ff57908..7e51dd24 100644 --- a/app/Services/Video/SlideAnnotationService.php +++ b/app/Services/Video/SlideAnnotationService.php @@ -227,6 +227,299 @@ private function drawStepBadge(\GdImage $canvas, int $stepNumber): void } } + /** + * 스포트라이트 효과가 적용된 슬라이드 생성 + * + * 원본 이미지를 dim 처리한 후, focused_element 영역만 밝게 복원 + */ + public function annotateSlideWithSpotlight( + string $imagePath, + ?array $focusedElement, + int $stepNumber, + int $totalSteps, + string $caption, + string $outputPath + ): ?string { + if (! file_exists($imagePath)) { + Log::error("SlideAnnotation: 원본 이미지 없음 - {$imagePath}"); + return null; + } + + try { + $dir = dirname($outputPath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $source = $this->loadImage($imagePath); + if (! $source) { + return null; + } + + $srcW = imagesx($source); + $srcH = imagesy($source); + + // 16:9 캔버스 생성 + $canvas = imagecreatetruecolor(self::TARGET_WIDTH, self::TARGET_HEIGHT); + $black = imagecolorallocate($canvas, 0, 0, 0); + imagefill($canvas, 0, 0, $black); + + // 캡션 영역 제외한 영역에 이미지 배치 + $availH = self::TARGET_HEIGHT - self::CAPTION_HEIGHT; + $scale = min(self::TARGET_WIDTH / $srcW, $availH / $srcH); + $newW = (int) ($srcW * $scale); + $newH = (int) ($srcH * $scale); + $offsetX = (int) ((self::TARGET_WIDTH - $newW) / 2); + $offsetY = (int) (($availH - $newH) / 2); + + // 원본 이미지 배치 + imagecopyresampled($canvas, $source, $offsetX, $offsetY, 0, 0, $newW, $newH, $srcW, $srcH); + + // 강한 dim 오버레이 (alpha 60 → 상당히 어둡게) + $dimOverlay = imagecolorallocatealpha($canvas, 0, 0, 0, 60); + imagefilledrectangle($canvas, $offsetX, $offsetY, $offsetX + $newW, $offsetY + $newH, $dimOverlay); + + // 스포트라이트: focused_element 영역만 원본 밝기로 복원 + if ($focusedElement) { + $fx = $focusedElement['x'] ?? 0.5; + $fy = $focusedElement['y'] ?? 0.5; + $fw = $focusedElement['w'] ?? 0.2; + $fh = $focusedElement['h'] ?? 0.15; + + // 비율 → 원본 이미지 좌표 + $srcFx = (int) ($fx * $srcW); + $srcFy = (int) ($fy * $srcH); + $srcFw = (int) ($fw * $srcW); + $srcFh = (int) ($fh * $srcH); + + // 비율 → 캔버스 좌표 + $canvasFx = $offsetX + (int) ($fx * $newW); + $canvasFy = $offsetY + (int) ($fy * $newH); + $canvasFw = (int) ($fw * $newW); + $canvasFh = (int) ($fh * $newH); + + // 경계 보정 + $canvasFw = min($canvasFw, self::TARGET_WIDTH - $canvasFx); + $canvasFh = min($canvasFh, $availH - $canvasFy + $offsetY); + + // 원본 이미지에서 해당 영역을 다시 복사 (dim 위에 덮어씌움) + if ($canvasFw > 0 && $canvasFh > 0) { + imagecopyresampled( + $canvas, $source, + $canvasFx, $canvasFy, + $srcFx, $srcFy, + $canvasFw, $canvasFh, + $srcFw, $srcFh + ); + + // 빨간 테두리 + $red = imagecolorallocate($canvas, 239, 68, 68); + $borderW = 3; + for ($b = 0; $b < $borderW; $b++) { + imagerectangle( + $canvas, + $canvasFx - $b, $canvasFy - $b, + $canvasFx + $canvasFw + $b, $canvasFy + $canvasFh + $b, + $red + ); + } + + // 번호 마커 (좌상단) + $this->drawNumberMarker($canvas, $stepNumber, $canvasFx - 5, $canvasFy - 5); + } + } + + imagedestroy($source); + + // 하단 캡션 바 + $this->drawCaptionBar($canvas, $caption, $stepNumber); + + // 상단 STEP 배지 (진행 표시) + $this->drawStepProgressBadge($canvas, $stepNumber, $totalSteps); + + // PNG로 저장 + imagepng($canvas, $outputPath, 6); + imagedestroy($canvas); + + Log::info("SlideAnnotation: 스포트라이트 슬라이드 생성 완료", ['output' => $outputPath]); + + return $outputPath; + } catch (\Exception $e) { + Log::error("SlideAnnotation: 스포트라이트 예외 발생", ['error' => $e->getMessage()]); + return null; + } + } + + /** + * 인트로 슬라이드 생성 + */ + public function createIntroSlide(string $title, string $outputPath): ?string + { + try { + $dir = dirname($outputPath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $canvas = imagecreatetruecolor(self::TARGET_WIDTH, self::TARGET_HEIGHT); + + // 인디고 그라데이션 배경 + $topColor = [49, 46, 129]; // indigo-900 + $bottomColor = [79, 70, 229]; // indigo-600 + for ($y = 0; $y < self::TARGET_HEIGHT; $y++) { + $ratio = $y / self::TARGET_HEIGHT; + $r = (int) ($topColor[0] + ($bottomColor[0] - $topColor[0]) * $ratio); + $g = (int) ($topColor[1] + ($bottomColor[1] - $topColor[1]) * $ratio); + $b = (int) ($topColor[2] + ($bottomColor[2] - $topColor[2]) * $ratio); + $color = imagecolorallocate($canvas, $r, $g, $b); + imageline($canvas, 0, $y, self::TARGET_WIDTH, $y, $color); + } + + $white = imagecolorallocate($canvas, 255, 255, 255); + $lightWhite = imagecolorallocate($canvas, 200, 200, 220); + + if (file_exists($this->fontPath)) { + // 타이틀 + $titleSize = 42; + $bbox = imagettfbbox($titleSize, 0, $this->fontPath, $title); + $tw = $bbox[2] - $bbox[0]; + $tx = (int) ((self::TARGET_WIDTH - $tw) / 2); + imagettftext($canvas, $titleSize, 0, $tx, 460, $white, $this->fontPath, $title); + + // 서브타이틀 + $sub = 'SAM 사용법을 안내합니다.'; + $subSize = 24; + $bbox2 = imagettfbbox($subSize, 0, $this->fontPath, $sub); + $tw2 = $bbox2[2] - $bbox2[0]; + $tx2 = (int) ((self::TARGET_WIDTH - $tw2) / 2); + imagettftext($canvas, $subSize, 0, $tx2, 530, $lightWhite, $this->fontPath, $sub); + + // 구분선 + $lineColor = imagecolorallocatealpha($canvas, 255, 255, 255, 80); + imagefilledrectangle($canvas, (self::TARGET_WIDTH / 2) - 60, 560, (self::TARGET_WIDTH / 2) + 60, 563, $lineColor); + } else { + imagestring($canvas, 5, self::TARGET_WIDTH / 2 - 100, 450, $title, $white); + } + + imagepng($canvas, $outputPath, 6); + imagedestroy($canvas); + + return $outputPath; + } catch (\Exception $e) { + Log::error("SlideAnnotation: 인트로 슬라이드 생성 실패", ['error' => $e->getMessage()]); + return null; + } + } + + /** + * 아웃트로 슬라이드 생성 + */ + public function createOutroSlide(string $title, string $outputPath): ?string + { + try { + $dir = dirname($outputPath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $canvas = imagecreatetruecolor(self::TARGET_WIDTH, self::TARGET_HEIGHT); + + // 진한 그라데이션 배경 + $topColor = [30, 27, 75]; // indigo-950 + $bottomColor = [49, 46, 129]; // indigo-900 + for ($y = 0; $y < self::TARGET_HEIGHT; $y++) { + $ratio = $y / self::TARGET_HEIGHT; + $r = (int) ($topColor[0] + ($bottomColor[0] - $topColor[0]) * $ratio); + $g = (int) ($topColor[1] + ($bottomColor[1] - $topColor[1]) * $ratio); + $b = (int) ($topColor[2] + ($bottomColor[2] - $topColor[2]) * $ratio); + $color = imagecolorallocate($canvas, $r, $g, $b); + imageline($canvas, 0, $y, self::TARGET_WIDTH, $y, $color); + } + + $white = imagecolorallocate($canvas, 255, 255, 255); + $lightWhite = imagecolorallocate($canvas, 180, 180, 200); + + if (file_exists($this->fontPath)) { + $mainText = '이상으로 안내를 마칩니다.'; + $mainSize = 36; + $bbox = imagettfbbox($mainSize, 0, $this->fontPath, $mainText); + $tw = $bbox[2] - $bbox[0]; + $tx = (int) ((self::TARGET_WIDTH - $tw) / 2); + imagettftext($canvas, $mainSize, 0, $tx, 470, $white, $this->fontPath, $mainText); + + $subText = '감사합니다.'; + $subSize = 24; + $bbox2 = imagettfbbox($subSize, 0, $this->fontPath, $subText); + $tw2 = $bbox2[2] - $bbox2[0]; + $tx2 = (int) ((self::TARGET_WIDTH - $tw2) / 2); + imagettftext($canvas, $subSize, 0, $tx2, 530, $lightWhite, $this->fontPath, $subText); + } else { + imagestring($canvas, 5, self::TARGET_WIDTH / 2 - 100, 470, 'Thank you', $white); + } + + imagepng($canvas, $outputPath, 6); + imagedestroy($canvas); + + return $outputPath; + } catch (\Exception $e) { + Log::error("SlideAnnotation: 아웃트로 슬라이드 생성 실패", ['error' => $e->getMessage()]); + return null; + } + } + + /** + * 번호 마커 그리기 (스포트라이트용) + */ + private function drawNumberMarker(\GdImage $canvas, int $number, int $cx, int $cy): void + { + $red = imagecolorallocate($canvas, 239, 68, 68); + $white = imagecolorallocate($canvas, 255, 255, 255); + $r = self::MARKER_RADIUS; + + imagefilledellipse($canvas, $cx, $cy, $r * 2, $r * 2, $red); + imageellipse($canvas, $cx, $cy, $r * 2, $r * 2, $white); + + $num = (string) $number; + $fontSize = 14; + + if (file_exists($this->fontPath)) { + $bbox = imagettfbbox($fontSize, 0, $this->fontPath, $num); + $tw = $bbox[2] - $bbox[0]; + $th = $bbox[1] - $bbox[7]; + imagettftext($canvas, $fontSize, 0, $cx - (int) ($tw / 2), $cy + (int) ($th / 2), $white, $this->fontPath, $num); + } else { + imagestring($canvas, 5, $cx - 4, $cy - 7, $num, $white); + } + } + + /** + * 상단 STEP 진행 배지 (STEP 1/5 형식) + */ + private function drawStepProgressBadge(\GdImage $canvas, int $stepNumber, int $totalSteps): void + { + $badgeBg = imagecolorallocate($canvas, 79, 70, 229); + $white = imagecolorallocate($canvas, 255, 255, 255); + + $bx = 30; + $by = 20; + $bw = 170; + $bh = 44; + + imagefilledrectangle($canvas, $bx, $by, $bx + $bw, $by + $bh, $badgeBg); + + $text = "STEP {$stepNumber}/{$totalSteps}"; + + if (file_exists($this->fontPath)) { + $fontSize = 18; + $bbox = imagettfbbox($fontSize, 0, $this->fontPath, $text); + $tw = $bbox[2] - $bbox[0]; + $tx = $bx + (int) (($bw - $tw) / 2); + imagettftext($canvas, $fontSize, 0, $tx, $by + 32, $white, $this->fontPath, $text); + } else { + imagestring($canvas, 5, $bx + 20, $by + 12, $text, $white); + } + } + /** * 이미지 로드 (형식 자동 감지) */ diff --git a/resources/views/video/tutorial/index.blade.php b/resources/views/video/tutorial/index.blade.php index 41fc535e..807809c9 100644 --- a/resources/views/video/tutorial/index.blade.php +++ b/resources/views/video/tutorial/index.blade.php @@ -263,9 +263,11 @@ className="px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-in const [editedAnalysis, setEditedAnalysis] = useState(analysis); const [title, setTitle] = useState('SAM 사용자 매뉴얼'); - const updateNarration = (index, newNarration) => { + const updateStepNarration = (screenIndex, stepIndex, newNarration) => { const updated = [...editedAnalysis]; - updated[index] = { ...updated[index], narration: newNarration }; + const steps = [...(updated[screenIndex].steps || [])]; + steps[stepIndex] = { ...steps[stepIndex], narration: newNarration }; + updated[screenIndex] = { ...updated[screenIndex], steps }; setEditedAnalysis(updated); }; @@ -273,11 +275,24 @@ className="px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-in onConfirm(editedAnalysis, title); }; + // 전체 예상 시간 계산 + const totalDuration = editedAnalysis.reduce((sum, screen) => { + const screenDur = (screen.steps || []).reduce((s, step) => s + (step.duration || 6), 0); + return sum + screenDur; + }, 6); // +6 for intro+outro + + const totalSteps = editedAnalysis.reduce((sum, screen) => sum + (screen.steps || []).length, 0); + return (
나레이션을 편집하고 영상을 생성하세요
+단계별 나레이션을 편집하고 영상을 생성하세요
+