Files
sam-manage/app/Services/Video/SlideAnnotationService.php
김보곤 768bc30a6d feat:사용자 매뉴얼 영상 자동 생성 기능 구현
- TutorialVideo 모델 (상태 관리, TenantScope)
- GeminiScriptService에 callGeminiWithParts() 멀티모달 지원 추가
- ScreenAnalysisService: Gemini Vision 스크린샷 AI 분석
- SlideAnnotationService: PHP GD 이미지 어노테이션 (마커, 캡션)
- TutorialAssemblyService: FFmpeg 이미지→영상 합성 (crossfade)
- TutorialVideoJob: 분석→슬라이드→TTS→BGM→합성 파이프라인
- TutorialVideoController: 업로드/분석/생성/상태/다운로드/이력 API
- React-in-Blade UI: 3단계 (업로드→분석확인→생성모니터링) + 이력

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

279 lines
9.2 KiB
PHP

<?php
namespace App\Services\Video;
use Illuminate\Support\Facades\Log;
class SlideAnnotationService
{
private const TARGET_WIDTH = 1920;
private const TARGET_HEIGHT = 1080;
private const CAPTION_HEIGHT = 100;
private const MARKER_RADIUS = 20;
private string $fontPath;
public function __construct()
{
// Docker에 설치된 나눔고딕 폰트
$this->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 + 3, $accent);
// 캡션 텍스트
$white = imagecolorallocate($canvas, 255, 255, 255);
$fontSize = 20;
$textY = $barY + 55;
if (file_exists($this->fontPath)) {
// 텍스트 줄바꿈 처리
$wrappedText = $this->wrapText($caption, 60);
$lines = explode("\n", $wrappedText);
$lineHeight = 30;
$startY = $barY + 20 + $lineHeight;
if (count($lines) > 1) {
$fontSize = 17;
$lineHeight = 26;
$startY = $barY + 15 + $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, $textY, $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);
}
}
/**
* 이미지 로드 (형식 자동 감지)
*/
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));
}
}