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; } // 그리드 오버레이가 있는 이미지 생성 $gridImagePath = $this->createGridOverlay($imagePath); $analyzeImagePath = $gridImagePath ?: $imagePath; $imageData = base64_encode(file_get_contents($analyzeImagePath)); $mimeType = mime_content_type($analyzeImagePath) ?: 'image/png'; $prompt = << $prompt], [ 'inlineData' => [ 'mimeType' => $mimeType, 'data' => $imageData, ], ], ]; $result = $this->gemini->callGeminiWithParts($parts, 0.3, 2048); // 그리드 이미지 정리 if ($gridImagePath && file_exists($gridImagePath)) { @unlink($gridImagePath); } 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; // 0~1000 좌표 → 0~1 비율로 변환 $this->normalizeCoordinates($parsed); return $parsed; } private const ANALYSIS_WIDTH = 1920; private const ANALYSIS_HEIGHT = 1080; /** * 스크린샷을 1920x1080으로 정규화 + 10x10 그리드 오버레이 생성 * * 일관된 해상도로 AI 좌표 추정 정확도를 높입니다. */ private function createGridOverlay(string $imagePath): ?string { try { $info = getimagesize($imagePath); if (! $info) { return null; } $source = match ($info[2]) { IMAGETYPE_PNG => imagecreatefrompng($imagePath), IMAGETYPE_JPEG => imagecreatefromjpeg($imagePath), IMAGETYPE_GIF => imagecreatefromgif($imagePath), IMAGETYPE_WEBP => imagecreatefromwebp($imagePath), default => null, }; if (! $source) { return null; } $srcW = imagesx($source); $srcH = imagesy($source); // 1920x1080 캔버스에 리사이즈 (전체 채움, 레터박스 없음) // 이미지를 캔버스 전체에 채워서 Gemini 좌표가 이미지 비율과 직접 매핑되도록 함 $canvas = imagecreatetruecolor(self::ANALYSIS_WIDTH, self::ANALYSIS_HEIGHT); imagecopyresampled($canvas, $source, 0, 0, 0, 0, self::ANALYSIS_WIDTH, self::ANALYSIS_HEIGHT, $srcW, $srcH); imagedestroy($source); Log::debug("ScreenAnalysis: 이미지 정규화 (전체 채움)", [ 'original' => "{$srcW}x{$srcH}", 'canvas' => self::ANALYSIS_WIDTH . 'x' . self::ANALYSIS_HEIGHT, ]); // 그리드 오버레이 (정규화된 캔버스 위에) $gridColor = imagecolorallocatealpha($canvas, 255, 0, 0, 100); $labelBg = imagecolorallocatealpha($canvas, 0, 0, 0, 70); $labelText = imagecolorallocate($canvas, 255, 255, 0); $tickColor = imagecolorallocate($canvas, 255, 100, 100); $cols = 10; $rows = 10; $cellW = self::ANALYSIS_WIDTH / $cols; // 192px $cellH = self::ANALYSIS_HEIGHT / $rows; // 108px $rowLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']; // 세로선 for ($i = 1; $i < $cols; $i++) { $x = (int) ($i * $cellW); imageline($canvas, $x, 0, $x, self::ANALYSIS_HEIGHT, $gridColor); } // 가로선 for ($j = 1; $j < $rows; $j++) { $y = (int) ($j * $cellH); imageline($canvas, 0, $y, self::ANALYSIS_WIDTH, $y, $gridColor); } // 라벨: 가로축 숫자 (상단) + 픽셀 좌표 for ($i = 0; $i < $cols; $i++) { $lx = (int) ($i * $cellW) + 4; $pxLabel = (string) ($i * 100); // 0, 100, 200, ... imagefilledrectangle($canvas, $lx - 2, 0, $lx + 28, 18, $labelBg); imagestring($canvas, 3, $lx, 2, "{$i}={$pxLabel}", $labelText); } // 라벨: 세로축 문자 (좌측) + 픽셀 좌표 for ($j = 0; $j < $rows; $j++) { $ly = (int) ($j * $cellH) + 4; $pxLabel = (string) ($j * 100); // 0, 100, 200, ... imagefilledrectangle($canvas, 0, $ly - 1, 42, $ly + 15, $labelBg); imagestring($canvas, 3, 2, $ly, "{$rowLabels[$j]}={$pxLabel}", $labelText); } // 교차점에 작은 십자 마커 (더 정밀한 참조점) for ($i = 1; $i < $cols; $i++) { for ($j = 1; $j < $rows; $j++) { $cx = (int) ($i * $cellW); $cy = (int) ($j * $cellH); imageline($canvas, $cx - 4, $cy, $cx + 4, $cy, $tickColor); imageline($canvas, $cx, $cy - 4, $cx, $cy + 4, $tickColor); } } $gridPath = $imagePath . '_grid.png'; imagepng($canvas, $gridPath, 6); imagedestroy($canvas); Log::debug("ScreenAnalysis: 그리드 오버레이 생성 완료", ['path' => $gridPath]); return $gridPath; } catch (\Exception $e) { Log::warning("ScreenAnalysis: 그리드 오버레이 생성 실패", ['error' => $e->getMessage()]); return null; } } // UI 요소 타입별 최소 크기 (0~1 비율) private const MIN_ELEMENT_SIZES = [ 'button' => ['w' => 0.06, 'h' => 0.04], 'input' => ['w' => 0.15, 'h' => 0.05], 'form' => ['w' => 0.25, 'h' => 0.15], 'table' => ['w' => 0.30, 'h' => 0.20], 'tab' => ['w' => 0.20, 'h' => 0.05], 'menu' => ['w' => 0.10, 'h' => 0.25], 'sidebar' => ['w' => 0.10, 'h' => 0.40], 'header' => ['w' => 0.40, 'h' => 0.06], 'other' => ['w' => 0.08, 'h' => 0.06], ]; private const SPOTLIGHT_PADDING = 0.015; // 전체 크기 대비 1.5% 패딩 /** * 0~1000 정수 좌표 → 0~1 비율로 변환 + 타입별 최소 크기 보정 + 패딩 * * Gemini가 반환한 0~1000 좌표를 하류 파이프라인이 사용하는 0~1 비율로 정규화 */ public function normalizeCoordinates(array &$parsed): void { if (empty($parsed['steps'])) { return; } foreach ($parsed['steps'] as &$step) { if (empty($step['focused_element'])) { continue; } $el = &$step['focused_element']; // Step 1: 0~1000 → 0~1 비율 변환 $isMilliCoords = ($el['x'] ?? 0) > 1 || ($el['y'] ?? 0) > 1 || ($el['w'] ?? 0) > 1 || ($el['h'] ?? 0) > 1; if ($isMilliCoords) { $el['x'] = round(($el['x'] ?? 0) / 1000, 4); $el['y'] = round(($el['y'] ?? 0) / 1000, 4); $el['w'] = round(($el['w'] ?? 200) / 1000, 4); $el['h'] = round(($el['h'] ?? 200) / 1000, 4); } // Step 2: 타입별 최소 크기 보정 (중심점 기준 확장) $type = $el['type'] ?? 'other'; $minSize = self::MIN_ELEMENT_SIZES[$type] ?? self::MIN_ELEMENT_SIZES['other']; if ($el['w'] < $minSize['w']) { $centerX = $el['x'] + $el['w'] / 2; $el['w'] = $minSize['w']; $el['x'] = $centerX - $el['w'] / 2; } if ($el['h'] < $minSize['h']) { $centerY = $el['y'] + $el['h'] / 2; $el['h'] = $minSize['h']; $el['y'] = $centerY - $el['h'] / 2; } // Step 3: 패딩 추가 (스포트라이트가 요소를 넉넉히 감싸도록) $pad = self::SPOTLIGHT_PADDING; $el['x'] -= $pad; $el['y'] -= $pad; $el['w'] += $pad * 2; $el['h'] += $pad * 2; // Step 4: 경계 클램핑 $el['x'] = max(0, min(1 - $minSize['w'], $el['x'])); $el['y'] = max(0, min(1 - $minSize['h'], $el['y'])); $el['w'] = max($minSize['w'], min(1 - $el['x'], $el['w'])); $el['h'] = max($minSize['h'], min(1 - $el['y'], $el['h'])); } unset($step, $el); } }