fontPath = '/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf'; if (! file_exists($this->fontPath)) { $this->fontPath = '/usr/share/fonts/truetype/nanum/NanumGothic.ttf'; } } /** * 스크린샷 위에 시각적 어노테이션 추가 * * @param string $imagePath 원본 스크린샷 경로 * @param array $uiElements UI 요소 배열 [{type, label, x, y, description}] * @param int $stepNumber 현재 스텝 번호 * @param string $caption 하단 캡션 텍스트 * @param string $outputPath 출력 파일 경로 * @return string|null 성공 시 출력 경로 */ public function annotateSlide( string $imagePath, array $uiElements, int $stepNumber, 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); imagedestroy($source); // 반투명 오버레이 (약간 dim) $overlay = imagecolorallocatealpha($canvas, 0, 0, 0, 100); // 약간 어둡게 imagefilledrectangle($canvas, $offsetX, $offsetY, $offsetX + $newW, $offsetY + $newH, $overlay); // UI 요소 마커 그리기 $this->drawMarkers($canvas, $uiElements, $offsetX, $offsetY, $newW, $newH); // 하단 캡션 바 $this->drawCaptionBar($canvas, $caption, $stepNumber); // 상단 스텝 인디케이터 $this->drawStepBadge($canvas, $stepNumber); // 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; } } /** * UI 요소 위치에 빨간 번호 마커 그리기 */ private function drawMarkers(\GdImage $canvas, array $uiElements, int $offsetX, int $offsetY, int $imgW, int $imgH): void { $red = imagecolorallocate($canvas, 239, 68, 68); $white = imagecolorallocate($canvas, 255, 255, 255); $highlightBg = imagecolorallocatealpha($canvas, 239, 68, 68, 100); foreach ($uiElements as $i => $element) { $x = $element['x'] ?? 0.5; $y = $element['y'] ?? 0.5; // 비율 좌표 → 실제 픽셀 좌표 $px = $offsetX + (int) ($x * $imgW); $py = $offsetY + (int) ($y * $imgH); // 하이라이트 영역 (요소 주변 밝게) $hlSize = 60; imagefilledrectangle( $canvas, max(0, $px - $hlSize), max(0, $py - $hlSize), min(self::TARGET_WIDTH, $px + $hlSize), min(self::TARGET_HEIGHT - self::CAPTION_HEIGHT, $py + $hlSize), $highlightBg ); // 빨간 원형 배지 $r = self::MARKER_RADIUS; imagefilledellipse($canvas, $px, $py, $r * 2, $r * 2, $red); // 흰색 테두리 imageellipse($canvas, $px, $py, $r * 2, $r * 2, $white); // 번호 텍스트 $num = (string) ($i + 1); $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, $px - (int) ($tw / 2), $py + (int) ($th / 2), $white, $this->fontPath, $num); } else { imagestring($canvas, 5, $px - 4, $py - 7, $num, $white); } } } /** * 하단 캡션 바 그리기 */ private function drawCaptionBar(\GdImage $canvas, string $caption, int $stepNumber): void { $barY = self::TARGET_HEIGHT - self::CAPTION_HEIGHT; // 반투명 검정 배경 $barBg = imagecolorallocatealpha($canvas, 0, 0, 0, 40); imagefilledrectangle($canvas, 0, $barY, self::TARGET_WIDTH, self::TARGET_HEIGHT, $barBg); // 상단 구분선 (인디고) $accent = imagecolorallocate($canvas, 79, 70, 229); imagefilledrectangle($canvas, 0, $barY, self::TARGET_WIDTH, $barY + 4, $accent); // 캡션 텍스트 $white = imagecolorallocate($canvas, 255, 255, 255); $fontSize = 38; if (file_exists($this->fontPath)) { $wrappedText = $this->wrapText($caption, 40); $lines = explode("\n", $wrappedText); $lineHeight = 52; $startY = $barY + 30 + $lineHeight; if (count($lines) > 1) { $fontSize = 32; $lineHeight = 46; $startY = $barY + 22 + $lineHeight; } foreach ($lines as $li => $line) { $bbox = imagettfbbox($fontSize, 0, $this->fontPath, $line); $tw = $bbox[2] - $bbox[0]; $tx = (int) ((self::TARGET_WIDTH - $tw) / 2); imagettftext($canvas, $fontSize, 0, $tx, $startY + ($li * $lineHeight), $white, $this->fontPath, $line); } } else { imagestring($canvas, 5, 40, $barY + 80, $caption, $white); } } /** * 좌상단 스텝 배지 그리기 */ private function drawStepBadge(\GdImage $canvas, int $stepNumber): void { $badgeBg = imagecolorallocate($canvas, 79, 70, 229); $white = imagecolorallocate($canvas, 255, 255, 255); // 둥근 사각형 배지 (좌상단) $bx = 30; $by = 20; $bw = 140; $bh = 44; imagefilledrectangle($canvas, $bx, $by, $bx + $bw, $by + $bh, $badgeBg); $text = "STEP {$stepNumber}"; 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); } } /** * 스포트라이트 효과가 적용된 슬라이드 생성 * * 원본 이미지를 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); $borderWhite = imagecolorallocate($canvas, 255, 255, 255); $outerW = 4; $innerW = 2; // 바깥 빨간 테두리 for ($b = 0; $b < $outerW; $b++) { imagerectangle( $canvas, $canvasFx - $innerW - $b, $canvasFy - $innerW - $b, $canvasFx + $canvasFw + $innerW + $b, $canvasFy + $canvasFh + $innerW + $b, $red ); } // 안쪽 흰색 테두리 for ($b = 0; $b < $innerW; $b++) { imagerectangle( $canvas, $canvasFx - $b, $canvasFy - $b, $canvasFx + $canvasFw + $b, $canvasFy + $canvasFh + $b, $borderWhite ); } // 번호 마커 (테두리 바깥 좌상단) $markerOffset = self::MARKER_RADIUS + $outerW + 4; $this->drawNumberMarker($canvas, $stepNumber, $canvasFx - $markerOffset, $canvasFy - $markerOffset); } } 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 = 72; $bbox = imagettfbbox($titleSize, 0, $this->fontPath, $title); $tw = $bbox[2] - $bbox[0]; $tx = (int) ((self::TARGET_WIDTH - $tw) / 2); imagettftext($canvas, $titleSize, 0, $tx, 440, $white, $this->fontPath, $title); // 서브타이틀 $sub = 'SAM 사용법을 안내합니다.'; $subSize = 40; $bbox2 = imagettfbbox($subSize, 0, $this->fontPath, $sub); $tw2 = $bbox2[2] - $bbox2[0]; $tx2 = (int) ((self::TARGET_WIDTH - $tw2) / 2); imagettftext($canvas, $subSize, 0, $tx2, 550, $lightWhite, $this->fontPath, $sub); // 구분선 $lineColor = imagecolorallocatealpha($canvas, 255, 255, 255, 80); imagefilledrectangle($canvas, (self::TARGET_WIDTH / 2) - 80, 590, (self::TARGET_WIDTH / 2) + 80, 594, $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 = 72; $bbox = imagettfbbox($mainSize, 0, $this->fontPath, $mainText); $tw = $bbox[2] - $bbox[0]; $tx = (int) ((self::TARGET_WIDTH - $tw) / 2); imagettftext($canvas, $mainSize, 0, $tx, 440, $white, $this->fontPath, $mainText); $subText = '감사합니다.'; $subSize = 48; $bbox2 = imagettfbbox($subSize, 0, $this->fontPath, $subText); $tw2 = $bbox2[2] - $bbox2[0]; $tx2 = (int) ((self::TARGET_WIDTH - $tw2) / 2); imagettftext($canvas, $subSize, 0, $tx2, 560, $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 = 26; 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 = 20; $by = 14; $bw = 150; $bh = 38; imagefilledrectangle($canvas, $bx, $by, $bx + $bw, $by + $bh, $badgeBg); $text = "STEP {$stepNumber}/{$totalSteps}"; if (file_exists($this->fontPath)) { $fontSize = 17; $bbox = imagettfbbox($fontSize, 0, $this->fontPath, $text); $tw = $bbox[2] - $bbox[0]; $tx = $bx + (int) (($bw - $tw) / 2); imagettftext($canvas, $fontSize, 0, $tx, $by + 27, $white, $this->fontPath, $text); } else { imagestring($canvas, 5, $bx + 20, $by + 20, $text, $white); } } /** * 이미지 로드 (형식 자동 감지) */ private function loadImage(string $path): ?\GdImage { $info = getimagesize($path); if (! $info) { Log::error("SlideAnnotation: 이미지 정보를 읽을 수 없음 - {$path}"); return null; } return match ($info[2]) { IMAGETYPE_PNG => imagecreatefrompng($path), IMAGETYPE_JPEG => imagecreatefromjpeg($path), IMAGETYPE_GIF => imagecreatefromgif($path), IMAGETYPE_WEBP => imagecreatefromwebp($path), default => null, }; } /** * 텍스트 줄바꿈 */ private function wrapText(string $text, int $maxChars): string { if (mb_strlen($text) <= $maxChars) { return $text; } $words = preg_split('/(?<=\s)|(?<=\.)|(?<=,)/u', $text); $lines = []; $currentLine = ''; foreach ($words as $word) { if (mb_strlen($currentLine.$word) > $maxChars && $currentLine !== '') { $lines[] = trim($currentLine); $currentLine = $word; } else { $currentLine .= $word; } } if ($currentLine !== '') { $lines[] = trim($currentLine); } return implode("\n", array_slice($lines, 0, 3)); } }