Files
sam-manage/app/Services/Video/GeminiScriptService.php

308 lines
11 KiB
PHP
Raw Normal View History

<?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;
}
}