Files
sam-manage/app/Services/Video/GeminiScriptService.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

447 lines
18 KiB
PHP

<?php
namespace App\Services\Video;
use App\Models\System\AiConfig;
use App\Services\GoogleCloudService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiScriptService
{
private ?AiConfig $config = null;
private GoogleCloudService $googleCloud;
public function __construct(GoogleCloudService $googleCloud)
{
$this->googleCloud = $googleCloud;
$this->config = AiConfig::getActiveGemini();
}
/**
* 실시간 트렌딩 키워드 → 건강 채널용 필터링/리프레이밍
*/
public function filterHealthTrending(array $trendingKeywords): array
{
if (empty($trendingKeywords)) {
return [];
}
$keywordList = collect($trendingKeywords)->map(function ($item, $i) {
$news = ! empty($item['news_title']) ? " (뉴스: {$item['news_title']})" : '';
return ($i + 1) . ". {$item['keyword']}{$news}";
})->implode("\n");
$prompt = <<<PROMPT
당신은 건강/웰빙 전문 YouTube Shorts 채널 기획자입니다.
아래는 오늘의 한국 실시간 급상승 키워드 목록입니다:
{$keywordList}
이 중에서 건강/웰빙/의료/다이어트/운동/영양/수면/스트레스 관련 키워드만 선택하세요.
=== 엄격한 선택 기준 ===
- 키워드 자체가 건강/의료/음식/영양/운동/질병/신체 관련일 때만 포함
- 최소 1개, 최대 8개 선택 (해당 없으면 0개 가능)
=== 절대 제외 대상 (아무리 건강과 연결하려 해도 제외) ===
- 연예인/아이돌/배우/가수 이름 (예: 아이유, 신세경, BTS 등)
- 정치인/정당/선거/정치 이슈
- 스포츠 경기 결과/선수 이름
- 드라마/영화/예능 프로그램명
- 사건/사고/범죄 뉴스
- 게임/IT기기/앱 이름
- 인물 이름이 키워드인 경우 무조건 제외
=== 포함 가능 예시 ===
- "다이어트" → 직접 건강 관련 → 포함
- "김치" → 음식/영양 → 포함 가능
- "수면" → 건강 → 포함
- "설날" → 명절 건강/음식 관련 → 포함 가능
- "미세먼지" → 건강 영향 → 포함 가능
=== 제외 예시 ===
- "아이유" → 가수 이름 → 제외
- "신세경" → 배우 이름 → 제외
- "손흥민" → 축구선수 → 제외
- "더글로리" → 드라마 → 제외
health_angle은 10자 이내 핵심 태그 (예: "장건강", "면역력", "다이어트")
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
{
"keywords": [
{"keyword": "원본 키워드", "health_angle": "건강 태그 10자 이내", "suggested_topic": "건강 채널에서 다룰 구체적 주제 20자 이내"}
]
}
해당하는 키워드가 없으면 빈 배열을 반환하세요: {"keywords": []}
PROMPT;
$result = $this->callGemini($prompt);
if (! $result) {
return [];
}
$parsed = $this->parseJsonResponse($result);
if (! $parsed || empty($parsed['keywords'])) {
return [];
}
// 원본 트렌딩 데이터와 매칭하여 traffic 등 보존
$trendingMap = collect($trendingKeywords)->keyBy('keyword');
return collect($parsed['keywords'])->map(function ($item) use ($trendingMap) {
$original = $trendingMap->get($item['keyword']);
return [
'keyword' => $item['keyword'],
'health_angle' => $item['health_angle'] ?? '',
'suggested_topic' => $item['suggested_topic'] ?? $item['keyword'],
'traffic' => $original['traffic'] ?? '',
'news_title' => $original['news_title'] ?? '',
'pub_date' => $original['pub_date'] ?? null,
];
})->values()->toArray();
}
/**
* 키워드 → 트렌딩 제목 5개 생성 (기본)
*/
public function generateTrendingTitles(string $keyword): array
{
$prompt = <<<PROMPT
당신은 YouTube Shorts 전문 크리에이터입니다.
키워드: "{$keyword}"
이 키워드로 YouTube Shorts에서 조회수를 극대화할 수 있는 매력적인 제목 5개를 생성해주세요.
요구사항:
- 각 제목은 40자 이내
- 호기심을 자극하는 제목
- 한국어로 작성
- 이모지 1-2개 포함 가능
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
{
"titles": [
{"title": "제목1", "hook": "이 제목이 효과적인 이유 한줄"},
{"title": "제목2", "hook": "이유"},
{"title": "제목3", "hook": "이유"},
{"title": "제목4", "hook": "이유"},
{"title": "제목5", "hook": "이유"}
]
}
PROMPT;
$result = $this->callGemini($prompt);
if (! $result) {
return [];
}
$parsed = $this->parseJsonResponse($result);
return $parsed['titles'] ?? [];
}
/**
* 트렌딩 키워드 + 컨텍스트 → 후킹 제목 5개 생성
*/
public function generateTrendingHookTitles(string $keyword, array $context = []): array
{
$contextBlock = '';
if (! empty($context)) {
$newsTitle = $context['news_title'] ?? '';
$traffic = $context['traffic'] ?? '';
$healthAngle = $context['health_angle'] ?? '';
$suggestedTopic = $context['suggested_topic'] ?? '';
$contextBlock = <<<CTX
[실시간 트렌딩 정보]
- 이 키워드는 지금 한국에서 실시간 급상승 중입니다
- 검색량: {$traffic}
- 관련 뉴스: {$newsTitle}
- 건강 관점: {$healthAngle}
- 건강 주제 제안: {$suggestedTopic}
CTX;
}
$prompt = <<<PROMPT
당신은 건강/웰빙 전문 YouTube Shorts 조회수 1000만 크리에이터입니다.
이 채널은 "건강 채널"입니다. 모든 제목은 반드시 건강/웰빙/의료/영양/운동 관점이어야 합니다.
키워드: "{$keyword}"
{$contextBlock}
이 키워드를 건강 관점에서 YouTube Shorts 조회수를 폭발시킬 후킹 제목 5개를 생성해주세요.
반드시 아래 5가지 패턴을 각각 1개씩 사용:
1. 충격형: "이거 실화임?" / "소름 돋는 진실" 스타일
2. 비교형: "A vs B, 결과가 충격" / "전문가도 놀란" 스타일
3. 숫자형: "3가지 이유" / "단 10초면" 스타일
4. 질문형: "왜 아무도 안 알려줬을까?" / "이것도 몰랐어?" 스타일
5. 반전형: "알고 보니..." / "사실은 반대였다" 스타일
요구사항:
- 각 제목은 40자 이내
- 첫 2초 안에 시청자 호기심 폭발 (스크롤 멈춤 유도)
- 감정 유발 단어 필수 (충격, 소름, 미친, 레전드, 역대급 등)
- 한국어로 작성
- 이모지 1-2개 포함
- 트렌딩 키워드의 맥락을 정확히 반영
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
{
"titles": [
{"title": "제목1", "hook": "이 제목이 효과적인 이유 한줄", "pattern": "충격형"},
{"title": "제목2", "hook": "이유", "pattern": "비교형"},
{"title": "제목3", "hook": "이유", "pattern": "숫자형"},
{"title": "제목4", "hook": "이유", "pattern": "질문형"},
{"title": "제목5", "hook": "이유", "pattern": "반전형"}
]
}
PROMPT;
$result = $this->callGemini($prompt);
if (! $result) {
return [];
}
$parsed = $this->parseJsonResponse($result);
return $parsed['titles'] ?? [];
}
/**
* 제목 → 장면별 시나리오 생성 (Veo 3.1 공식 가이드 기반 프롬프트)
*/
public function generateScenario(string $title, string $keyword = ''): array
{
$prompt = <<<PROMPT
당신은 조회수 1000만 이상 YouTube Shorts 전문 크리에이터이자 영화 촬영감독입니다.
영상 제목: "{$title}"
키워드: "{$keyword}"
이 제목으로 40초 분량의 YouTube Shorts 영상 시나리오를 작성해주세요.
=== 핵심 원칙: 초반 3초가 생사를 결정한다 ===
- Shorts는 스크롤 한 번이면 넘어간다. 첫 3초 안에 시청자를 "멈추게" 해야 한다.
- 장면 1의 나레이션 첫 문장은 반드시 충격/의문/공감 중 하나로 시작 (예: "이거 모르면 손해입니다", "방금 소름 돋았어요", "설마 아직도 이렇게 하세요?")
- 절대 "안녕하세요", "오늘은~", "여러분~" 같은 평범한 인사로 시작하지 마세요
=== 나레이션 작성 규칙 (매우 중요) ===
- 말투: 반말 or 친근한 존댓말 (방송 톤X, 친구한테 신기한 걸 알려주는 톤O)
- 속도감: TTS가 1.4배속으로 빠르게 재생됨. 한 장면당 3~4문장 (장면당 60~100자). 빈 시간 없이 빽빽하게 채워라.
- 문장 구분: 반드시 마침표(.) 또는 느낌표(!) 또는 물음표(?)로 문장을 끝내라. 자막이 문장 단위로 전환된다.
- 한 문장 길이: 10~25자 이내의 짧고 펀치감 있는 문장. 긴 문장 금지.
- 매 장면마다 한 가지 "놀라운 팩트" 또는 "감정 변화"가 있어야 한다
- 뻔한 설명 금지. "~라고 합니다", "~인데요" 같은 수동적 표현 대신 단정적이고 강렬한 어투 사용
- 마지막 장면에서 "좋아요/구독/알림설정" 같은 CTA 절대 금지. 대신 여운이 남는 한마디 또는 강렬한 마무리
=== 나레이션 절대 금지 사항 (TTS가 읽어버림) ===
- 이모지 절대 금지: 😊🔥💪❤️ 등 모든 이모지/이모티콘 사용 금지
- 특수 표현 금지: *강조*, (효과음), [동작], ~물결, ○기호 등 사용 금지
- 순수 한글 텍스트만 작성. TTS가 음성으로 변환하므로 사람이 말하는 것처럼 자연스러운 문장만 허용
- 숫자는 한글로 표기 (예: "3가지" → "세 가지", "100%" → "백 퍼센트")
=== 나레이션 좋은 예시 ===
- "이거 매일 먹어봐요. 얼굴이 확 달라집니다."
- "과학자들도 설명 못 한대요. 왜냐면요."
- "근데 진짜 무서운 건요. 이 다음이에요."
=== 나레이션 나쁜 예시 (절대 이렇게 쓰지 마세요) ===
- "안녕하세요, 오늘은 ○○에 대해 알아보겠습니다." (평범한 시작)
- "맛있게 먹고 행복해지세요!😊" (이모지 포함 - TTS가 읽음)
- "이건 진짜 *소름* 돋는 사실인데요~" (특수기호 포함)
- "3가지 방법을 알려드릴게요!" (숫자 한글 미변환)
=== 장면 구성 패턴 (5장면, 총 40초) ===
장면 1 (5초): HOOK - extreme close-up 또는 whip pan → 충격/의문/공감으로 3초 안에 시청자 잡기
장면 2 (8초): CONTEXT - medium shot, 왜 이게 중요한지 빠르게 설명
장면 3 (10초): DEVELOPMENT - tracking shot, 핵심 정보 전달 (가장 밀도 높은 구간)
장면 4 (10초): CLIMAX - dramatic camera movement, 반전 또는 가장 충격적인 팩트
장면 5 (7초): PUNCHLINE - close-up, 여운 남기는 강렬한 한마디로 끝 (CTA 절대 금지)
=== visual_prompt 작성 규칙 (Veo 3.1 공식 프롬프팅 가이드) ===
반드시 아래 5개 요소를 모두 포함하여 영어로 작성:
1. SHOT TYPE (필수 1개 선택):
- extreme close-up, close-up, medium close-up, medium shot, medium wide shot, wide shot, extreme wide shot, bird's-eye view, over-the-shoulder shot
2. CAMERA MOVEMENT (필수 1개 선택):
- static, slow pan left/right, tilt up/down, dolly in/out, tracking shot, crane shot, aerial shot, handheld, arc shot, whip pan, zoom in/out
3. LIGHTING (필수, 구체적으로):
- golden hour sunlight, dramatic side lighting, soft diffused light, neon glow, Rembrandt lighting, high-key lighting, low-key lighting, backlit silhouette, warm ambient light, cool blue moonlight
4. STYLE/QUALITY (필수):
- cinematic 4K, shot on 35mm film, shallow depth of field, film grain, anamorphic lens flare, bokeh background, high contrast, muted color palette, vibrant saturated colors
5. ACTION (한 장면에 하나의 주요 동작만):
- 명확한 하나의 동작을 묘사 (예: "picks up the phone", "turns around slowly", "looks directly at camera")
=== 추가 필수 규칙 ===
- 등장인물: 모든 장면에 동일 인물 = "a young Korean woman in her 20s with shoulder-length black hair, wearing [첫 장면에서 정한 구체적 의상]"
- 의상은 첫 장면에서 구체적으로 설정하고 모든 장면에서 동일하게 유지
- 표정/감정: "with a shocked expression", "looking curious", "smiling warmly" 등 구체적 묘사
- 배경: 구체적 장소 + 소품 묘사 (예: "a modern Seoul café with exposed brick walls and hanging plants")
- 9:16 세로 구도: 인물을 화면 중앙에 배치, 상반신 중심 프레이밍
- 장면 간 시각적 연결: 동일 색상 팔레트, 동일 의상, 동일 장소 또는 자연스러운 장소 전환
=== visual_prompt 좋은 예시 ===
"Medium shot, slow dolly in. A young Korean woman in her 20s with shoulder-length black hair, wearing a cream knit sweater, sitting at a cozy café table. She looks down at her phone with a shocked expression, mouth slightly open. Warm golden hour sunlight streams through the window. Shallow depth of field, cinematic 4K, shot on 35mm film. Steam rises from a coffee cup in the foreground."
=== visual_prompt 나쁜 예시 (이렇게 작성하지 마세요) ===
"A woman looking at phone in café, 4K cinematic"
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
{
"title": "{$title}",
"scenes": [
{
"scene_number": 1,
"duration": 5,
"scene_role": "HOOK",
"narration": "짧은 문장. 짧은 문장. 짧은 문장! (순수 한글만, 이모지 절대금지, 40~70자, 한 문장 10~20자)",
"visual_prompt": "Shot type, camera movement. Character description with specific clothing, action and expression. Lighting description. Style/quality keywords. Background and props detail.",
"mood": "shocking"
}
],
"total_duration": 40,
"bgm_mood": "upbeat, energetic",
"character_description": "첫 장면의 인물 설정을 여기에 요약 (의상, 헤어 등)"
}
PROMPT;
$result = $this->callGemini($prompt);
if (! $result) {
return [];
}
return $this->parseJsonResponse($result) ?: [];
}
/**
* Gemini API 호출 (텍스트 전용 - 기존 호환)
*/
private function callGemini(string $prompt): ?string
{
return $this->callGeminiWithParts([['text' => $prompt]]);
}
/**
* Gemini API 호출 (멀티모달 지원 - 텍스트 + 이미지)
*
* @param array $parts [['text' => '...'], ['inlineData' => ['mimeType' => '...', 'data' => '...']]]
*/
public function callGeminiWithParts(array $parts, float $temperature = 0.9, int $maxTokens = 4096): ?string
{
if (! $this->config) {
Log::error('GeminiScriptService: 활성화된 Gemini 설정이 없습니다. (시스템 > AI 설정 확인)');
return null;
}
try {
$model = $this->config->model ?: 'gemini-3.0-flash';
$body = [
'contents' => [
[
'parts' => $parts,
],
],
'generationConfig' => [
'temperature' => $temperature,
'maxOutputTokens' => $maxTokens,
'responseMimeType' => 'application/json',
],
];
if ($this->config->isVertexAi()) {
$accessToken = $this->googleCloud->getAccessToken();
if (! $accessToken) {
Log::error('GeminiScriptService: Vertex AI 액세스 토큰 획득 실패');
return null;
}
$projectId = $this->config->getProjectId();
$region = $this->config->getRegion();
$url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent";
$body['contents'][0]['role'] = 'user';
$response = Http::withToken($accessToken)
->timeout(120)
->post($url, $body);
} else {
$apiKey = $this->config->api_key;
$baseUrl = $this->config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta';
$url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}";
$response = Http::timeout(120)->post($url, $body);
}
if (! $response->successful()) {
Log::error('GeminiScriptService: API 호출 실패', [
'status' => $response->status(),
'body' => $response->body(),
'auth_type' => $this->config->isVertexAi() ? 'vertex_ai' : 'api_key',
]);
return null;
}
$data = $response->json();
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
return $text;
} catch (\Exception $e) {
Log::error('GeminiScriptService: 예외 발생', ['error' => $e->getMessage()]);
return null;
}
}
/**
* JSON 응답 파싱 (public - 외부 서비스에서도 사용)
*/
public function parseJson(string $text): ?array
{
return $this->parseJsonResponse($text);
}
/**
* JSON 응답 파싱 (코드블록 제거 포함)
*/
private function parseJsonResponse(string $text): ?array
{
// 코드블록 제거 (```json ... ```)
$text = preg_replace('/^```(?:json)?\s*/m', '', $text);
$text = preg_replace('/```\s*$/m', '', $text);
$text = trim($text);
$decoded = json_decode($text, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::warning('GeminiScriptService: JSON 파싱 실패', [
'error' => json_last_error_msg(),
'text' => substr($text, 0, 500),
]);
return null;
}
return $decoded;
}
}