Files
sam-manage/app/Services/Video/GeminiScriptService.php
김보곤 0be763b05a feat:트렌딩 키워드 수집 + Veo 3.1 프롬프트 강화 파이프라인
- Google Trends RSS 기반 실시간 급상승 키워드 수집 서비스 추가
- 트렌딩 컨텍스트 활용 후킹 제목 생성 (5패턴: 충격/비교/숫자/질문/반전)
- Veo 3.1 공식 가이드 기반 visual_prompt 5요소 프레임워크 적용
- GET /video/veo3/trending 엔드포인트 추가

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

308 lines
11 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();
}
/**
* 키워드 → 트렌딩 제목 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'] ?? '';
$contextBlock = <<<CTX
[실시간 트렌딩 정보]
- 이 키워드는 지금 한국에서 실시간 급상승 중입니다
- 검색량: {$traffic}
- 관련 뉴스: {$newsTitle}
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
당신은 YouTube Shorts 영상 시나리오 전문 작가이자 영화 촬영감독입니다.
영상 제목: "{$title}"
키워드: "{$keyword}"
이 제목으로 40초 분량의 YouTube Shorts 영상 시나리오를 작성해주세요.
=== 장면 구성 패턴 (6장면, 총 40초) ===
장면 1 (6초): HOOK - extreme close-up 또는 dynamic camera movement → 시청자 호기심 극대화
장면 2 (8초): CONTEXT - medium shot, 상황 설명과 배경 제시
장면 3 (8초): DEVELOPMENT - tracking shot, 스토리 전개
장면 4 (8초): CLIMAX - dramatic camera movement, 핵심 메시지 전달
장면 5 (6초): TWIST/REVEAL - whip pan 또는 cut, 반전 또는 핵심 공개
장면 6 (4초): CTA - close-up, 좋아요/구독 행동 유도
=== 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": 6,
"scene_role": "HOOK",
"narration": "나레이션 텍스트 (한국어)",
"visual_prompt": "Shot type, camera movement. Character description with specific clothing, action and expression. Lighting description. Style/quality keywords. Background and props detail.",
"mood": "exciting"
}
],
"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;
}
}