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>
This commit is contained in:
187
app/Services/Video/GeminiScriptService.php
Normal file
187
app/Services/Video/GeminiScriptService.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user