Files
sam-manage/app/Services/Video/VideoAssemblyService.php
김보곤 6ab93aedd2 feat:YouTube Shorts AI 자동 생성 시스템 구현 (Veo 3.1 + Gemini)
- GeminiScriptService: 트렌딩 제목/시나리오 생성
- VeoVideoService: Veo 3.1 영상 클립 생성
- TtsService: Google TTS 나레이션 생성
- BgmService: 분위기별 BGM 선택
- VideoAssemblyService: FFmpeg 영상 합성
- VideoGenerationJob: 백그라운드 처리
- Veo3Controller: API 엔드포인트
- React 프론트엔드 (5단계 위저드)
- GoogleCloudService.getAccessToken() public 변경

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

385 lines
12 KiB
PHP

<?php
namespace App\Services\Video;
use Illuminate\Support\Facades\Log;
class VideoAssemblyService
{
/**
* 영상 클립 결합 (concat)
*
* @param array $clipPaths 클립 파일 경로 배열
* @return string|null 결합된 영상 경로
*/
public function concatClips(array $clipPaths, string $outputPath): ?string
{
if (empty($clipPaths)) {
return null;
}
// 클립이 1개면 그대로 복사
if (count($clipPaths) === 1) {
copy($clipPaths[0], $outputPath);
return $outputPath;
}
$dir = dirname($outputPath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
// concat용 파일 리스트 생성
$listFile = "{$dir}/concat_list.txt";
$listContent = '';
foreach ($clipPaths as $path) {
$listContent .= "file " . escapeshellarg($path) . "\n";
}
file_put_contents($listFile, $listContent);
// 모든 클립을 동일 형식으로 재인코딩 후 concat
$scaledPaths = [];
foreach ($clipPaths as $i => $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);
}
// 각 나레이션에 패딩(무음)을 추가해서 장면 길이에 맞춤
$paddedPaths = [];
foreach ($scenes as $scene) {
$sceneNum = $scene['scene_number'];
$duration = $scene['duration'] ?? 8;
if (isset($audioPaths[$sceneNum])) {
$paddedPath = "{$dir}/narration_padded_{$sceneNum}.mp3";
// 나레이션을 장면 길이에 맞춰 패딩
$cmd = sprintf(
'ffmpeg -y -i %s -af "apad=whole_dur=%d" -c:a libmp3lame -q:a 2 %s 2>&1',
escapeshellarg($audioPaths[$sceneNum]),
$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;
}
/**
* ASS 자막 파일 생성
*
* @param array $scenes [{scene_number, narration, duration}, ...]
*/
public function generateAssSubtitle(array $scenes, string $outputPath): string
{
$dir = dirname($outputPath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$ass = "[Script Info]\n";
$ass .= "ScriptType: v4.00+\n";
$ass .= "PlayResX: 1080\n";
$ass .= "PlayResY: 1920\n";
$ass .= "WrapStyle: 0\n\n";
$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";
$ass .= "Style: Default,NanumGothic,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,40,40,200,1\n\n";
$ass .= "[Events]\n";
$ass .= "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n";
$currentTime = 0;
foreach ($scenes as $scene) {
$duration = $scene['duration'] ?? 8;
$narration = $scene['narration'] ?? '';
if (empty($narration)) {
$currentTime += $duration;
continue;
}
$startTime = $this->formatAssTime($currentTime);
$endTime = $this->formatAssTime($currentTime + $duration);
// 긴 텍스트는 줄바꿈
$text = $this->wrapText($narration, 18);
$text = str_replace("\n", "\\N", $text);
$ass .= "Dialogue: 0,{$startTime},{$endTime},Default,,0,0,0,,{$text}\n";
$currentTime += $duration;
}
file_put_contents($outputPath, $ass);
return $outputPath;
}
/**
* 최종 합성: 영상 + 나레이션 + 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=1.0[nar]";
$audioIndex++;
}
// BGM 추가
if ($bgmPath && file_exists($bgmPath)) {
$inputs[] = '-i ' . escapeshellarg($bgmPath);
$filterParts[] = "[{$audioIndex}:a]volume=0.15[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;
}
// 자막 비디오 필터
$vf = sprintf("subtitles=%s", escapeshellarg($subtitlePath));
// FFmpeg 명령 조립
$cmd = 'ffmpeg -y ' . implode(' ', $inputs);
if ($filterComplex) {
$cmd .= ' -filter_complex "' . $filterComplex . '"';
}
$cmd .= ' ' . implode(' ', $mapParts);
$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);
}
}
}
}