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:
@@ -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, '최종 합성 중...');
|
||||
|
||||
@@ -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 절대 금지. 대신 여운이 남는 한마디 또는 강렬한 마무리
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user