Files
sam-manage/app/Services/Video/GeminiScriptService.php
김보곤 6ab93aedd2 feat:YouTube Shorts AI 자동 생성 시스템 구현 (Veo 3.1 + Gemini)
- GeminiScriptService: 트렌딩 제목/시나리오 생성
- VeoVideoService: Veo 3.1 영상 클립 생성
- TtsService: Google TTS 나레이션 생성
- BgmService: 분위기별 BGM 선택
- VideoAssemblyService: FFmpeg 영상 합성
- VideoGenerationJob: 백그라운드 처리
- Veo3Controller: API 엔드포인트
- React 프론트엔드 (5단계 위저드)
- GoogleCloudService.getAccessToken() public 변경

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

188 lines
5.2 KiB
PHP

<?php
namespace App\Services\Video;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiScriptService
{
private string $apiKey;
private string $model = 'gemini-2.5-flash';
public function __construct()
{
$this->apiKey = config('services.gemini.api_key', '');
}
/**
* 키워드 → 트렌딩 제목 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'] ?? [];
}
/**
* 제목 → 장면별 시나리오 생성
*/
public function generateScenario(string $title, string $keyword = ''): array
{
$prompt = <<<PROMPT
당신은 YouTube Shorts 영상 시나리오 전문 작가입니다.
영상 제목: "{$title}"
키워드: "{$keyword}"
이 제목으로 40초 분량의 YouTube Shorts 영상 시나리오를 작성해주세요.
요구사항:
- 5~6개 장면 (각 6~8초)
- 각 장면에 나레이션 텍스트 (한국어, 자막으로도 사용)
- 각 장면에 Veo 3.1 영상 생성용 프롬프트 (영어, 구체적이고 시각적)
- 총 길이 합계가 정확히 40초
- 첫 장면은 강렬한 후크 (시청 유지율 극대화)
- 마지막 장면은 CTA (좋아요/구독 유도)
visual_prompt 작성 규칙:
- 영어로 작성
- 카메라 앵글, 조명, 분위기를 구체적으로 묘사
- "cinematic", "4K", "dramatic lighting" 등 품질 키워드 포함
- 사람이 등장할 경우 외모, 표정, 동작을 구체적으로
- 9:16 세로 영상에 적합한 구도
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
{
"title": "{$title}",
"scenes": [
{
"scene_number": 1,
"duration": 8,
"narration": "나레이션 텍스트 (한국어)",
"visual_prompt": "Detailed English prompt for Veo 3.1 video generation...",
"mood": "exciting"
}
],
"total_duration": 40,
"bgm_mood": "upbeat, energetic"
}
PROMPT;
$result = $this->callGemini($prompt);
if (! $result) {
return [];
}
return $this->parseJsonResponse($result) ?: [];
}
/**
* Gemini API 호출
*/
private function callGemini(string $prompt): ?string
{
if (empty($this->apiKey)) {
Log::error('GeminiScriptService: API 키가 설정되지 않았습니다.');
return null;
}
try {
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent";
$response = Http::withHeaders([
'x-goog-api-key' => $this->apiKey,
])->timeout(60)->post($url, [
'contents' => [
[
'parts' => [
['text' => $prompt],
],
],
],
'generationConfig' => [
'temperature' => 0.9,
'maxOutputTokens' => 4096,
'responseMimeType' => 'application/json',
],
]);
if (! $response->successful()) {
Log::error('GeminiScriptService: API 호출 실패', [
'status' => $response->status(),
'body' => $response->body(),
]);
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;
}
}