Files
sam-manage/app/Services/Video/TutorialAssemblyService.php
김보곤 768bc30a6d feat:사용자 매뉴얼 영상 자동 생성 기능 구현
- TutorialVideo 모델 (상태 관리, TenantScope)
- GeminiScriptService에 callGeminiWithParts() 멀티모달 지원 추가
- ScreenAnalysisService: Gemini Vision 스크린샷 AI 분석
- SlideAnnotationService: PHP GD 이미지 어노테이션 (마커, 캡션)
- TutorialAssemblyService: FFmpeg 이미지→영상 합성 (crossfade)
- TutorialVideoJob: 분석→슬라이드→TTS→BGM→합성 파이프라인
- TutorialVideoController: 업로드/분석/생성/상태/다운로드/이력 API
- React-in-Blade UI: 3단계 (업로드→분석확인→생성모니터링) + 이력

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:56:39 +09:00

293 lines
8.9 KiB
PHP

<?php
namespace App\Services\Video;
use Illuminate\Support\Facades\Log;
class TutorialAssemblyService
{
private VideoAssemblyService $videoAssembly;
public function __construct(VideoAssemblyService $videoAssembly)
{
$this->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;
}
}