diff --git a/app/Services/Video/GeminiScriptService.php b/app/Services/Video/GeminiScriptService.php index 82290968..3a919657 100644 --- a/app/Services/Video/GeminiScriptService.php +++ b/app/Services/Video/GeminiScriptService.php @@ -142,15 +142,17 @@ public function generateScenario(string $title, string $keyword = ''): array === 나레이션 작성 규칙 (매우 중요) === - 말투: 반말 or 친근한 존댓말 (방송 톤X, 친구한테 신기한 걸 알려주는 톤O) -- 속도감: 한 장면당 2~3문장, 빠르게 말하는 것을 전제로 글자 수를 늘려라 (장면당 50~80자) +- 속도감: TTS가 1.5배속으로 재생되므로, 한 장면당 3~4문장으로 밀도 있게 작성 (장면당 60~100자) +- 문장 구분: 반드시 마침표(.) 또는 느낌표(!) 또는 물음표(?)로 문장을 끝내라. 자막이 문장 단위로 전환된다. +- 한 문장 길이: 15~25자 이내의 짧고 펀치감 있는 문장. 긴 문장 금지. - 매 장면마다 한 가지 "놀라운 팩트" 또는 "감정 변화"가 있어야 한다 - 뻔한 설명 금지. "~라고 합니다", "~인데요" 같은 수동적 표현 대신 단정적이고 강렬한 어투 사용 - 마지막 장면에서 "좋아요/구독/알림설정" 같은 CTA 절대 금지. 대신 여운이 남는 한마디 또는 강렬한 마무리 -=== 나레이션 좋은 예시 === -- "이 음식 매일 먹으면 얼굴이 확 달라집니다. 진짜예요." -- "소름 돋는 게, 과학자들도 이걸 설명 못 한대요." -- "근데 진짜 무서운 건 이 다음이에요." +=== 나레이션 좋은 예시 (한 문장=15~25자, 마침표로 구분) === +- "이거 매일 먹어봐요. 얼굴이 확 달라집니다. 진짜예요." +- "소름 돋는 거 알려줄게요. 과학자들도 설명 못 한대요. 왜냐면요." +- "근데 진짜 무서운 건요. 이 다음이에요. 절대 넘기지 마세요." === 나레이션 나쁜 예시 (절대 이렇게 쓰지 마세요) === - "안녕하세요, 오늘은 ○○에 대해 알아보겠습니다." @@ -206,7 +208,7 @@ public function generateScenario(string $title, string $keyword = ''): array "scene_number": 1, "duration": 5, "scene_role": "HOOK", - "narration": "나레이션 텍스트 (한국어, 50~80자, 충격/의문/공감으로 시작)", + "narration": "짧은 문장1. 짧은 문장2. 짧은 문장3! (한국어, 60~100자, 한 문장 15~25자, 반드시 마침표/느낌표/물음표로 구분)", "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" } diff --git a/app/Services/Video/TtsService.php b/app/Services/Video/TtsService.php index d9d8756f..020bebbe 100644 --- a/app/Services/Video/TtsService.php +++ b/app/Services/Video/TtsService.php @@ -31,9 +31,9 @@ public function synthesize(string $text, string $savePath, array $options = []): try { $languageCode = $options['language_code'] ?? 'ko-KR'; - $voiceName = $options['voice_name'] ?? 'ko-KR-Wavenet-A'; - $speakingRate = $options['speaking_rate'] ?? 1.0; - $pitch = $options['pitch'] ?? 0.0; + $voiceName = $options['voice_name'] ?? 'ko-KR-Neural2-C'; + $speakingRate = $options['speaking_rate'] ?? 1.5; + $pitch = $options['pitch'] ?? 2.0; $response = Http::withToken($token) ->timeout(30) diff --git a/app/Services/Video/VideoAssemblyService.php b/app/Services/Video/VideoAssemblyService.php index d04b1200..6f06ebb4 100644 --- a/app/Services/Video/VideoAssemblyService.php +++ b/app/Services/Video/VideoAssemblyService.php @@ -184,7 +184,7 @@ public function concatNarrations(array $audioPaths, array $scenes, string $outpu } /** - * ASS 자막 파일 생성 + * ASS 자막 파일 생성 (한 문장 단위로 분리, 음성 싱크) * * @param array $scenes [{scene_number, narration, duration}, ...] */ @@ -221,14 +221,42 @@ public function generateAssSubtitle(array $scenes, string $outputPath): string continue; } - $startTime = $this->formatAssTime($currentTime); - $endTime = $this->formatAssTime($currentTime + $duration); + // 나레이션을 문장 단위로 분리 + $sentences = $this->splitIntoSentences($narration); - // 긴 텍스트는 줄바꿈 - $text = $this->wrapText($narration, 12); - $text = str_replace("\n", "\\N", $text); + // 총 글자 수 기준으로 각 문장의 시간 비율 계산 + $totalChars = array_sum(array_map('mb_strlen', $sentences)); + // 나레이션이 차지하는 시간 (장면 끝에 0.3초 여백) + $activeTime = max($duration - 0.3, $duration * 0.9); + $offset = 0.0; - $ass .= "Dialogue: 0,{$startTime},{$endTime},Default,,0,0,0,,{$text}\n"; + foreach ($sentences as $sentence) { + $sentence = trim($sentence); + if (empty($sentence)) { + continue; + } + + $ratio = mb_strlen($sentence) / max($totalChars, 1); + $sentDuration = max($activeTime * $ratio, 0.8); + + // 장면 경계 초과 방지 + if ($offset + $sentDuration > $duration) { + $sentDuration = $duration - $offset; + } + if ($sentDuration <= 0) { + break; + } + + $startTime = $this->formatAssTime($currentTime + $offset); + $endTime = $this->formatAssTime($currentTime + $offset + $sentDuration); + + $text = $this->wrapText($sentence, 14); + $text = str_replace("\n", "\\N", $text); + + $ass .= "Dialogue: 0,{$startTime},{$endTime},Default,,0,0,0,,{$text}\n"; + + $offset += $sentDuration; + } $currentTime += $duration; } @@ -238,6 +266,22 @@ public function generateAssSubtitle(array $scenes, string $outputPath): string return $outputPath; } + /** + * 나레이션 텍스트를 문장 단위로 분리 + */ + private function splitIntoSentences(string $text): array + { + // 마침표, 느낌표, 물음표, 물결표 뒤에서 분리 + $sentences = preg_split('/(?<=[.!?~。!?])\s*/', $text, -1, PREG_SPLIT_NO_EMPTY); + + // 분리 안 되고 30자 이상이면 쉼표에서 분리 + if (count($sentences) <= 1 && mb_strlen($text) > 30) { + $sentences = preg_split('/(?<=[,,])\s*/', $text, -1, PREG_SPLIT_NO_EMPTY); + } + + return ! empty($sentences) ? $sentences : [$text]; + } + /** * 최종 합성: 영상 + 나레이션 + BGM + 자막 */