Files
sam-manage/app/Services/Video/GeminiScriptService.php
김보곤 60edc28a17 fix:한국인 여성 캐릭터 고정 + 나레이션-영상 싱크 버그 수정
- 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>
2026-02-15 10:50:51 +09:00

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