fix:자막-음성 싱크 개선 + 나레이션 밀도 증가 + BGM 볼륨 상향

1. 자막 싱크: ffprobe로 실제 TTS 오디오 길이 측정 → 자막 타이밍 반영
   - 기존: 장면 길이 * 0.75 추정 → 음성과 자막 불일치
   - 변경: 실제 나레이션 오디오 길이 기반 문장별 타이밍 계산
2. 나레이션 밀도: 장면당 40~70자 → 60~100자 (빈 시간 없이 채움)
3. BGM 볼륨: 0.4 → 1.2 (안 들리던 문제 해결)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-15 14:50:52 +09:00
parent 0f162d1df9
commit e4d8dbddb3
3 changed files with 47 additions and 17 deletions

View File

@@ -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, '최종 합성 중...');

View File

@@ -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 절대 금지. 대신 여운이 남는 한마디 또는 강렬한 마무리

View File

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