diff --git a/app/Jobs/VideoGenerationJob.php b/app/Jobs/VideoGenerationJob.php index ab23280c..496b6b89 100644 --- a/app/Jobs/VideoGenerationJob.php +++ b/app/Jobs/VideoGenerationJob.php @@ -254,9 +254,17 @@ public function handle( $narrationConcatPath = "{$workDir}/narration_full.mp3"; $assembly->concatNarrations($activeNarrationPaths, $activeScenes, $narrationConcatPath); - // 5-3. 자막 생성 (성공한 장면만) + // 5-3. 자막 생성 (실제 TTS 오디오 길이 기반 싱크) + $narrationDurations = []; + foreach ($activeNarrationPaths as $sceneNum => $narPath) { + $dur = $assembly->getAudioDuration($narPath); + if ($dur > 0) { + $narrationDurations[$sceneNum] = $dur; + } + } + $subtitlePath = "{$workDir}/subtitles.ass"; - $assembly->generateAssSubtitle($activeScenes, $subtitlePath); + $assembly->generateAssSubtitle($activeScenes, $subtitlePath, $narrationDurations); // 5-4. 최종 합성 $video->updateProgress(VideoGeneration::STATUS_ASSEMBLING, 90, '최종 합성 중...'); diff --git a/app/Services/Video/GeminiScriptService.php b/app/Services/Video/GeminiScriptService.php index 92650a32..42732805 100644 --- a/app/Services/Video/GeminiScriptService.php +++ b/app/Services/Video/GeminiScriptService.php @@ -238,9 +238,9 @@ public function generateScenario(string $title, string $keyword = ''): array === 나레이션 작성 규칙 (매우 중요) === - 말투: 반말 or 친근한 존댓말 (방송 톤X, 친구한테 신기한 걸 알려주는 톤O) -- 속도감: TTS가 1.2배속으로 재생되므로, 한 장면당 2~3문장 (장면당 40~70자) +- 속도감: TTS가 1.4배속으로 빠르게 재생됨. 한 장면당 3~4문장 (장면당 60~100자). 빈 시간 없이 빽빽하게 채워라. - 문장 구분: 반드시 마침표(.) 또는 느낌표(!) 또는 물음표(?)로 문장을 끝내라. 자막이 문장 단위로 전환된다. -- 한 문장 길이: 10~20자 이내의 짧고 펀치감 있는 문장. 긴 문장 금지. +- 한 문장 길이: 10~25자 이내의 짧고 펀치감 있는 문장. 긴 문장 금지. - 매 장면마다 한 가지 "놀라운 팩트" 또는 "감정 변화"가 있어야 한다 - 뻔한 설명 금지. "~라고 합니다", "~인데요" 같은 수동적 표현 대신 단정적이고 강렬한 어투 사용 - 마지막 장면에서 "좋아요/구독/알림설정" 같은 CTA 절대 금지. 대신 여운이 남는 한마디 또는 강렬한 마무리 diff --git a/app/Services/Video/VideoAssemblyService.php b/app/Services/Video/VideoAssemblyService.php index 6f29d301..fb7bca07 100644 --- a/app/Services/Video/VideoAssemblyService.php +++ b/app/Services/Video/VideoAssemblyService.php @@ -184,11 +184,31 @@ public function concatNarrations(array $audioPaths, array $scenes, string $outpu } /** - * ASS 자막 파일 생성 (한 문장 단위로 분리, 음성 싱크) + * ffprobe로 오디오 파일의 실제 재생 시간 측정 + */ + public function getAudioDuration(string $path): float + { + if (! file_exists($path)) { + return 0.0; + } + + $cmd = sprintf( + 'ffprobe -v quiet -show_entries format=duration -of csv=p=0 %s 2>/dev/null', + escapeshellarg($path) + ); + + $output = trim(shell_exec($cmd) ?? ''); + + return (float) $output; + } + + /** + * ASS 자막 파일 생성 (실제 TTS 오디오 길이 기반 싱크) * * @param array $scenes [{scene_number, narration, duration}, ...] + * @param array $narrationDurations [scene_number => 실제 오디오 초] (ffprobe 측정값) */ - public function generateAssSubtitle(array $scenes, string $outputPath): string + public function generateAssSubtitle(array $scenes, string $outputPath, array $narrationDurations = []): string { $dir = dirname($outputPath); if (! is_dir($dir)) { @@ -203,7 +223,6 @@ public function generateAssSubtitle(array $scenes, string $outputPath): string $ass .= "[V4+ Styles]\n"; $ass .= "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"; - // Fontsize=96(2배), 흰색 글자+검정 외곽선(5px)+검정 그림자(2px), Alignment=5(화면 중앙) $ass .= "Style: Default,NanumGothic,96,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,5,2,5,40,40,0,1\n\n"; $ass .= "[Events]\n"; @@ -212,11 +231,12 @@ public function generateAssSubtitle(array $scenes, string $outputPath): string $currentTime = 0; foreach ($scenes as $scene) { - $duration = $scene['duration'] ?? 8; + $sceneDuration = $scene['duration'] ?? 8; + $sceneNum = $scene['scene_number'] ?? 0; $narration = $scene['narration'] ?? ''; if (empty($narration)) { - $currentTime += $duration; + $currentTime += $sceneDuration; continue; } @@ -225,10 +245,12 @@ public function generateAssSubtitle(array $scenes, string $outputPath): string $cleanNarration = $this->stripEmoji($narration); $sentences = $this->splitIntoSentences($cleanNarration); - // 총 글자 수 기준으로 각 문장의 시간 비율 계산 + // 실제 TTS 오디오 길이 사용 (없으면 장면 길이의 90%로 추정) + $audioDuration = $narrationDurations[$sceneNum] ?? ($sceneDuration * 0.9); + // 장면 길이를 초과하지 않도록 제한 + $activeTime = min($audioDuration, $sceneDuration); + $totalChars = array_sum(array_map('mb_strlen', $sentences)); - // 나레이션이 차지하는 시간 (1.4x 속도에 맞춰 75% 구간에 압축) - $activeTime = $duration * 0.75; $offset = 0.0; foreach ($sentences as $sentence) { @@ -238,11 +260,11 @@ public function generateAssSubtitle(array $scenes, string $outputPath): string } $ratio = mb_strlen($sentence) / max($totalChars, 1); - $sentDuration = max($activeTime * $ratio, 1.5); + $sentDuration = max($activeTime * $ratio, 1.0); // 장면 경계 초과 방지 - if ($offset + $sentDuration > $duration) { - $sentDuration = $duration - $offset; + if ($offset + $sentDuration > $sceneDuration) { + $sentDuration = $sceneDuration - $offset; } if ($sentDuration <= 0) { break; @@ -259,7 +281,7 @@ public function generateAssSubtitle(array $scenes, string $outputPath): string $offset += $sentDuration; } - $currentTime += $duration; + $currentTime += $sceneDuration; } file_put_contents($outputPath, $ass); @@ -364,7 +386,7 @@ public function assemble( // BGM 추가 if ($bgmPath && file_exists($bgmPath)) { $inputs[] = '-i ' . escapeshellarg($bgmPath); - $filterParts[] = "[{$audioIndex}:a]volume=0.4[bgm]"; + $filterParts[] = "[{$audioIndex}:a]volume=1.2[bgm]"; $audioIndex++; }