Files
sam-manage/app/Services/Video/VideoAssemblyService.php
김보곤 a8d87e2b92 fix:여성 음성 변경 + 속도 20% 감속 + 자막 한글자 버그 수정
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>
2026-02-15 13:18:47 +09:00

477 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}
}
}