videoAssembly = $videoAssembly; } /** * 어노테이션 이미지들 → MP4 영상 합성 * * @param array $slidePaths 슬라이드 이미지 경로 배열 * @param array $durations 각 슬라이드 표시 시간(초) 배열 * @param string|null $narrationPath 나레이션 오디오 경로 * @param string|null $bgmPath BGM 오디오 경로 * @param string $subtitlePath ASS 자막 파일 경로 * @param string $outputPath 최종 MP4 출력 경로 * @return string|null 성공 시 출력 경로 */ public function assembleFromImages( array $slidePaths, array $durations, ?string $narrationPath, ?string $bgmPath, ?string $subtitlePath, string $outputPath ): ?string { if (empty($slidePaths)) { Log::error('TutorialAssembly: 슬라이드가 없습니다'); return null; } $dir = dirname($outputPath); if (! is_dir($dir)) { mkdir($dir, 0755, true); } // Step 1: 이미지 시퀀스 → 무음 MP4 (crossfade 포함) $silentVideoPath = "{$dir}/silent_video.mp4"; $silentVideo = $this->imagesToVideo($slidePaths, $durations, $silentVideoPath); if (! $silentVideo) { Log::error('TutorialAssembly: 이미지→영상 변환 실패'); return null; } // Step 2: 무음 영상 + 나레이션 + BGM + 자막 합성 $result = $this->videoAssembly->assemble( $silentVideo, $narrationPath, $bgmPath, $subtitlePath, $outputPath ); // 임시 파일 정리 @unlink($silentVideoPath); return $result; } /** * 이미지 시퀀스를 crossfade 트랜지션으로 영상 변환 */ private function imagesToVideo(array $slidePaths, array $durations, string $outputPath): ?string { $count = count($slidePaths); if ($count === 0) { return null; } // 이미지 1장인 경우 단순 변환 if ($count === 1) { $duration = $durations[0] ?? 8; $cmd = sprintf( 'ffmpeg -y -loop 1 -i %s -c:v libx264 -t %d -pix_fmt yuv420p -vf "scale=1920:1080" -r 30 %s 2>&1', escapeshellarg($slidePaths[0]), $duration, escapeshellarg($outputPath) ); exec($cmd, $output, $returnCode); if ($returnCode !== 0) { Log::error('TutorialAssembly: 단일 이미지 변환 실패', [ 'output' => implode("\n", array_slice($output, -10)), ]); return null; } return $outputPath; } // 여러 장인 경우: 각 이미지를 개별 영상으로 만든 후 xfade로 결합 $fadeDuration = 0.5; $dir = dirname($outputPath); $clipPaths = []; // Step 1: 각 이미지를 개별 클립으로 변환 foreach ($slidePaths as $i => $slidePath) { $duration = $durations[$i] ?? 8; $clipPath = "{$dir}/clip_{$i}.mp4"; $cmd = sprintf( 'ffmpeg -y -loop 1 -i %s -c:v libx264 -t %s -pix_fmt yuv420p -vf "scale=1920:1080" -r 30 %s 2>&1', escapeshellarg($slidePath), $duration, escapeshellarg($clipPath) ); exec($cmd, $output, $returnCode); if ($returnCode !== 0) { Log::error("TutorialAssembly: 클립 {$i} 변환 실패", [ 'output' => implode("\n", array_slice($output, -10)), ]); // 실패 시 crossfade 없이 fallback return $this->imagesToVideoSimple($slidePaths, $durations, $outputPath); } $clipPaths[] = $clipPath; } // Step 2: xfade 필터로 crossfade 결합 $result = $this->xfadeConcat($clipPaths, $durations, $fadeDuration, $outputPath); // 임시 클립 정리 foreach ($clipPaths as $path) { @unlink($path); } return $result; } /** * xfade 필터를 사용한 crossfade 결합 */ private function xfadeConcat(array $clipPaths, array $durations, float $fadeDuration, string $outputPath): ?string { $count = count($clipPaths); if ($count < 2) { return null; } // 2개인 경우 단순 xfade if ($count === 2) { $offset = max(0, ($durations[0] ?? 8) - $fadeDuration); $cmd = sprintf( 'ffmpeg -y -i %s -i %s -filter_complex "[0:v][1:v]xfade=transition=fade:duration=%s:offset=%s[v]" -map "[v]" -c:v libx264 -preset fast -crf 23 -r 30 -pix_fmt yuv420p %s 2>&1', escapeshellarg($clipPaths[0]), escapeshellarg($clipPaths[1]), $fadeDuration, $offset, escapeshellarg($outputPath) ); exec($cmd, $output, $returnCode); if ($returnCode !== 0) { Log::warning('TutorialAssembly: xfade 실패, concat fallback', [ 'output' => implode("\n", array_slice($output, -10)), ]); return $this->simpleConcatClips($clipPaths, $outputPath); } return $outputPath; } // 3개 이상: 체인 xfade $inputs = ''; foreach ($clipPaths as $path) { $inputs .= '-i '.escapeshellarg($path).' '; } $filter = ''; $cumulativeOffset = 0; for ($i = 0; $i < $count - 1; $i++) { $cumulativeOffset += ($durations[$i] ?? 8) - $fadeDuration; $inputA = ($i === 0) ? '[0:v]' : "[v{$i}]"; $inputB = '['.($i + 1).':v]'; $outputLabel = ($i === $count - 2) ? '[v]' : '[v'.($i + 1).']'; $filter .= "{$inputA}{$inputB}xfade=transition=fade:duration={$fadeDuration}:offset={$cumulativeOffset}{$outputLabel}"; if ($i < $count - 2) { $filter .= ';'; } } $cmd = sprintf( 'ffmpeg -y %s -filter_complex "%s" -map "[v]" -c:v libx264 -preset fast -crf 23 -r 30 -pix_fmt yuv420p %s 2>&1', $inputs, $filter, escapeshellarg($outputPath) ); exec($cmd, $output, $returnCode); if ($returnCode !== 0) { Log::warning('TutorialAssembly: 체인 xfade 실패, concat fallback', [ 'output' => implode("\n", array_slice($output, -10)), ]); return $this->simpleConcatClips($clipPaths, $outputPath); } return $outputPath; } /** * 단순 concat fallback (crossfade 실패 시) */ private function simpleConcatClips(array $clipPaths, string $outputPath): ?string { $dir = dirname($outputPath); $listFile = "{$dir}/concat_tutorial.txt"; $listContent = ''; foreach ($clipPaths as $path) { $listContent .= 'file '.escapeshellarg($path)."\n"; } file_put_contents($listFile, $listContent); $cmd = sprintf( 'ffmpeg -y -f concat -safe 0 -i %s -c copy %s 2>&1', escapeshellarg($listFile), escapeshellarg($outputPath) ); exec($cmd, $output, $returnCode); @unlink($listFile); if ($returnCode !== 0) { Log::error('TutorialAssembly: concat fallback도 실패', [ 'output' => implode("\n", array_slice($output, -10)), ]); return null; } return $outputPath; } /** * crossfade 없이 단순 이미지→영상 변환 fallback */ private function imagesToVideoSimple(array $slidePaths, array $durations, string $outputPath): ?string { $dir = dirname($outputPath); $clipPaths = []; foreach ($slidePaths as $i => $slidePath) { $duration = $durations[$i] ?? 8; $clipPath = "{$dir}/simple_clip_{$i}.mp4"; $cmd = sprintf( 'ffmpeg -y -loop 1 -i %s -c:v libx264 -t %d -pix_fmt yuv420p -vf "scale=1920:1080" -r 30 %s 2>&1', escapeshellarg($slidePath), $duration, escapeshellarg($clipPath) ); exec($cmd, $output, $returnCode); if ($returnCode === 0) { $clipPaths[] = $clipPath; } } if (empty($clipPaths)) { return null; } $result = $this->simpleConcatClips($clipPaths, $outputPath); foreach ($clipPaths as $path) { @unlink($path); } return $result; } }