- TutorialVideo 모델 (상태 관리, TenantScope) - GeminiScriptService에 callGeminiWithParts() 멀티모달 지원 추가 - ScreenAnalysisService: Gemini Vision 스크린샷 AI 분석 - SlideAnnotationService: PHP GD 이미지 어노테이션 (마커, 캡션) - TutorialAssemblyService: FFmpeg 이미지→영상 합성 (crossfade) - TutorialVideoJob: 분석→슬라이드→TTS→BGM→합성 파이프라인 - TutorialVideoController: 업로드/분석/생성/상태/다운로드/이력 API - React-in-Blade UI: 3단계 (업로드→분석확인→생성모니터링) + 이력 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
447 lines
18 KiB
PHP
447 lines
18 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();
|
|
}
|
|
|
|
/**
|
|
* 실시간 트렌딩 키워드 → 건강 채널용 필터링/리프레이밍
|
|
*/
|
|
public function filterHealthTrending(array $trendingKeywords): array
|
|
{
|
|
if (empty($trendingKeywords)) {
|
|
return [];
|
|
}
|
|
|
|
$keywordList = collect($trendingKeywords)->map(function ($item, $i) {
|
|
$news = ! empty($item['news_title']) ? " (뉴스: {$item['news_title']})" : '';
|
|
|
|
return ($i + 1) . ". {$item['keyword']}{$news}";
|
|
})->implode("\n");
|
|
|
|
$prompt = <<<PROMPT
|
|
당신은 건강/웰빙 전문 YouTube Shorts 채널 기획자입니다.
|
|
|
|
아래는 오늘의 한국 실시간 급상승 키워드 목록입니다:
|
|
{$keywordList}
|
|
|
|
이 중에서 건강/웰빙/의료/다이어트/운동/영양/수면/스트레스 관련 키워드만 선택하세요.
|
|
|
|
=== 엄격한 선택 기준 ===
|
|
- 키워드 자체가 건강/의료/음식/영양/운동/질병/신체 관련일 때만 포함
|
|
- 최소 1개, 최대 8개 선택 (해당 없으면 0개 가능)
|
|
|
|
=== 절대 제외 대상 (아무리 건강과 연결하려 해도 제외) ===
|
|
- 연예인/아이돌/배우/가수 이름 (예: 아이유, 신세경, BTS 등)
|
|
- 정치인/정당/선거/정치 이슈
|
|
- 스포츠 경기 결과/선수 이름
|
|
- 드라마/영화/예능 프로그램명
|
|
- 사건/사고/범죄 뉴스
|
|
- 게임/IT기기/앱 이름
|
|
- 인물 이름이 키워드인 경우 무조건 제외
|
|
|
|
=== 포함 가능 예시 ===
|
|
- "다이어트" → 직접 건강 관련 → 포함
|
|
- "김치" → 음식/영양 → 포함 가능
|
|
- "수면" → 건강 → 포함
|
|
- "설날" → 명절 건강/음식 관련 → 포함 가능
|
|
- "미세먼지" → 건강 영향 → 포함 가능
|
|
|
|
=== 제외 예시 ===
|
|
- "아이유" → 가수 이름 → 제외
|
|
- "신세경" → 배우 이름 → 제외
|
|
- "손흥민" → 축구선수 → 제외
|
|
- "더글로리" → 드라마 → 제외
|
|
|
|
health_angle은 10자 이내 핵심 태그 (예: "장건강", "면역력", "다이어트")
|
|
|
|
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
|
|
{
|
|
"keywords": [
|
|
{"keyword": "원본 키워드", "health_angle": "건강 태그 10자 이내", "suggested_topic": "건강 채널에서 다룰 구체적 주제 20자 이내"}
|
|
]
|
|
}
|
|
|
|
해당하는 키워드가 없으면 빈 배열을 반환하세요: {"keywords": []}
|
|
PROMPT;
|
|
|
|
$result = $this->callGemini($prompt);
|
|
|
|
if (! $result) {
|
|
return [];
|
|
}
|
|
|
|
$parsed = $this->parseJsonResponse($result);
|
|
|
|
if (! $parsed || empty($parsed['keywords'])) {
|
|
return [];
|
|
}
|
|
|
|
// 원본 트렌딩 데이터와 매칭하여 traffic 등 보존
|
|
$trendingMap = collect($trendingKeywords)->keyBy('keyword');
|
|
|
|
return collect($parsed['keywords'])->map(function ($item) use ($trendingMap) {
|
|
$original = $trendingMap->get($item['keyword']);
|
|
|
|
return [
|
|
'keyword' => $item['keyword'],
|
|
'health_angle' => $item['health_angle'] ?? '',
|
|
'suggested_topic' => $item['suggested_topic'] ?? $item['keyword'],
|
|
'traffic' => $original['traffic'] ?? '',
|
|
'news_title' => $original['news_title'] ?? '',
|
|
'pub_date' => $original['pub_date'] ?? null,
|
|
];
|
|
})->values()->toArray();
|
|
}
|
|
|
|
/**
|
|
* 키워드 → 트렌딩 제목 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'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* 트렌딩 키워드 + 컨텍스트 → 후킹 제목 5개 생성
|
|
*/
|
|
public function generateTrendingHookTitles(string $keyword, array $context = []): array
|
|
{
|
|
$contextBlock = '';
|
|
if (! empty($context)) {
|
|
$newsTitle = $context['news_title'] ?? '';
|
|
$traffic = $context['traffic'] ?? '';
|
|
$healthAngle = $context['health_angle'] ?? '';
|
|
$suggestedTopic = $context['suggested_topic'] ?? '';
|
|
$contextBlock = <<<CTX
|
|
|
|
[실시간 트렌딩 정보]
|
|
- 이 키워드는 지금 한국에서 실시간 급상승 중입니다
|
|
- 검색량: {$traffic}
|
|
- 관련 뉴스: {$newsTitle}
|
|
- 건강 관점: {$healthAngle}
|
|
- 건강 주제 제안: {$suggestedTopic}
|
|
CTX;
|
|
}
|
|
|
|
$prompt = <<<PROMPT
|
|
당신은 건강/웰빙 전문 YouTube Shorts 조회수 1000만 크리에이터입니다.
|
|
이 채널은 "건강 채널"입니다. 모든 제목은 반드시 건강/웰빙/의료/영양/운동 관점이어야 합니다.
|
|
|
|
키워드: "{$keyword}"
|
|
{$contextBlock}
|
|
|
|
이 키워드를 건강 관점에서 YouTube Shorts 조회수를 폭발시킬 후킹 제목 5개를 생성해주세요.
|
|
|
|
반드시 아래 5가지 패턴을 각각 1개씩 사용:
|
|
1. 충격형: "이거 실화임?" / "소름 돋는 진실" 스타일
|
|
2. 비교형: "A vs B, 결과가 충격" / "전문가도 놀란" 스타일
|
|
3. 숫자형: "3가지 이유" / "단 10초면" 스타일
|
|
4. 질문형: "왜 아무도 안 알려줬을까?" / "이것도 몰랐어?" 스타일
|
|
5. 반전형: "알고 보니..." / "사실은 반대였다" 스타일
|
|
|
|
요구사항:
|
|
- 각 제목은 40자 이내
|
|
- 첫 2초 안에 시청자 호기심 폭발 (스크롤 멈춤 유도)
|
|
- 감정 유발 단어 필수 (충격, 소름, 미친, 레전드, 역대급 등)
|
|
- 한국어로 작성
|
|
- 이모지 1-2개 포함
|
|
- 트렌딩 키워드의 맥락을 정확히 반영
|
|
|
|
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
|
|
{
|
|
"titles": [
|
|
{"title": "제목1", "hook": "이 제목이 효과적인 이유 한줄", "pattern": "충격형"},
|
|
{"title": "제목2", "hook": "이유", "pattern": "비교형"},
|
|
{"title": "제목3", "hook": "이유", "pattern": "숫자형"},
|
|
{"title": "제목4", "hook": "이유", "pattern": "질문형"},
|
|
{"title": "제목5", "hook": "이유", "pattern": "반전형"}
|
|
]
|
|
}
|
|
PROMPT;
|
|
|
|
$result = $this->callGemini($prompt);
|
|
|
|
if (! $result) {
|
|
return [];
|
|
}
|
|
|
|
$parsed = $this->parseJsonResponse($result);
|
|
|
|
return $parsed['titles'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* 제목 → 장면별 시나리오 생성 (Veo 3.1 공식 가이드 기반 프롬프트)
|
|
*/
|
|
public function generateScenario(string $title, string $keyword = ''): array
|
|
{
|
|
$prompt = <<<PROMPT
|
|
당신은 조회수 1000만 이상 YouTube Shorts 전문 크리에이터이자 영화 촬영감독입니다.
|
|
|
|
영상 제목: "{$title}"
|
|
키워드: "{$keyword}"
|
|
|
|
이 제목으로 40초 분량의 YouTube Shorts 영상 시나리오를 작성해주세요.
|
|
|
|
=== 핵심 원칙: 초반 3초가 생사를 결정한다 ===
|
|
- Shorts는 스크롤 한 번이면 넘어간다. 첫 3초 안에 시청자를 "멈추게" 해야 한다.
|
|
- 장면 1의 나레이션 첫 문장은 반드시 충격/의문/공감 중 하나로 시작 (예: "이거 모르면 손해입니다", "방금 소름 돋았어요", "설마 아직도 이렇게 하세요?")
|
|
- 절대 "안녕하세요", "오늘은~", "여러분~" 같은 평범한 인사로 시작하지 마세요
|
|
|
|
=== 나레이션 작성 규칙 (매우 중요) ===
|
|
- 말투: 반말 or 친근한 존댓말 (방송 톤X, 친구한테 신기한 걸 알려주는 톤O)
|
|
- 속도감: TTS가 1.4배속으로 빠르게 재생됨. 한 장면당 3~4문장 (장면당 60~100자). 빈 시간 없이 빽빽하게 채워라.
|
|
- 문장 구분: 반드시 마침표(.) 또는 느낌표(!) 또는 물음표(?)로 문장을 끝내라. 자막이 문장 단위로 전환된다.
|
|
- 한 문장 길이: 10~25자 이내의 짧고 펀치감 있는 문장. 긴 문장 금지.
|
|
- 매 장면마다 한 가지 "놀라운 팩트" 또는 "감정 변화"가 있어야 한다
|
|
- 뻔한 설명 금지. "~라고 합니다", "~인데요" 같은 수동적 표현 대신 단정적이고 강렬한 어투 사용
|
|
- 마지막 장면에서 "좋아요/구독/알림설정" 같은 CTA 절대 금지. 대신 여운이 남는 한마디 또는 강렬한 마무리
|
|
|
|
=== 나레이션 절대 금지 사항 (TTS가 읽어버림) ===
|
|
- 이모지 절대 금지: 😊🔥💪❤️ 등 모든 이모지/이모티콘 사용 금지
|
|
- 특수 표현 금지: *강조*, (효과음), [동작], ~물결, ○기호 등 사용 금지
|
|
- 순수 한글 텍스트만 작성. TTS가 음성으로 변환하므로 사람이 말하는 것처럼 자연스러운 문장만 허용
|
|
- 숫자는 한글로 표기 (예: "3가지" → "세 가지", "100%" → "백 퍼센트")
|
|
|
|
=== 나레이션 좋은 예시 ===
|
|
- "이거 매일 먹어봐요. 얼굴이 확 달라집니다."
|
|
- "과학자들도 설명 못 한대요. 왜냐면요."
|
|
- "근데 진짜 무서운 건요. 이 다음이에요."
|
|
|
|
=== 나레이션 나쁜 예시 (절대 이렇게 쓰지 마세요) ===
|
|
- "안녕하세요, 오늘은 ○○에 대해 알아보겠습니다." (평범한 시작)
|
|
- "맛있게 먹고 행복해지세요!😊" (이모지 포함 - TTS가 읽음)
|
|
- "이건 진짜 *소름* 돋는 사실인데요~" (특수기호 포함)
|
|
- "3가지 방법을 알려드릴게요!" (숫자 한글 미변환)
|
|
|
|
=== 장면 구성 패턴 (5장면, 총 40초) ===
|
|
장면 1 (5초): HOOK - extreme close-up 또는 whip pan → 충격/의문/공감으로 3초 안에 시청자 잡기
|
|
장면 2 (8초): CONTEXT - medium shot, 왜 이게 중요한지 빠르게 설명
|
|
장면 3 (10초): DEVELOPMENT - tracking shot, 핵심 정보 전달 (가장 밀도 높은 구간)
|
|
장면 4 (10초): CLIMAX - dramatic camera movement, 반전 또는 가장 충격적인 팩트
|
|
장면 5 (7초): PUNCHLINE - close-up, 여운 남기는 강렬한 한마디로 끝 (CTA 절대 금지)
|
|
|
|
=== visual_prompt 작성 규칙 (Veo 3.1 공식 프롬프팅 가이드) ===
|
|
|
|
반드시 아래 5개 요소를 모두 포함하여 영어로 작성:
|
|
|
|
1. SHOT TYPE (필수 1개 선택):
|
|
- extreme close-up, close-up, medium close-up, medium shot, medium wide shot, wide shot, extreme wide shot, bird's-eye view, over-the-shoulder shot
|
|
|
|
2. CAMERA MOVEMENT (필수 1개 선택):
|
|
- static, slow pan left/right, tilt up/down, dolly in/out, tracking shot, crane shot, aerial shot, handheld, arc shot, whip pan, zoom in/out
|
|
|
|
3. LIGHTING (필수, 구체적으로):
|
|
- golden hour sunlight, dramatic side lighting, soft diffused light, neon glow, Rembrandt lighting, high-key lighting, low-key lighting, backlit silhouette, warm ambient light, cool blue moonlight
|
|
|
|
4. STYLE/QUALITY (필수):
|
|
- cinematic 4K, shot on 35mm film, shallow depth of field, film grain, anamorphic lens flare, bokeh background, high contrast, muted color palette, vibrant saturated colors
|
|
|
|
5. ACTION (한 장면에 하나의 주요 동작만):
|
|
- 명확한 하나의 동작을 묘사 (예: "picks up the phone", "turns around slowly", "looks directly at camera")
|
|
|
|
=== 추가 필수 규칙 ===
|
|
- 등장인물: 모든 장면에 동일 인물 = "a young Korean woman in her 20s with shoulder-length black hair, wearing [첫 장면에서 정한 구체적 의상]"
|
|
- 의상은 첫 장면에서 구체적으로 설정하고 모든 장면에서 동일하게 유지
|
|
- 표정/감정: "with a shocked expression", "looking curious", "smiling warmly" 등 구체적 묘사
|
|
- 배경: 구체적 장소 + 소품 묘사 (예: "a modern Seoul café with exposed brick walls and hanging plants")
|
|
- 9:16 세로 구도: 인물을 화면 중앙에 배치, 상반신 중심 프레이밍
|
|
- 장면 간 시각적 연결: 동일 색상 팔레트, 동일 의상, 동일 장소 또는 자연스러운 장소 전환
|
|
|
|
=== visual_prompt 좋은 예시 ===
|
|
"Medium shot, slow dolly in. A young Korean woman in her 20s with shoulder-length black hair, wearing a cream knit sweater, sitting at a cozy café table. She looks down at her phone with a shocked expression, mouth slightly open. Warm golden hour sunlight streams through the window. Shallow depth of field, cinematic 4K, shot on 35mm film. Steam rises from a coffee cup in the foreground."
|
|
|
|
=== visual_prompt 나쁜 예시 (이렇게 작성하지 마세요) ===
|
|
"A woman looking at phone in café, 4K cinematic"
|
|
|
|
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
|
|
{
|
|
"title": "{$title}",
|
|
"scenes": [
|
|
{
|
|
"scene_number": 1,
|
|
"duration": 5,
|
|
"scene_role": "HOOK",
|
|
"narration": "짧은 문장. 짧은 문장. 짧은 문장! (순수 한글만, 이모지 절대금지, 40~70자, 한 문장 10~20자)",
|
|
"visual_prompt": "Shot type, camera movement. Character description with specific clothing, action and expression. Lighting description. Style/quality keywords. Background and props detail.",
|
|
"mood": "shocking"
|
|
}
|
|
],
|
|
"total_duration": 40,
|
|
"bgm_mood": "upbeat, energetic",
|
|
"character_description": "첫 장면의 인물 설정을 여기에 요약 (의상, 헤어 등)"
|
|
}
|
|
PROMPT;
|
|
|
|
$result = $this->callGemini($prompt);
|
|
|
|
if (! $result) {
|
|
return [];
|
|
}
|
|
|
|
return $this->parseJsonResponse($result) ?: [];
|
|
}
|
|
|
|
/**
|
|
* Gemini API 호출 (텍스트 전용 - 기존 호환)
|
|
*/
|
|
private function callGemini(string $prompt): ?string
|
|
{
|
|
return $this->callGeminiWithParts([['text' => $prompt]]);
|
|
}
|
|
|
|
/**
|
|
* Gemini API 호출 (멀티모달 지원 - 텍스트 + 이미지)
|
|
*
|
|
* @param array $parts [['text' => '...'], ['inlineData' => ['mimeType' => '...', 'data' => '...']]]
|
|
*/
|
|
public function callGeminiWithParts(array $parts, float $temperature = 0.9, int $maxTokens = 4096): ?string
|
|
{
|
|
if (! $this->config) {
|
|
Log::error('GeminiScriptService: 활성화된 Gemini 설정이 없습니다. (시스템 > AI 설정 확인)');
|
|
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$model = $this->config->model ?: 'gemini-3.0-flash';
|
|
$body = [
|
|
'contents' => [
|
|
[
|
|
'parts' => $parts,
|
|
],
|
|
],
|
|
'generationConfig' => [
|
|
'temperature' => $temperature,
|
|
'maxOutputTokens' => $maxTokens,
|
|
'responseMimeType' => 'application/json',
|
|
],
|
|
];
|
|
|
|
if ($this->config->isVertexAi()) {
|
|
$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";
|
|
|
|
$body['contents'][0]['role'] = 'user';
|
|
|
|
$response = Http::withToken($accessToken)
|
|
->timeout(120)
|
|
->post($url, $body);
|
|
} else {
|
|
$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(120)->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 응답 파싱 (public - 외부 서비스에서도 사용)
|
|
*/
|
|
public function parseJson(string $text): ?array
|
|
{
|
|
return $this->parseJsonResponse($text);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|