TTS 설정: - 음성: Neural2-C (남성) → Neural2-A (여성) - 속도: 1.5x → 1.2x (20% 감속) - 피치: 2.0 → 0.0 (자연스러운 여성 톤) 자막 한글자/한단어 버그 수정: - 최소 청크 길이 10자 보장 (짧은 조각 인접 청크에 병합) - 전체 25자 이하면 분리하지 않고 한 블록으로 표시 - 남은 짧은 버퍼는 마지막 청크에 합치기 - 최소 표시 시간 0.8초 → 1.5초로 증가 - 줄바꿈 기준 14자 → 16자 (가독성 향상) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
477 lines
15 KiB
PHP
477 lines
15 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Video;
|
||
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
class VideoAssemblyService
|
||
{
|
||
/**
|
||
* 영상 클립 결합 (concat)
|
||
*
|
||
* @param array $clipPaths 클립 파일 경로 배열
|
||
* @return string|null 결합된 영상 경로
|
||
*/
|
||
public function concatClips(array $clipPaths, string $outputPath): ?string
|
||
{
|
||
if (empty($clipPaths)) {
|
||
return null;
|
||
}
|
||
|
||
// 클립이 1개면 그대로 복사
|
||
if (count($clipPaths) === 1) {
|
||
copy($clipPaths[0], $outputPath);
|
||
|
||
return $outputPath;
|
||
}
|
||
|
||
$dir = dirname($outputPath);
|
||
if (! is_dir($dir)) {
|
||
mkdir($dir, 0755, true);
|
||
}
|
||
|
||
// concat용 파일 리스트 생성
|
||
$listFile = "{$dir}/concat_list.txt";
|
||
$listContent = '';
|
||
|
||
foreach ($clipPaths as $path) {
|
||
$listContent .= "file " . escapeshellarg($path) . "\n";
|
||
}
|
||
|
||
file_put_contents($listFile, $listContent);
|
||
|
||
// 모든 클립을 동일 형식으로 재인코딩 후 concat
|
||
$scaledPaths = [];
|
||
foreach ($clipPaths as $i => $path) {
|
||
$scaledPath = "{$dir}/scaled_{$i}.mp4";
|
||
$scaleCmd = sprintf(
|
||
'ffmpeg -y -i %s -vf "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1" -r 30 -c:v libx264 -preset fast -crf 23 -an %s 2>&1',
|
||
escapeshellarg($path),
|
||
escapeshellarg($scaledPath)
|
||
);
|
||
exec($scaleCmd, $output, $returnCode);
|
||
|
||
if ($returnCode !== 0) {
|
||
Log::error('VideoAssemblyService: 클립 스케일링 실패', [
|
||
'clip' => $path,
|
||
'output' => implode("\n", $output),
|
||
]);
|
||
|
||
return null;
|
||
}
|
||
|
||
$scaledPaths[] = $scaledPath;
|
||
}
|
||
|
||
// 스케일된 클립 리스트
|
||
$scaledListFile = "{$dir}/scaled_list.txt";
|
||
$scaledListContent = '';
|
||
foreach ($scaledPaths as $path) {
|
||
$scaledListContent .= "file " . escapeshellarg($path) . "\n";
|
||
}
|
||
file_put_contents($scaledListFile, $scaledListContent);
|
||
|
||
$cmd = sprintf(
|
||
'ffmpeg -y -f concat -safe 0 -i %s -c copy %s 2>&1',
|
||
escapeshellarg($scaledListFile),
|
||
escapeshellarg($outputPath)
|
||
);
|
||
|
||
exec($cmd, $output, $returnCode);
|
||
|
||
// 임시 파일 정리
|
||
@unlink($listFile);
|
||
@unlink($scaledListFile);
|
||
foreach ($scaledPaths as $path) {
|
||
@unlink($path);
|
||
}
|
||
|
||
if ($returnCode !== 0) {
|
||
Log::error('VideoAssemblyService: 클립 결합 실패', [
|
||
'output' => implode("\n", $output),
|
||
]);
|
||
|
||
return null;
|
||
}
|
||
|
||
return $outputPath;
|
||
}
|
||
|
||
/**
|
||
* 나레이션 오디오 파일들을 하나로 합치기
|
||
*
|
||
* @param array $audioPaths [scene_number => file_path]
|
||
* @param array $scenes 시나리오 장면 정보 (duration 사용)
|
||
*/
|
||
public function concatNarrations(array $audioPaths, array $scenes, string $outputPath): ?string
|
||
{
|
||
if (empty($audioPaths)) {
|
||
return null;
|
||
}
|
||
|
||
$dir = dirname($outputPath);
|
||
if (! is_dir($dir)) {
|
||
mkdir($dir, 0755, true);
|
||
}
|
||
|
||
// 각 나레이션을 장면 길이에 정확히 맞춤 (atrim으로 자르고 apad로 채움)
|
||
$paddedPaths = [];
|
||
foreach ($scenes as $scene) {
|
||
$sceneNum = $scene['scene_number'];
|
||
$duration = $scene['duration'] ?? 8;
|
||
|
||
if (isset($audioPaths[$sceneNum])) {
|
||
$paddedPath = "{$dir}/narration_padded_{$sceneNum}.mp3";
|
||
// atrim: 장면보다 긴 나레이션은 잘라냄, apad: 짧으면 무음으로 채움
|
||
$cmd = sprintf(
|
||
'ffmpeg -y -i %s -af "atrim=0:%d,apad=whole_dur=%d" -c:a libmp3lame -q:a 2 %s 2>&1',
|
||
escapeshellarg($audioPaths[$sceneNum]),
|
||
$duration,
|
||
$duration,
|
||
escapeshellarg($paddedPath)
|
||
);
|
||
exec($cmd, $output, $returnCode);
|
||
|
||
if ($returnCode === 0) {
|
||
$paddedPaths[] = $paddedPath;
|
||
} else {
|
||
$paddedPaths[] = $audioPaths[$sceneNum];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (empty($paddedPaths)) {
|
||
return null;
|
||
}
|
||
|
||
// 나레이션 결합
|
||
if (count($paddedPaths) === 1) {
|
||
copy($paddedPaths[0], $outputPath);
|
||
} else {
|
||
$listFile = "{$dir}/narration_list.txt";
|
||
$listContent = '';
|
||
foreach ($paddedPaths as $path) {
|
||
$listContent .= "file " . escapeshellarg($path) . "\n";
|
||
}
|
||
file_put_contents($listFile, $listContent);
|
||
|
||
$cmd = sprintf(
|
||
'ffmpeg -y -f concat -safe 0 -i %s -c:a libmp3lame -q:a 2 %s 2>&1',
|
||
escapeshellarg($listFile),
|
||
escapeshellarg($outputPath)
|
||
);
|
||
|
||
exec($cmd, $output, $returnCode);
|
||
@unlink($listFile);
|
||
|
||
if ($returnCode !== 0) {
|
||
Log::error('VideoAssemblyService: 나레이션 결합 실패', [
|
||
'output' => implode("\n", $output),
|
||
]);
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 임시 패딩 파일 정리
|
||
foreach ($paddedPaths as $path) {
|
||
if (str_contains($path, 'narration_padded_')) {
|
||
@unlink($path);
|
||
}
|
||
}
|
||
|
||
return $outputPath;
|
||
}
|
||
|
||
/**
|
||
* ASS 자막 파일 생성 (한 문장 단위로 분리, 음성 싱크)
|
||
*
|
||
* @param array $scenes [{scene_number, narration, duration}, ...]
|
||
*/
|
||
public function generateAssSubtitle(array $scenes, string $outputPath): string
|
||
{
|
||
$dir = dirname($outputPath);
|
||
if (! is_dir($dir)) {
|
||
mkdir($dir, 0755, true);
|
||
}
|
||
|
||
$ass = "[Script Info]\n";
|
||
$ass .= "ScriptType: v4.00+\n";
|
||
$ass .= "PlayResX: 1080\n";
|
||
$ass .= "PlayResY: 1920\n";
|
||
$ass .= "WrapStyle: 0\n\n";
|
||
|
||
$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";
|
||
$ass .= "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n";
|
||
|
||
$currentTime = 0;
|
||
|
||
foreach ($scenes as $scene) {
|
||
$duration = $scene['duration'] ?? 8;
|
||
$narration = $scene['narration'] ?? '';
|
||
|
||
if (empty($narration)) {
|
||
$currentTime += $duration;
|
||
|
||
continue;
|
||
}
|
||
|
||
// 나레이션을 문장 단위로 분리
|
||
$sentences = $this->splitIntoSentences($narration);
|
||
|
||
// 총 글자 수 기준으로 각 문장의 시간 비율 계산
|
||
$totalChars = array_sum(array_map('mb_strlen', $sentences));
|
||
// 나레이션이 차지하는 시간 (장면 끝에 0.3초 여백)
|
||
$activeTime = max($duration - 0.3, $duration * 0.9);
|
||
$offset = 0.0;
|
||
|
||
foreach ($sentences as $sentence) {
|
||
$sentence = trim($sentence);
|
||
if (empty($sentence)) {
|
||
continue;
|
||
}
|
||
|
||
$ratio = mb_strlen($sentence) / max($totalChars, 1);
|
||
$sentDuration = max($activeTime * $ratio, 1.5);
|
||
|
||
// 장면 경계 초과 방지
|
||
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, 16);
|
||
$text = str_replace("\n", "\\N", $text);
|
||
|
||
$ass .= "Dialogue: 0,{$startTime},{$endTime},Default,,0,0,0,,{$text}\n";
|
||
|
||
$offset += $sentDuration;
|
||
}
|
||
|
||
$currentTime += $duration;
|
||
}
|
||
|
||
file_put_contents($outputPath, $ass);
|
||
|
||
return $outputPath;
|
||
}
|
||
|
||
/**
|
||
* 나레이션 텍스트를 자막 청크 단위로 분리
|
||
*
|
||
* 핵심 규칙:
|
||
* - 한 청크 최소 10자 이상 (한글자/한단어 자막 방지)
|
||
* - 장면당 2~3개 청크 목표
|
||
* - 짧은 조각은 인접 청크에 병합
|
||
*/
|
||
private function splitIntoSentences(string $text): array
|
||
{
|
||
$text = trim($text);
|
||
|
||
// 전체가 25자 이하면 분리하지 않음
|
||
if (mb_strlen($text) <= 25) {
|
||
return [$text];
|
||
}
|
||
|
||
// 1차: 마침표/느낌표/물음표 기준으로 분리
|
||
$rawParts = preg_split('/(?<=[.!?。!?])\s*/', $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;
|
||
$merged = [];
|
||
$buffer = '';
|
||
|
||
foreach ($rawParts as $part) {
|
||
$part = trim($part);
|
||
if ($part === '') {
|
||
continue;
|
||
}
|
||
|
||
$buffer .= ($buffer !== '' ? ' ' : '') . $part;
|
||
|
||
if (mb_strlen($buffer) >= $minChunkLength) {
|
||
$merged[] = $buffer;
|
||
$buffer = '';
|
||
}
|
||
}
|
||
|
||
// 남은 버퍼 처리
|
||
if ($buffer !== '') {
|
||
if (! empty($merged)) {
|
||
// 마지막 청크에 붙이기
|
||
$merged[count($merged) - 1] .= ' ' . $buffer;
|
||
} else {
|
||
$merged[] = $buffer;
|
||
}
|
||
}
|
||
|
||
return ! empty($merged) ? $merged : [$text];
|
||
}
|
||
|
||
/**
|
||
* 최종 합성: 영상 + 나레이션 + BGM + 자막
|
||
*/
|
||
public function assemble(
|
||
string $videoPath,
|
||
?string $narrationPath,
|
||
?string $bgmPath,
|
||
string $subtitlePath,
|
||
string $outputPath
|
||
): ?string {
|
||
$dir = dirname($outputPath);
|
||
if (! is_dir($dir)) {
|
||
mkdir($dir, 0755, true);
|
||
}
|
||
|
||
$inputs = ['-i ' . escapeshellarg($videoPath)];
|
||
$filterParts = [];
|
||
$mapParts = ['-map 0:v'];
|
||
$audioIndex = 1;
|
||
|
||
// 나레이션 추가
|
||
if ($narrationPath && file_exists($narrationPath)) {
|
||
$inputs[] = '-i ' . escapeshellarg($narrationPath);
|
||
$filterParts[] = "[{$audioIndex}:a]volume=2.0[nar]";
|
||
$audioIndex++;
|
||
}
|
||
|
||
// BGM 추가
|
||
if ($bgmPath && file_exists($bgmPath)) {
|
||
$inputs[] = '-i ' . escapeshellarg($bgmPath);
|
||
$filterParts[] = "[{$audioIndex}:a]volume=0.4[bgm]";
|
||
$audioIndex++;
|
||
}
|
||
|
||
// 오디오 믹싱 필터
|
||
if (count($filterParts) === 2) {
|
||
// 나레이션 + BGM
|
||
$filterComplex = implode(';', $filterParts) . ';[nar][bgm]amix=inputs=2:duration=first[a]';
|
||
$mapParts[] = '-map "[a]"';
|
||
} elseif (count($filterParts) === 1) {
|
||
// 나레이션 또는 BGM 중 하나만
|
||
$streamName = $narrationPath ? 'nar' : 'bgm';
|
||
$filterComplex = $filterParts[0];
|
||
$mapParts[] = '-map "[' . $streamName . ']"';
|
||
} else {
|
||
$filterComplex = null;
|
||
}
|
||
|
||
// 자막 비디오 필터
|
||
$vf = sprintf("subtitles=%s", escapeshellarg($subtitlePath));
|
||
|
||
// FFmpeg 명령 조립
|
||
$cmd = 'ffmpeg -y ' . implode(' ', $inputs);
|
||
|
||
if ($filterComplex) {
|
||
$cmd .= ' -filter_complex "' . $filterComplex . '"';
|
||
}
|
||
|
||
$cmd .= ' ' . implode(' ', $mapParts);
|
||
$cmd .= ' -vf ' . escapeshellarg($vf);
|
||
$cmd .= ' -c:v libx264 -preset fast -crf 23 -r 30';
|
||
$cmd .= ' -c:a aac -b:a 192k';
|
||
$cmd .= ' -shortest';
|
||
$cmd .= ' ' . escapeshellarg($outputPath);
|
||
$cmd .= ' 2>&1';
|
||
|
||
Log::info('VideoAssemblyService: 최종 합성 시작', ['cmd' => $cmd]);
|
||
|
||
exec($cmd, $output, $returnCode);
|
||
|
||
if ($returnCode !== 0) {
|
||
Log::error('VideoAssemblyService: 최종 합성 실패', [
|
||
'return_code' => $returnCode,
|
||
'output' => implode("\n", array_slice($output, -20)),
|
||
]);
|
||
|
||
return null;
|
||
}
|
||
|
||
Log::info('VideoAssemblyService: 최종 합성 완료', [
|
||
'output' => $outputPath,
|
||
'size' => file_exists($outputPath) ? filesize($outputPath) : 0,
|
||
]);
|
||
|
||
return $outputPath;
|
||
}
|
||
|
||
/**
|
||
* ASS 시간 형식 (H:MM:SS.cs)
|
||
*/
|
||
private function formatAssTime(float $seconds): string
|
||
{
|
||
$hours = floor($seconds / 3600);
|
||
$minutes = floor(($seconds % 3600) / 60);
|
||
$secs = floor($seconds % 60);
|
||
$centiseconds = round(($seconds - floor($seconds)) * 100);
|
||
|
||
return sprintf('%d:%02d:%02d.%02d', $hours, $minutes, $secs, $centiseconds);
|
||
}
|
||
|
||
/**
|
||
* 긴 텍스트를 일정 글자수로 줄바꿈
|
||
*/
|
||
private function wrapText(string $text, int $maxCharsPerLine): string
|
||
{
|
||
if (mb_strlen($text) <= $maxCharsPerLine) {
|
||
return $text;
|
||
}
|
||
|
||
$lines = [];
|
||
$words = preg_split('/\s+/', $text);
|
||
$currentLine = '';
|
||
|
||
foreach ($words as $word) {
|
||
if (mb_strlen($currentLine . ' ' . $word) > $maxCharsPerLine && $currentLine !== '') {
|
||
$lines[] = trim($currentLine);
|
||
$currentLine = $word;
|
||
} else {
|
||
$currentLine .= ($currentLine ? ' ' : '') . $word;
|
||
}
|
||
}
|
||
|
||
if ($currentLine !== '') {
|
||
$lines[] = trim($currentLine);
|
||
}
|
||
|
||
return implode("\n", $lines);
|
||
}
|
||
|
||
/**
|
||
* 작업 디렉토리 정리
|
||
*/
|
||
public function cleanup(string $workDir): void
|
||
{
|
||
if (! is_dir($workDir)) {
|
||
return;
|
||
}
|
||
|
||
$files = glob("{$workDir}/*");
|
||
foreach ($files as $file) {
|
||
if (is_file($file) && ! str_contains($file, 'final_')) {
|
||
@unlink($file);
|
||
}
|
||
}
|
||
}
|
||
}
|