Files
sam-manage/app/Services/Video/GeminiScriptService.php
김보곤 ed1967405c feat:트렌드 키워드를 건강 채널용으로 필터링
건강 채널 전용 트렌딩 시스템:
- Gemini로 실시간 트렌드에서 건강 관련 키워드만 필터링
- 간접적 키워드도 건강 앵글로 리프레이밍 (예: 김치 → 장건강)
- 필터 결과 30분 캐싱 (Gemini 호출 최소화)
- 필터 실패 시 원본 키워드 폴백

제목 생성 건강 앵글 반영:
- generateTrendingHookTitles 프롬프트에 건강 채널 명시
- trending_context에 health_angle, suggested_topic 추가
- 모든 제목이 건강/웰빙 관점으로 생성되도록 가이드

UI 건강 테마 적용:
- 버튼/칩 색상: orange/indigo → green 테마
- 칩에 건강 앵글 태그 배지 표시
- 칩 클릭 시 건강 주제(suggested_topic)가 인풋에 채워짐

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

405 lines
16 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}
이 중에서 건강/웰빙/의료/다이어트/운동/영양/정신건강/수면/스트레스 관련 YouTube Shorts 콘텐츠를 만들 수 있는 키워드를 골라주세요.
선택 기준:
- 직접적으로 건강 관련인 키워드는 반드시 포함
- 간접적이라도 건강 관점의 콘텐츠로 연결 가능하면 포함 (예: "김치" → "김치 유산균의 장 건강 효과")
- 최소 3개, 최대 8개 선택
- 건강과 전혀 연결할 수 없는 키워드(정치, 스포츠 결과, 연예 등)는 제외
- health_angle은 10자 이내 핵심 태그 (예: "장건강", "면역력", "다이어트")
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
{
"keywords": [
{"keyword": "원본 키워드", "health_angle": "건강 태그 10자 이내", "suggested_topic": "건강 채널에서 다룰 구체적 주제 20자 이내"}
]
}
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.5배속으로 재생되므로, 한 장면당 3~4문장으로 밀도 있게 작성 (장면당 60~100자)
- 문장 구분: 반드시 마침표(.) 또는 느낌표(!) 또는 물음표(?)로 문장을 끝내라. 자막이 문장 단위로 전환된다.
- 한 문장 길이: 15~25자 이내의 짧고 펀치감 있는 문장. 긴 문장 금지.
- 매 장면마다 한 가지 "놀라운 팩트" 또는 "감정 변화"가 있어야 한다
- 뻔한 설명 금지. "~라고 합니다", "~인데요" 같은 수동적 표현 대신 단정적이고 강렬한 어투 사용
- 마지막 장면에서 "좋아요/구독/알림설정" 같은 CTA 절대 금지. 대신 여운이 남는 한마디 또는 강렬한 마무리
=== 나레이션 좋은 예시 (한 문장=15~25자, 마침표로 구분) ===
- "이거 매일 먹어봐요. 얼굴이 확 달라집니다. 진짜예요."
- "소름 돋는 거 알려줄게요. 과학자들도 설명 못 한대요. 왜냐면요."
- "근데 진짜 무서운 건요. 이 다음이에요. 절대 넘기지 마세요."
=== 나레이션 나쁜 예시 (절대 이렇게 쓰지 마세요) ===
- "안녕하세요, 오늘은 ○○에 대해 알아보겠습니다."
- "이 영상이 도움이 되셨다면 좋아요와 구독 부탁드려요!"
- "여러분도 한번 해보시면 좋을 것 같습니다~"
- "○○라고 하는데요, 참 신기하죠?"
=== 장면 구성 패턴 (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": "짧은 문장1. 짧은 문장2. 짧은 문장3! (한국어, 60~100자, 한 문장 15~25자, 반드시 마침표/느낌표/물음표로 구분)",
"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 호출 (AiConfig 기반 - API Key / Vertex AI 자동 분기)
*/
private function callGemini(string $prompt): ?string
{
if (! $this->config) {
Log::error('GeminiScriptService: 활성화된 Gemini 설정이 없습니다. (시스템 > AI 설정 확인)');
return null;
}
try {
$model = $this->config->model ?: 'gemini-3.0-flash';
$body = [
'contents' => [
[
'parts' => [
['text' => $prompt],
],
],
],
'generationConfig' => [
'temperature' => 0.9,
'maxOutputTokens' => 4096,
'responseMimeType' => 'application/json',
],
];
if ($this->config->isVertexAi()) {
// Vertex AI 방식 (서비스 계정 OAuth)
$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";
// Vertex AI에서는 role 필수
$body['contents'][0]['role'] = 'user';
$response = Http::withToken($accessToken)
->timeout(60)
->post($url, $body);
} else {
// API Key 방식
$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(60)->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 응답 파싱 (코드블록 제거 포함)
*/
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;
}
}