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;
}

View File

@@ -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 = [

View File

@@ -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);
}
}
/**
* 이미지 로드 (형식 자동 감지)
*/

View File

@@ -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}단계 &middot; 예상 {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>