Files
sam-manage/app/Services/Video/TutorialAssemblyService.php

300 lines
8.9 KiB
PHP
Raw Normal View History

<?php
namespace App\Services\Video;
use Illuminate\Support\Facades\Log;
class TutorialAssemblyService
{
private VideoAssemblyService $videoAssembly;
public function __construct(VideoAssemblyService $videoAssembly)
{
$this->videoAssembly = $videoAssembly;
}
/**
* 어노테이션 이미지들 MP4 영상 합성
*
2026-02-25 11:45:01 +09:00
* @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: 슬라이드가 없습니다');
2026-02-25 11:45:01 +09:00
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: 이미지→영상 변환 실패');
2026-02-25 11:45:01 +09:00
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)),
]);
2026-02-25 11:45:01 +09:00
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)),
]);
2026-02-25 11:45:01 +09:00
// 실패 시 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)),
]);
2026-02-25 11:45:01 +09:00
return $this->simpleConcatClips($clipPaths, $outputPath);
}
return $outputPath;
}
// 3개 이상: 체인 xfade
$inputs = '';
foreach ($clipPaths as $path) {
2026-02-25 11:45:01 +09:00
$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}]";
2026-02-25 11:45:01 +09:00
$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)),
]);
2026-02-25 11:45:01 +09:00
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) {
2026-02-25 11:45:01 +09:00
$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)),
]);
2026-02-25 11:45:01 +09:00
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;
}
}