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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 로드 (형식 자동 감지)
|
||||
*/
|
||||
|
||||
@@ -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 (
|
||||
<div className="fade-in max-w-3xl mx-auto">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">AI 분석 결과 확인</h2>
|
||||
<p className="text-gray-500 mt-2">나레이션을 편집하고 영상을 생성하세요</p>
|
||||
<p className="text-gray-500 mt-2">단계별 나레이션을 편집하고 영상을 생성하세요</p>
|
||||
<div className="mt-2 inline-flex items-center gap-3 px-4 py-1.5 bg-indigo-50 rounded-full text-sm">
|
||||
<span className="text-indigo-700 font-medium">총 {totalSteps}단계</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="text-indigo-600">예상 {totalDuration}초 ({Math.floor(totalDuration/60)}분 {totalDuration%60}초)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
@@ -291,42 +306,49 @@ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editedAnalysis.map((screen, i) => (
|
||||
<div key={i} className="analysis-card">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="step-badge step-active">{screen.screen_number || i + 1}</span>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{screen.title}</div>
|
||||
<div className="text-xs text-gray-400">표시 시간: {screen.duration}초</div>
|
||||
</div>
|
||||
</div>
|
||||
{editedAnalysis.map((screen, i) => {
|
||||
const steps = screen.steps || [];
|
||||
const screenDuration = steps.reduce((s, step) => s + (step.duration || 6), 0);
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">나레이션 (편집 가능)</label>
|
||||
<textarea
|
||||
value={screen.narration}
|
||||
onChange={(e) => updateNarration(i, e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
|
||||
/>
|
||||
<div className="text-xs text-gray-400 mt-1">{screen.narration?.length || 0}자</div>
|
||||
</div>
|
||||
|
||||
{screen.ui_elements && screen.ui_elements.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 mb-1">인식된 UI 요소</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{screen.ui_elements.map((el, j) => (
|
||||
<span key={j} className="ui-element-tag">
|
||||
<span className="text-indigo-500 font-bold">{j + 1}</span>
|
||||
{el.label || el.type}
|
||||
</span>
|
||||
))}
|
||||
return (
|
||||
<div key={i} className="analysis-card">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="step-badge step-active">{screen.screen_number || i + 1}</span>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{screen.title}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{steps.length}단계 · 예상 {screenDuration}초
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="space-y-3 ml-2 border-l-2 border-indigo-100 pl-4">
|
||||
{steps.map((step, j) => (
|
||||
<div key={j} className="relative">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-indigo-100 text-indigo-700 text-xs font-bold">
|
||||
{step.step_number || j + 1}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
{step.focused_element?.label || `단계 ${j + 1}`}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300 ml-auto">{step.duration || 6}초</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={step.narration || ''}
|
||||
onChange={(e) => updateStepNarration(i, j, e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
|
||||
/>
|
||||
<div className="text-xs text-gray-400 mt-0.5 text-right">{(step.narration || '').length}자</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex gap-3 mt-6 justify-center">
|
||||
<button
|
||||
@@ -340,7 +362,7 @@ className="px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medi
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? '생성 시작 중...' : '영상 생성 시작'}
|
||||
{loading ? '생성 시작 중...' : `영상 생성 시작 (${totalSteps}단계, ~${totalDuration}초)`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user