- 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>
293 lines
8.9 KiB
PHP
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;
|
|
}
|
|
}
|