Files
sam-manage/app/Services/Video/ScreenAnalysisService.php
김보곤 edb832bc6a fix:슬라이드 폰트 크기 2배 확대 + 좌표 정확도 개선
- SlideAnnotationService: 아웃트로 메인텍스트 36→72, 서브텍스트 24→48
- ScreenAnalysisService: 그리드 오버레이 레터박스 제거 (전체 채움)
  → Gemini 좌표가 이미지 비율과 직접 매핑되어 스포트라이트 위치 정확도 향상

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 18:04:12 +09:00

393 lines
14 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;
}
// 그리드 오버레이가 있는 이미지 생성
$gridImagePath = $this->createGridOverlay($imagePath);
$analyzeImagePath = $gridImagePath ?: $imagePath;
$imageData = base64_encode(file_get_contents($analyzeImagePath));
$mimeType = mime_content_type($analyzeImagePath) ?: 'image/png';
$prompt = <<<PROMPT
당신은 SAM(Smart Automation Management) 시스템의 사용자 매뉴얼 작성 전문가입니다.
이 스크린샷은 SAM 시스템의 화면입니다. (화면 {$screenNumber}/{$totalScreens})
이미지는 **1920x1080 해상도로 정규화**되어 있습니다.
이미지에는 **10x10 그리드 오버레이**가 표시되어 있습니다.
- 가로축: 0~9 (각 192px 간격, 라벨에 0=0, 1=100, ..., 9=900 표시)
- 세로축: A~J (각 108px 간격, 라벨에 A=0, B=100, ..., J=900 표시)
- 교차점에 빨간 십자 마커가 있습니다.
이 그리드와 좌표 라벨을 참고하여 각 UI 영역의 정확한 위치를 파악하세요.
이 화면을 분석하고, 사용자가 따라할 수 있는 **3~5개 단계(steps)**로 나누어 튜토리얼을 작성하세요.
각 단계마다 화면에서 집중해야 할 UI 영역(focused_element)을 지정합니다.
=== 분석 요구사항 ===
1. 화면의 주요 목적/기능을 파악
2. 사용자가 수행할 작업을 3~5단계로 분해
3. 각 단계마다 집중할 UI 영역의 위치와 크기를 0~1000 정수 좌표로 표시
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": 100,
"y": 300,
"w": 200,
"h": 400
},
"duration": 6
}
]
}
=== focused_element 좌표 (0~1000 정수 좌표계) ===
좌표는 이미지의 좌상단(0,0) ~ 우하단(1000,1000) 범위의 **정수**입니다.
그리드 오버레이를 활용하여 정확한 좌표를 지정하세요.
- x: 영역 좌측 가장자리의 가로 위치 (0=왼쪽 끝, 1000=오른쪽 끝)
- y: 영역 상단 가장자리의 세로 위치 (0=위쪽 끝, 1000=아래쪽 끝)
- w: 영역의 너비 (0~1000)
- h: 영역의 높이 (0~1000)
=== 그리드 참조 가이드 ===
- 가로축 0~9: 0=가로 0~100, 1=100~200, ..., 9=900~1000
- 세로축 A~J: A=세로 0~100, B=100~200, ..., J=900~1000
- 예) 그리드 "0A~1C" 영역 → x=0, y=0, w=200, h=300
- 예) 그리드 "2B~8H" 영역 → x=200, y=100, w=600, h=600
=== 좌표 정확도 예시 (ERP/관리 시스템 화면 기준) ===
- 왼쪽 사이드바 메뉴: x=0, y=50, w=150, h=950
- 상단 헤더/네비게이션: x=0, y=0, w=1000, h=60
- 메인 콘텐츠의 데이터 테이블: x=180, y=150, w=800, h=600
- 상단 검색/필터 영역: x=180, y=80, w=800, h=80
- 우측 상단 버튼 그룹: x=700, y=80, w=280, h=50
- 탭 메뉴: x=180, y=100, w=600, h=40
- 모달/팝업: x=250, y=200, w=500, h=500
=== 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 ($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;
}
}
/**
* 0~1000 정수 좌표 → 0~1 비율로 변환
*
* Gemini가 반환한 0~1000 좌표를 하류 파이프라인이 사용하는 0~1 비율로 정규화
*/
private 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'];
// 0~1000 범위인지 확인 (어떤 좌표든 1보다 크면 0~1000 좌표계)
$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);
}
// 범위 클램핑
$el['x'] = max(0, min(1, $el['x']));
$el['y'] = max(0, min(1, $el['y']));
$el['w'] = max(0.02, min(1 - $el['x'], $el['w']));
$el['h'] = max(0.02, min(1 - $el['y'], $el['h']));
}
unset($step, $el);
}
}