- 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>
188 lines
5.2 KiB
PHP
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;
|
|
}
|
|
}
|