From 85654799be84d2e065a8b1260ed63118971b554f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sun, 15 Feb 2026 13:34:04 +0900 Subject: [PATCH] =?UTF-8?q?fix:=EC=9D=B4=EB=AA=A8=EC=A7=80=20TTS=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EB=B2=84=EA=B7=B8=20+=20=EC=9E=90?= =?UTF-8?q?=EB=A7=89=20=EA=B3=BC=EB=8B=A4=20=ED=91=9C=EC=8B=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이모지/특수문자 제거: - TtsService: TTS 전송 전 이모지, *강조*, (효과음), [동작] 등 제거 - VideoAssemblyService: 자막 생성 시에도 동일하게 이모지 제거 - 유니코드 이모지 전체 블록 커버 (이모티콘~태그 문자) 자막 분리 로직 개선: - 5자 미만 조각만 병합 (기존 10자 → 과도한 병합 제거) - 정상 문장(5자+)은 독립 자막으로 표시 - 장면당 2~3개 자막으로 깔끔하게 전환 시나리오 프롬프트 수정: - 이모지/이모티콘 절대 금지 규칙 명시 - *강조*, (효과음), [동작], ○기호 금지 - 숫자 한글 표기 권장 (3가지 → 세 가지) - 장면당 글자 수 40~70자로 조정 (1.2x 속도에 맞춤) - 한 문장 10~20자로 축소 (자막 가독성) Co-Authored-By: Claude Opus 4.6 --- app/Services/Video/GeminiScriptService.php | 28 +++++---- app/Services/Video/TtsService.php | 40 +++++++++++- app/Services/Video/VideoAssemblyService.php | 70 +++++++++++++-------- 3 files changed, 101 insertions(+), 37 deletions(-) diff --git a/app/Services/Video/GeminiScriptService.php b/app/Services/Video/GeminiScriptService.php index 9e67bf20..e4cbd43b 100644 --- a/app/Services/Video/GeminiScriptService.php +++ b/app/Services/Video/GeminiScriptService.php @@ -215,23 +215,29 @@ public function generateScenario(string $title, string $keyword = ''): array === 나레이션 작성 규칙 (매우 중요) === - 말투: 반말 or 친근한 존댓말 (방송 톤X, 친구한테 신기한 걸 알려주는 톤O) -- 속도감: TTS가 1.5배속으로 재생되므로, 한 장면당 3~4문장으로 밀도 있게 작성 (장면당 60~100자) +- 속도감: TTS가 1.2배속으로 재생되므로, 한 장면당 2~3문장 (장면당 40~70자) - 문장 구분: 반드시 마침표(.) 또는 느낌표(!) 또는 물음표(?)로 문장을 끝내라. 자막이 문장 단위로 전환된다. -- 한 문장 길이: 15~25자 이내의 짧고 펀치감 있는 문장. 긴 문장 금지. +- 한 문장 길이: 10~20자 이내의 짧고 펀치감 있는 문장. 긴 문장 금지. - 매 장면마다 한 가지 "놀라운 팩트" 또는 "감정 변화"가 있어야 한다 - 뻔한 설명 금지. "~라고 합니다", "~인데요" 같은 수동적 표현 대신 단정적이고 강렬한 어투 사용 - 마지막 장면에서 "좋아요/구독/알림설정" 같은 CTA 절대 금지. 대신 여운이 남는 한마디 또는 강렬한 마무리 -=== 나레이션 좋은 예시 (한 문장=15~25자, 마침표로 구분) === -- "이거 매일 먹어봐요. 얼굴이 확 달라집니다. 진짜예요." -- "소름 돋는 거 알려줄게요. 과학자들도 설명 못 한대요. 왜냐면요." -- "근데 진짜 무서운 건요. 이 다음이에요. 절대 넘기지 마세요." +=== 나레이션 절대 금지 사항 (TTS가 읽어버림) === +- 이모지 절대 금지: 😊🔥💪❤️ 등 모든 이모지/이모티콘 사용 금지 +- 특수 표현 금지: *강조*, (효과음), [동작], ~물결, ○기호 등 사용 금지 +- 순수 한글 텍스트만 작성. TTS가 음성으로 변환하므로 사람이 말하는 것처럼 자연스러운 문장만 허용 +- 숫자는 한글로 표기 (예: "3가지" → "세 가지", "100%" → "백 퍼센트") + +=== 나레이션 좋은 예시 === +- "이거 매일 먹어봐요. 얼굴이 확 달라집니다." +- "과학자들도 설명 못 한대요. 왜냐면요." +- "근데 진짜 무서운 건요. 이 다음이에요." === 나레이션 나쁜 예시 (절대 이렇게 쓰지 마세요) === -- "안녕하세요, 오늘은 ○○에 대해 알아보겠습니다." -- "이 영상이 도움이 되셨다면 좋아요와 구독 부탁드려요!" -- "여러분도 한번 해보시면 좋을 것 같습니다~" -- "○○라고 하는데요, 참 신기하죠?" +- "안녕하세요, 오늘은 ○○에 대해 알아보겠습니다." (평범한 시작) +- "맛있게 먹고 행복해지세요!😊" (이모지 포함 - TTS가 읽음) +- "이건 진짜 *소름* 돋는 사실인데요~" (특수기호 포함) +- "3가지 방법을 알려드릴게요!" (숫자 한글 미변환) === 장면 구성 패턴 (5장면, 총 40초) === 장면 1 (5초): HOOK - extreme close-up 또는 whip pan → 충격/의문/공감으로 3초 안에 시청자 잡기 @@ -281,7 +287,7 @@ public function generateScenario(string $title, string $keyword = ''): array "scene_number": 1, "duration": 5, "scene_role": "HOOK", - "narration": "짧은 문장1. 짧은 문장2. 짧은 문장3! (한국어, 60~100자, 한 문장 15~25자, 반드시 마침표/느낌표/물음표로 구분)", + "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" } diff --git a/app/Services/Video/TtsService.php b/app/Services/Video/TtsService.php index 45bea9e7..b9498ab7 100644 --- a/app/Services/Video/TtsService.php +++ b/app/Services/Video/TtsService.php @@ -35,11 +35,14 @@ public function synthesize(string $text, string $savePath, array $options = []): $speakingRate = $options['speaking_rate'] ?? 1.2; $pitch = $options['pitch'] ?? 0.0; + // TTS 전송 전 이모지/특수문자 제거 (읽어버리는 문제 방지) + $cleanText = $this->stripNonSpeechChars($text); + $response = Http::withToken($token) ->timeout(30) ->post('https://texttospeech.googleapis.com/v1/text:synthesize', [ 'input' => [ - 'text' => $text, + 'text' => $cleanText, ], 'voice' => [ 'languageCode' => $languageCode, @@ -119,4 +122,39 @@ public function synthesizeScenes(array $scenes, string $baseDir): array return $results; } + + /** + * TTS에 보내기 전 이모지, 특수문자, 동작 표현 제거 + */ + private function stripNonSpeechChars(string $text): string + { + // 이모지 유니코드 블록 제거 + $text = preg_replace('/[\x{1F600}-\x{1F64F}]/u', '', $text); // 이모티콘 + $text = preg_replace('/[\x{1F300}-\x{1F5FF}]/u', '', $text); // 기호/픽토그래프 + $text = preg_replace('/[\x{1F680}-\x{1F6FF}]/u', '', $text); // 교통/지도 + $text = preg_replace('/[\x{1F900}-\x{1F9FF}]/u', '', $text); // 보충 이모지 + $text = preg_replace('/[\x{1FA00}-\x{1FA6F}]/u', '', $text); // 체스/확장 + $text = preg_replace('/[\x{1FA70}-\x{1FAFF}]/u', '', $text); // 심볼 확장 + $text = preg_replace('/[\x{2600}-\x{27BF}]/u', '', $text); // 기타 심볼 + $text = preg_replace('/[\x{2700}-\x{27BF}]/u', '', $text); // Dingbats + $text = preg_replace('/[\x{FE00}-\x{FE0F}]/u', '', $text); // 변형 선택자 + $text = preg_replace('/[\x{200D}]/u', '', $text); // Zero Width Joiner + $text = preg_replace('/[\x{20E3}]/u', '', $text); // 키캡 + $text = preg_replace('/[\x{E0020}-\x{E007F}]/u', '', $text); // 태그 + + // *강조* 스타일 텍스트에서 별표만 제거 (내용은 유지) + $text = str_replace('*', '', $text); + + // (효과음), [동작] 등 괄호 표현 제거 + $text = preg_replace('/\([^)]*\)/', '', $text); + $text = preg_replace('/\[[^\]]*\]/', '', $text); + + // ○, ●, ◎ 등 특수 기호 제거 + $text = preg_replace('/[○●◎◇◆□■△▲▽▼★☆♡♥]/u', '', $text); + + // 연속 공백 정리 + $text = preg_replace('/\s+/', ' ', $text); + + return trim($text); + } } diff --git a/app/Services/Video/VideoAssemblyService.php b/app/Services/Video/VideoAssemblyService.php index d505633b..df23f4c5 100644 --- a/app/Services/Video/VideoAssemblyService.php +++ b/app/Services/Video/VideoAssemblyService.php @@ -221,8 +221,9 @@ public function generateAssSubtitle(array $scenes, string $outputPath): string continue; } - // 나레이션을 문장 단위로 분리 - $sentences = $this->splitIntoSentences($narration); + // 이모지/특수문자 제거 후 문장 분리 + $cleanNarration = $this->stripEmoji($narration); + $sentences = $this->splitIntoSentences($cleanNarration); // 총 글자 수 기준으로 각 문장의 시간 비율 계산 $totalChars = array_sum(array_map('mb_strlen', $sentences)); @@ -267,37 +268,51 @@ public function generateAssSubtitle(array $scenes, string $outputPath): string } /** - * 나레이션 텍스트를 자막 청크 단위로 분리 + * 자막용 이모지/특수문자 제거 + */ + private function stripEmoji(string $text): string + { + $text = preg_replace('/[\x{1F600}-\x{1F64F}]/u', '', $text); + $text = preg_replace('/[\x{1F300}-\x{1F5FF}]/u', '', $text); + $text = preg_replace('/[\x{1F680}-\x{1F6FF}]/u', '', $text); + $text = preg_replace('/[\x{1F900}-\x{1F9FF}]/u', '', $text); + $text = preg_replace('/[\x{1FA00}-\x{1FAFF}]/u', '', $text); + $text = preg_replace('/[\x{2600}-\x{27BF}]/u', '', $text); + $text = preg_replace('/[\x{FE00}-\x{FE0F}]/u', '', $text); + $text = preg_replace('/[\x{200D}]/u', '', $text); + $text = preg_replace('/[\x{E0020}-\x{E007F}]/u', '', $text); + $text = str_replace('*', '', $text); + $text = preg_replace('/\([^)]*\)/', '', $text); + $text = preg_replace('/\[[^\]]*\]/', '', $text); + $text = preg_replace('/[○●◎◇◆□■△▲▽▼★☆♡♥]/u', '', $text); + $text = preg_replace('/\s+/', ' ', $text); + + return trim($text); + } + + /** + * 나레이션 텍스트를 문장 단위로 분리 * - * 핵심 규칙: - * - 한 청크 최소 10자 이상 (한글자/한단어 자막 방지) - * - 장면당 2~3개 청크 목표 - * - 짧은 조각은 인접 청크에 병합 + * 규칙: 마침표/느낌표/물음표 기준으로 자르되, + * 5자 미만 조각만 다음 문장에 병합 (한글자/한단어 방지) */ private function splitIntoSentences(string $text): array { $text = trim($text); - // 전체가 25자 이하면 분리하지 않음 - if (mb_strlen($text) <= 25) { + // 전체가 20자 이하면 분리하지 않음 (한 문장) + if (mb_strlen($text) <= 20) { return [$text]; } - // 1차: 마침표/느낌표/물음표 기준으로 분리 - $rawParts = preg_split('/(?<=[.!?。!?])\s*/', $text, -1, PREG_SPLIT_NO_EMPTY); + // 마침표/느낌표/물음표 기준으로 분리 + $rawParts = preg_split('/(?<=[.!?。!?])\s*/u', $text, -1, PREG_SPLIT_NO_EMPTY); - // 분리가 안 되면 쉼표에서 시도 - if (count($rawParts) <= 1) { - $rawParts = preg_split('/(?<=[,,])\s*/', $text, -1, PREG_SPLIT_NO_EMPTY); - } - - // 그래도 분리 안 되면 원본 반환 if (count($rawParts) <= 1) { return [$text]; } - // 2차: 짧은 조각을 병합하여 최소 10자 보장 - $minChunkLength = 10; + // 5자 미만 짧은 조각만 병합 (정상 문장은 그대로 유지) $merged = []; $buffer = ''; @@ -307,18 +322,23 @@ private function splitIntoSentences(string $text): array continue; } - $buffer .= ($buffer !== '' ? ' ' : '') . $part; - - if (mb_strlen($buffer) >= $minChunkLength) { - $merged[] = $buffer; - $buffer = ''; + if ($buffer !== '') { + $buffer .= ' ' . $part; + if (mb_strlen($buffer) >= 5) { + $merged[] = $buffer; + $buffer = ''; + } + } elseif (mb_strlen($part) < 5) { + // 5자 미만이면 버퍼에 넣고 다음 조각과 합치기 + $buffer = $part; + } else { + $merged[] = $part; } } // 남은 버퍼 처리 if ($buffer !== '') { if (! empty($merged)) { - // 마지막 청크에 붙이기 $merged[count($merged) - 1] .= ' ' . $buffer; } else { $merged[] = $buffer;