- 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>
213 lines
6.9 KiB
PHP
213 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Video;
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class ScreenAnalysisService
|
|
{
|
|
private GeminiScriptService $gemini;
|
|
|
|
public function __construct(GeminiScriptService $gemini)
|
|
{
|
|
$this->gemini = $gemini;
|
|
}
|
|
|
|
/**
|
|
* 스크린샷 배열을 Gemini Vision으로 분석
|
|
*
|
|
* @param array $imagePaths 스크린샷 파일 경로 배열
|
|
* @return array 분석 결과 배열
|
|
*/
|
|
public function analyzeScreenshots(array $imagePaths): array
|
|
{
|
|
$results = [];
|
|
|
|
foreach ($imagePaths as $index => $imagePath) {
|
|
$screenNumber = $index + 1;
|
|
|
|
Log::info("ScreenAnalysis: 스크린샷 {$screenNumber}/" . count($imagePaths) . " 분석 시작", [
|
|
'path' => $imagePath,
|
|
]);
|
|
|
|
$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}",
|
|
'steps' => [
|
|
[
|
|
'step_number' => 1,
|
|
'narration' => '이 화면에서는 주요 기능을 확인할 수 있습니다.',
|
|
'focused_element' => null,
|
|
'duration' => 6,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 단일 스크린샷 분석
|
|
*/
|
|
private function analyzeSingleScreen(string $imagePath, int $screenNumber, int $totalScreens): ?array
|
|
{
|
|
if (! file_exists($imagePath)) {
|
|
Log::error("ScreenAnalysis: 파일 없음 - {$imagePath}");
|
|
return null;
|
|
}
|
|
|
|
$imageData = base64_encode(file_get_contents($imagePath));
|
|
$mimeType = mime_content_type($imagePath) ?: 'image/png';
|
|
|
|
$prompt = <<<PROMPT
|
|
당신은 SAM(Smart Automation Management) 시스템의 사용자 매뉴얼 작성 전문가입니다.
|
|
|
|
이 스크린샷은 SAM 시스템의 화면입니다. (화면 {$screenNumber}/{$totalScreens})
|
|
|
|
이 화면을 분석하고, 사용자가 따라할 수 있는 **3~5개 단계(steps)**로 나누어 튜토리얼을 작성하세요.
|
|
각 단계마다 화면에서 집중해야 할 UI 영역(focused_element)을 지정합니다.
|
|
|
|
=== 분석 요구사항 ===
|
|
1. 화면의 주요 목적/기능을 파악
|
|
2. 사용자가 수행할 작업을 3~5단계로 분해
|
|
3. 각 단계마다 집중할 UI 영역의 위치와 크기를 비율(0~1)로 표시
|
|
4. 단계별 나레이션은 독립적인 문장으로 작성
|
|
|
|
=== 나레이션 작성 규칙 ===
|
|
- 친근한 존댓말 사용 (예: "~하실 수 있습니다", "~을 클릭하세요")
|
|
- TTS로 읽을 것이므로 이모지/특수기호 금지
|
|
- 순수 한글 텍스트만 작성
|
|
- 문장은 마침표로 끝내기
|
|
- 각 단계 나레이션은 30~80자 (약 3~7초 분량)
|
|
|
|
반드시 아래 JSON 형식으로만 응답하세요:
|
|
{
|
|
"screen_number": {$screenNumber},
|
|
"title": "이 화면의 제목 (10자 이내)",
|
|
"steps": [
|
|
{
|
|
"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
|
|
}
|
|
]
|
|
}
|
|
|
|
=== 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 = [
|
|
['text' => $prompt],
|
|
[
|
|
'inlineData' => [
|
|
'mimeType' => $mimeType,
|
|
'data' => $imageData,
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->gemini->callGeminiWithParts($parts, 0.3, 2048);
|
|
|
|
if (! $result) {
|
|
return null;
|
|
}
|
|
|
|
$parsed = $this->gemini->parseJson($result);
|
|
|
|
if (! $parsed || ! isset($parsed['screen_number'])) {
|
|
Log::warning('ScreenAnalysis: JSON 파싱 실패 또는 형식 불일치', [
|
|
'result' => substr($result, 0, 300),
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// screen_number 강제 보정
|
|
$parsed['screen_number'] = $screenNumber;
|
|
|
|
return $parsed;
|
|
}
|
|
}
|