Files
sam-manage/app/Services/Video/GeminiScriptService.php
김보곤 e093c7b7e7 fix:TTS 속도 1.5x + Neural2 음성 변경 + 자막 문장 단위 싱크
TTS 개선:
- 음성: ko-KR-Wavenet-A → ko-KR-Neural2-C (남성, 자연스럽고 개성있는 음성)
- 속도: 1.0x → 1.5x (기존 대비 50% 빠르게)
- 피치: 0.0 → 2.0 (더 에너지 있는 톤)

자막 싱크 버그 수정:
- 장면 전체 나레이션을 한 블록으로 표시 → 문장 단위로 분리 표시
- 각 문장 타이밍을 글자 수 비례로 자동 계산
- 문장 분리 로직: 마침표/느낌표/물음표 기준, 폴백으로 쉼표 분리
- 장면 끝 0.3초 여백으로 자연스러운 전환

시나리오 프롬프트:
- 나레이션 문장 길이 규칙 추가 (한 문장 15~25자)
- 반드시 마침표/느낌표/물음표로 문장 구분하도록 명시
- 장면당 글자 수 60~100자로 밀도 향상

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

332 lines
13 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
당신은 조회수 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;
}
}