- Gemini 프롬프트: visual_prompt에 한국인 여성(20대) 등장인물 규칙 추가 - Veo 프롬프트: 모든 클립에 "Korean woman in her 20s" 프리픽스 자동 추가 - 싱크 버그: activeNarrationPaths 인덱스 off-by-one ($num-1→$num) 수정 - 나레이션이 영상보다 1장면 앞서 재생되던 근본 원인 - concatNarrations: atrim+apad로 나레이션을 장면 길이에 정확히 매칭 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
218 lines
6.7 KiB
PHP
218 lines
6.7 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'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* 제목 → 장면별 시나리오 생성
|
|
*/
|
|
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" 등 품질 키워드 포함
|
|
- 등장인물은 반드시 "a young Korean woman in her 20s"로 설정 (모든 장면에 동일 인물 등장)
|
|
- 등장인물의 표정, 의상, 동작을 구체적으로 묘사
|
|
- 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 호출 (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;
|
|
}
|
|
}
|