$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; } /** * 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 측정값) */ /** * @param string $layout 'portrait' (9:16 Shorts) 또는 'landscape' (16:9 튜토리얼) */ public function generateAssSubtitle(array $scenes, string $outputPath, array $narrationDurations = [], string $layout = 'portrait'): string { $dir = dirname($outputPath); if (! is_dir($dir)) { mkdir($dir, 0755, true); } $isLandscape = $layout === 'landscape'; $ass = "[Script Info]\n"; $ass .= "ScriptType: v4.00+\n"; if ($isLandscape) { // 가로 영상 (16:9 튜토리얼) $ass .= "PlayResX: 1920\n"; $ass .= "PlayResY: 1080\n"; } else { // 세로 영상 (9:16 Shorts) $ass .= "PlayResX: 1080\n"; $ass .= "PlayResY: 1920\n"; } $ass .= "WrapStyle: 2\n\n"; // 2 = no word wrapping (한줄 유지) $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"; if ($isLandscape) { // 가로 영상: 하단 중앙, 폰트 작게, 여백 넓게, 한줄 최대 // Alignment=2: 하단 중앙 // MarginL=20, MarginR=20: 좌우 여백 최소화 → 길게 // MarginV=30: 하단에서 약간 위 // FontSize=48: 1920 기준 적절한 크기 // BorderStyle=3: 반투명 배경 박스 // BackColour=&H80000000: 반투명 검정 배경 $ass .= "Style: Default,NanumGothic,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,3,2,0,2,20,20,30,1\n\n"; } else { // 세로 영상 (기존) $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; // 가로 영상은 줄바꿈 없이 한줄, 세로 영상은 16자 줄바꿈 $maxCharsPerLine = $isLandscape ? 999 : 16; foreach ($scenes as $scene) { $sceneDuration = $scene['duration'] ?? 8; $sceneNum = (int) ($scene['scene_number'] ?? 0); $narration = $scene['narration'] ?? ''; // 인트로(1)/아웃트로(999) 씬은 슬라이드에 텍스트가 포함 → 자막 생략 // 또한 scene_number < 100인 경우도 인트로로 간주 (안전장치) $isIntroOutro = $sceneNum <= 1 || $sceneNum === 999; if (empty($narration) || $isIntroOutro) { $currentTime += $sceneDuration; continue; } // 이모지/특수문자 제거 후 문장 분리 $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)); $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.0); // 장면 경계 초과 방지 if ($offset + $sentDuration > $sceneDuration) { $sentDuration = $sceneDuration - $offset; } if ($sentDuration <= 0) { break; } $startTime = $this->formatAssTime($currentTime + $offset); $endTime = $this->formatAssTime($currentTime + $offset + $sentDuration); $text = $this->wrapText($sentence, $maxCharsPerLine); $text = str_replace("\n", '\\N', $text); $ass .= "Dialogue: 0,{$startTime},{$endTime},Default,,0,0,0,,{$text}\n"; $offset += $sentDuration; } $currentTime += $sceneDuration; } file_put_contents($outputPath, $ass); return $outputPath; } /** * 자막용 이모지/특수문자 제거 */ private function stripEmoji(string $text): string { $text = preg_replace('/[\x{1F600}-\x{1F64F}]/u', '', $text); $text = preg_replace('/[\x{1F300}-\x{1F5FF}]/u', '', $text); $text = preg_replace('/[\x{1F680}-\x{1F6FF}]/u', '', $text); $text = preg_replace('/[\x{1F900}-\x{1F9FF}]/u', '', $text); $text = preg_replace('/[\x{1FA00}-\x{1FAFF}]/u', '', $text); $text = preg_replace('/[\x{2600}-\x{27BF}]/u', '', $text); $text = preg_replace('/[\x{FE00}-\x{FE0F}]/u', '', $text); $text = preg_replace('/[\x{200D}]/u', '', $text); $text = preg_replace('/[\x{E0020}-\x{E007F}]/u', '', $text); $text = str_replace('*', '', $text); $text = preg_replace('/\([^)]*\)/', '', $text); $text = preg_replace('/\[[^\]]*\]/', '', $text); $text = preg_replace('/[○●◎◇◆□■△▲▽▼★☆♡♥]/u', '', $text); $text = preg_replace('/\s+/', ' ', $text); return trim($text); } /** * 나레이션 텍스트를 문장 단위로 분리 * * 규칙: * - 구두점(연속 포함) + 공백 기준으로 분리 ("?!" 등은 하나로 유지) * - 5자 미만 조각은 이전 문장에 병합 (구두점이 다음 페이지로 넘어가는 버그 방지) */ private function splitIntoSentences(string $text): array { $text = trim($text); // 전체가 20자 이하면 분리하지 않음 (한 문장) if (mb_strlen($text) <= 20) { return [$text]; } // \K로 구두점은 앞 문장에 유지, 공백만 구분자로 소비 // "정말요?!" → 분리 안 됨, "정말요?! 다음 문장" → ["정말요?!", "다음 문장"] $rawParts = preg_split('/[.!?。!?](?![.!?。!?])\K\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY); if (count($rawParts) <= 1) { return [$text]; } // 5자 미만 짧은 조각은 이전 문장에 병합 (backward merge) $merged = []; foreach ($rawParts as $part) { $part = trim($part); if ($part === '') { continue; } if (mb_strlen($part) < 5 && ! empty($merged)) { // 짧은 조각 → 이전 항목 뒤에 붙임 $merged[count($merged) - 1] .= ' '.$part; } else { $merged[] = $part; } } 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=1.2[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; } // FFmpeg 명령 조립 $cmd = 'ffmpeg -y '.implode(' ', $inputs); if ($filterComplex) { $cmd .= ' -filter_complex "'.$filterComplex.'"'; } $cmd .= ' '.implode(' ', $mapParts); // 자막 비디오 필터 (슬라이드 캡션바에 텍스트 포함 시 생략 가능) if ($subtitlePath && file_exists($subtitlePath)) { $vf = sprintf('ass=%s', escapeshellarg($subtitlePath)); $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); } } } }