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>
This commit is contained in:
221
app/Http/Controllers/Video/Veo3Controller.php
Normal file
221
app/Http/Controllers/Video/Veo3Controller.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Video;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\VideoGenerationJob;
|
||||
use App\Models\VideoGeneration;
|
||||
use App\Services\Video\GeminiScriptService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class Veo3Controller extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GeminiScriptService $geminiService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 메인 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('video.veo3.index'));
|
||||
}
|
||||
|
||||
return view('video.veo3.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 키워드 → 제목 후보 생성
|
||||
*/
|
||||
public function generateTitles(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'keyword' => 'required|string|max:100',
|
||||
]);
|
||||
|
||||
$keyword = $request->input('keyword');
|
||||
$titles = $this->geminiService->generateTrendingTitles($keyword);
|
||||
|
||||
if (empty($titles)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '제목 생성에 실패했습니다. API 키를 확인해주세요.',
|
||||
], 500);
|
||||
}
|
||||
|
||||
// DB에 pending 레코드 생성
|
||||
$video = VideoGeneration::create([
|
||||
'tenant_id' => auth()->user()->tenant_id,
|
||||
'user_id' => auth()->id(),
|
||||
'keyword' => $keyword,
|
||||
'status' => VideoGeneration::STATUS_TITLES_GENERATED,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'video_id' => $video->id,
|
||||
'titles' => $titles,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 생성 (제목 선택 후)
|
||||
*/
|
||||
public function generateScenario(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'video_id' => 'required|integer',
|
||||
'title' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
$video = VideoGeneration::findOrFail($request->input('video_id'));
|
||||
$title = $request->input('title');
|
||||
|
||||
$scenario = $this->geminiService->generateScenario($title, $video->keyword);
|
||||
|
||||
if (empty($scenario) || empty($scenario['scenes'])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '시나리오 생성에 실패했습니다.',
|
||||
], 500);
|
||||
}
|
||||
|
||||
$video->update([
|
||||
'title' => $title,
|
||||
'scenario' => $scenario,
|
||||
'status' => VideoGeneration::STATUS_SCENARIO_READY,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'video_id' => $video->id,
|
||||
'scenario' => $scenario,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상 생성 시작 (Job 디스패치)
|
||||
*/
|
||||
public function generate(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'video_id' => 'required|integer',
|
||||
'scenario' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$video = VideoGeneration::findOrFail($request->input('video_id'));
|
||||
|
||||
// 이미 생성 중이면 거부
|
||||
if (in_array($video->status, [
|
||||
VideoGeneration::STATUS_GENERATING_TTS,
|
||||
VideoGeneration::STATUS_GENERATING_CLIPS,
|
||||
VideoGeneration::STATUS_GENERATING_BGM,
|
||||
VideoGeneration::STATUS_ASSEMBLING,
|
||||
])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '이미 영상을 생성 중입니다.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$customScenario = $request->input('scenario');
|
||||
|
||||
if ($customScenario) {
|
||||
$video->update(['scenario' => $customScenario]);
|
||||
}
|
||||
|
||||
$video->updateProgress(VideoGeneration::STATUS_PENDING, 0, '대기 중...');
|
||||
|
||||
VideoGenerationJob::dispatch($video->id, $video->title, $customScenario ?? $video->scenario);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'video_id' => $video->id,
|
||||
'message' => '영상 생성이 시작되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 상태 폴링
|
||||
*/
|
||||
public function status(int $id): JsonResponse
|
||||
{
|
||||
$video = VideoGeneration::findOrFail($id);
|
||||
|
||||
return response()->json([
|
||||
'id' => $video->id,
|
||||
'status' => $video->status,
|
||||
'progress' => $video->progress,
|
||||
'current_step' => $video->current_step,
|
||||
'error_message' => $video->error_message,
|
||||
'output_path' => $video->output_path ? true : false,
|
||||
'cost_usd' => $video->cost_usd,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 완성 영상 다운로드
|
||||
*/
|
||||
public function download(int $id): BinaryFileResponse|JsonResponse
|
||||
{
|
||||
$video = VideoGeneration::findOrFail($id);
|
||||
|
||||
if ($video->status !== VideoGeneration::STATUS_COMPLETED || ! $video->output_path) {
|
||||
return response()->json(['message' => '아직 영상이 완성되지 않았습니다.'], 404);
|
||||
}
|
||||
|
||||
if (! file_exists($video->output_path)) {
|
||||
return response()->json(['message' => '영상 파일을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$filename = "shorts_{$video->keyword}_{$video->id}.mp4";
|
||||
$filename = preg_replace('/[^a-zA-Z0-9가-힣_\-.]/', '_', $filename);
|
||||
|
||||
return response()->download($video->output_path, $filename, [
|
||||
'Content-Type' => 'video/mp4',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상 미리보기 (스트리밍)
|
||||
*/
|
||||
public function preview(int $id): Response|JsonResponse
|
||||
{
|
||||
$video = VideoGeneration::findOrFail($id);
|
||||
|
||||
if (! $video->output_path || ! file_exists($video->output_path)) {
|
||||
return response()->json(['message' => '영상 파일을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$path = $video->output_path;
|
||||
$size = filesize($path);
|
||||
|
||||
return response()->file($path, [
|
||||
'Content-Type' => 'video/mp4',
|
||||
'Content-Length' => $size,
|
||||
'Accept-Ranges' => 'bytes',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 이력 목록
|
||||
*/
|
||||
public function history(Request $request): JsonResponse
|
||||
{
|
||||
$videos = VideoGeneration::where('user_id', auth()->id())
|
||||
->orderByDesc('created_at')
|
||||
->limit(50)
|
||||
->get(['id', 'keyword', 'title', 'status', 'progress', 'cost_usd', 'created_at']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $videos,
|
||||
]);
|
||||
}
|
||||
}
|
||||
241
app/Jobs/VideoGenerationJob.php
Normal file
241
app/Jobs/VideoGenerationJob.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\VideoGeneration;
|
||||
use App\Services\Video\BgmService;
|
||||
use App\Services\Video\GeminiScriptService;
|
||||
use App\Services\Video\TtsService;
|
||||
use App\Services\Video\VeoVideoService;
|
||||
use App\Services\Video\VideoAssemblyService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class VideoGenerationJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 1800; // 30분
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
private int $videoGenerationId;
|
||||
|
||||
private ?string $selectedTitle;
|
||||
|
||||
private ?array $customScenario;
|
||||
|
||||
public function __construct(int $videoGenerationId, ?string $selectedTitle = null, ?array $customScenario = null)
|
||||
{
|
||||
$this->videoGenerationId = $videoGenerationId;
|
||||
$this->selectedTitle = $selectedTitle;
|
||||
$this->customScenario = $customScenario;
|
||||
}
|
||||
|
||||
public function handle(
|
||||
GeminiScriptService $gemini,
|
||||
VeoVideoService $veo,
|
||||
TtsService $tts,
|
||||
BgmService $bgm,
|
||||
VideoAssemblyService $assembly
|
||||
): void {
|
||||
$video = VideoGeneration::withoutGlobalScopes()->find($this->videoGenerationId);
|
||||
|
||||
if (! $video) {
|
||||
Log::error('VideoGenerationJob: 레코드를 찾을 수 없음', ['id' => $this->videoGenerationId]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$workDir = storage_path("app/video_gen/{$video->id}");
|
||||
if (! is_dir($workDir)) {
|
||||
mkdir($workDir, 0755, true);
|
||||
}
|
||||
|
||||
$totalCost = 0.0;
|
||||
|
||||
try {
|
||||
// === Step 1: 시나리오 생성 ===
|
||||
$video->updateProgress(VideoGeneration::STATUS_SCENARIO_READY, 5, '시나리오 생성 중...');
|
||||
|
||||
if ($this->customScenario) {
|
||||
$scenario = $this->customScenario;
|
||||
} else {
|
||||
$title = $this->selectedTitle ?? $video->title;
|
||||
$scenario = $gemini->generateScenario($title, $video->keyword);
|
||||
|
||||
if (empty($scenario) || empty($scenario['scenes'])) {
|
||||
$video->markFailed('시나리오 생성 실패');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$video->update([
|
||||
'scenario' => $scenario,
|
||||
'title' => $scenario['title'] ?? $video->title,
|
||||
]);
|
||||
|
||||
$scenes = $scenario['scenes'] ?? [];
|
||||
$totalCost += 0.001; // Gemini 비용
|
||||
|
||||
// === Step 2: 나레이션 생성 ===
|
||||
$video->updateProgress(VideoGeneration::STATUS_GENERATING_TTS, 15, '나레이션 생성 중...');
|
||||
|
||||
$narrationPaths = $tts->synthesizeScenes($scenes, $workDir);
|
||||
|
||||
if (empty($narrationPaths)) {
|
||||
$video->markFailed('나레이션 생성 실패');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$totalCost += 0.01; // TTS 비용
|
||||
|
||||
// === Step 3: 영상 클립 생성 ===
|
||||
$video->updateProgress(VideoGeneration::STATUS_GENERATING_CLIPS, 20, '영상 클립 생성 요청 중...');
|
||||
|
||||
$clipPaths = [];
|
||||
$operations = [];
|
||||
|
||||
// 모든 장면의 영상 생성 요청 (비동기)
|
||||
foreach ($scenes as $scene) {
|
||||
$sceneNum = $scene['scene_number'];
|
||||
$prompt = $scene['visual_prompt'] ?? '';
|
||||
$duration = $scene['duration'] ?? 8;
|
||||
|
||||
$video->updateProgress(
|
||||
VideoGeneration::STATUS_GENERATING_CLIPS,
|
||||
20 + (int) (($sceneNum / count($scenes)) * 10),
|
||||
"영상 클립 생성 요청 중 ({$sceneNum}/" . count($scenes) . ')'
|
||||
);
|
||||
|
||||
$result = $veo->generateClip($prompt, $duration);
|
||||
|
||||
if (! $result) {
|
||||
$video->markFailed("장면 {$sceneNum} 영상 생성 요청 실패");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$operations[$sceneNum] = $result['operationName'];
|
||||
}
|
||||
|
||||
$totalCost += count($scenes) * 1.20; // Veo 비용 (Fast 기준 8초당 $1.20)
|
||||
|
||||
// 모든 영상 클립 완료 대기
|
||||
foreach ($operations as $sceneNum => $operationName) {
|
||||
$video->updateProgress(
|
||||
VideoGeneration::STATUS_GENERATING_CLIPS,
|
||||
30 + (int) (($sceneNum / count($scenes)) * 40),
|
||||
"영상 클립 생성 대기 중 ({$sceneNum}/" . count($scenes) . ')'
|
||||
);
|
||||
|
||||
$clipPath = $veo->waitAndSave(
|
||||
$operationName,
|
||||
"{$workDir}/clip_{$sceneNum}.mp4"
|
||||
);
|
||||
|
||||
if (! $clipPath) {
|
||||
$video->markFailed("장면 {$sceneNum} 영상 생성 실패 (타임아웃)");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$clipPaths[$sceneNum] = $clipPath;
|
||||
}
|
||||
|
||||
// 장면 순서로 정렬
|
||||
ksort($clipPaths);
|
||||
|
||||
$video->update(['clips_data' => $clipPaths]);
|
||||
|
||||
// === Step 4: BGM 생성/선택 ===
|
||||
$video->updateProgress(VideoGeneration::STATUS_GENERATING_BGM, 75, 'BGM 준비 중...');
|
||||
|
||||
$bgmMood = $scenario['bgm_mood'] ?? 'upbeat';
|
||||
$bgmPath = $bgm->select($bgmMood, "{$workDir}/bgm.mp3");
|
||||
|
||||
// BGM 파일이 없으면 무음 BGM 생성
|
||||
if (! $bgmPath) {
|
||||
$totalDuration = array_sum(array_column($scenes, 'duration'));
|
||||
$bgmPath = $bgm->generateSilence($totalDuration, "{$workDir}/bgm.mp3");
|
||||
}
|
||||
|
||||
// === Step 5: 최종 합성 ===
|
||||
$video->updateProgress(VideoGeneration::STATUS_ASSEMBLING, 80, '영상 합성 중...');
|
||||
|
||||
// 5-1. 클립 결합
|
||||
$concatPath = "{$workDir}/concat.mp4";
|
||||
$concatResult = $assembly->concatClips(array_values($clipPaths), $concatPath);
|
||||
|
||||
if (! $concatResult) {
|
||||
$video->markFailed('영상 클립 결합 실패');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 5-2. 나레이션 결합
|
||||
$narrationConcatPath = "{$workDir}/narration_full.mp3";
|
||||
$assembly->concatNarrations($narrationPaths, $scenes, $narrationConcatPath);
|
||||
|
||||
// 5-3. 자막 생성
|
||||
$subtitlePath = "{$workDir}/subtitles.ass";
|
||||
$assembly->generateAssSubtitle($scenes, $subtitlePath);
|
||||
|
||||
// 5-4. 최종 합성
|
||||
$video->updateProgress(VideoGeneration::STATUS_ASSEMBLING, 90, '최종 합성 중...');
|
||||
|
||||
$finalPath = "{$workDir}/final_{$video->id}.mp4";
|
||||
$result = $assembly->assemble(
|
||||
$concatPath,
|
||||
file_exists($narrationConcatPath) ? $narrationConcatPath : null,
|
||||
$bgmPath,
|
||||
$subtitlePath,
|
||||
$finalPath
|
||||
);
|
||||
|
||||
if (! $result) {
|
||||
$video->markFailed('최종 영상 합성 실패');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// === 완료 ===
|
||||
$video->update([
|
||||
'status' => VideoGeneration::STATUS_COMPLETED,
|
||||
'progress' => 100,
|
||||
'current_step' => '완료',
|
||||
'output_path' => $finalPath,
|
||||
'cost_usd' => $totalCost,
|
||||
]);
|
||||
|
||||
// 중간 파일 정리
|
||||
$assembly->cleanup($workDir);
|
||||
|
||||
Log::info('VideoGenerationJob: 영상 생성 완료', [
|
||||
'id' => $video->id,
|
||||
'output' => $finalPath,
|
||||
'cost' => $totalCost,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('VideoGenerationJob: 예외 발생', [
|
||||
'id' => $video->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$video->markFailed($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
$video = VideoGeneration::withoutGlobalScopes()->find($this->videoGenerationId);
|
||||
$video?->markFailed('Job 실패: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
85
app/Models/VideoGeneration.php
Normal file
85
app/Models/VideoGeneration.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Scopes\TenantScope;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class VideoGeneration extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'keyword',
|
||||
'title',
|
||||
'scenario',
|
||||
'status',
|
||||
'progress',
|
||||
'current_step',
|
||||
'clips_data',
|
||||
'output_path',
|
||||
'cost_usd',
|
||||
'error_message',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scenario' => 'array',
|
||||
'clips_data' => 'array',
|
||||
'progress' => 'integer',
|
||||
'cost_usd' => 'decimal:4',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope(new TenantScope);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 상수
|
||||
*/
|
||||
const STATUS_PENDING = 'pending';
|
||||
|
||||
const STATUS_TITLES_GENERATED = 'titles_generated';
|
||||
|
||||
const STATUS_SCENARIO_READY = 'scenario_ready';
|
||||
|
||||
const STATUS_GENERATING_TTS = 'generating_tts';
|
||||
|
||||
const STATUS_GENERATING_CLIPS = 'generating_clips';
|
||||
|
||||
const STATUS_GENERATING_BGM = 'generating_bgm';
|
||||
|
||||
const STATUS_ASSEMBLING = 'assembling';
|
||||
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
|
||||
const STATUS_FAILED = 'failed';
|
||||
|
||||
/**
|
||||
* 진행 상태 업데이트 헬퍼
|
||||
*/
|
||||
public function updateProgress(string $status, int $progress, string $step): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => $status,
|
||||
'progress' => $progress,
|
||||
'current_step' => $step,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 처리
|
||||
*/
|
||||
public function markFailed(string $errorMessage): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'error_message' => $errorMessage,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ private function loadServiceAccount(): void
|
||||
/**
|
||||
* OAuth 토큰 발급
|
||||
*/
|
||||
private function getAccessToken(): ?string
|
||||
public function getAccessToken(): ?string
|
||||
{
|
||||
// 캐시된 토큰이 유효하면 재사용
|
||||
if ($this->accessToken && $this->tokenExpiry && time() < $this->tokenExpiry - 60) {
|
||||
|
||||
136
app/Services/Video/BgmService.php
Normal file
136
app/Services/Video/BgmService.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Video;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BgmService
|
||||
{
|
||||
/**
|
||||
* 분위기별 BGM 매핑 (로열티프리 BGM 파일 풀)
|
||||
* storage/app/bgm/ 디렉토리에 미리 준비
|
||||
*/
|
||||
private array $moodMap = [
|
||||
'upbeat' => ['upbeat_01.mp3', 'upbeat_02.mp3'],
|
||||
'energetic' => ['energetic_01.mp3', 'energetic_02.mp3'],
|
||||
'exciting' => ['exciting_01.mp3', 'exciting_02.mp3'],
|
||||
'calm' => ['calm_01.mp3', 'calm_02.mp3'],
|
||||
'dramatic' => ['dramatic_01.mp3', 'dramatic_02.mp3'],
|
||||
'happy' => ['happy_01.mp3', 'happy_02.mp3'],
|
||||
'sad' => ['sad_01.mp3', 'sad_02.mp3'],
|
||||
'mysterious' => ['mysterious_01.mp3', 'mysterious_02.mp3'],
|
||||
'inspiring' => ['inspiring_01.mp3', 'inspiring_02.mp3'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 분위기에 맞는 BGM 선택
|
||||
*
|
||||
* @return string|null BGM 파일 경로
|
||||
*/
|
||||
public function select(string $mood, string $savePath): ?string
|
||||
{
|
||||
$bgmDir = storage_path('app/bgm');
|
||||
|
||||
// 분위기 키워드 매칭 (부분 일치 지원)
|
||||
$matchedFiles = [];
|
||||
$moodLower = strtolower($mood);
|
||||
|
||||
foreach ($this->moodMap as $key => $files) {
|
||||
if (str_contains($moodLower, $key)) {
|
||||
$matchedFiles = array_merge($matchedFiles, $files);
|
||||
}
|
||||
}
|
||||
|
||||
// 매칭되는 분위기가 없으면 기본값
|
||||
if (empty($matchedFiles)) {
|
||||
$matchedFiles = $this->moodMap['upbeat'] ?? ['default.mp3'];
|
||||
}
|
||||
|
||||
// 랜덤 선택
|
||||
$selectedFile = $matchedFiles[array_rand($matchedFiles)];
|
||||
$sourcePath = "{$bgmDir}/{$selectedFile}";
|
||||
|
||||
// BGM 파일 존재 확인
|
||||
if (! file_exists($sourcePath)) {
|
||||
Log::warning('BgmService: BGM 파일 없음', [
|
||||
'path' => $sourcePath,
|
||||
'mood' => $mood,
|
||||
]);
|
||||
|
||||
// BGM 디렉토리에서 아무 파일이나 선택
|
||||
return $this->selectFallback($bgmDir, $savePath);
|
||||
}
|
||||
|
||||
$dir = dirname($savePath);
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
copy($sourcePath, $savePath);
|
||||
|
||||
Log::info('BgmService: BGM 선택 완료', [
|
||||
'mood' => $mood,
|
||||
'file' => $selectedFile,
|
||||
]);
|
||||
|
||||
return $savePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴백: BGM 디렉토리에서 아무 MP3 선택
|
||||
*/
|
||||
private function selectFallback(string $bgmDir, string $savePath): ?string
|
||||
{
|
||||
if (! is_dir($bgmDir)) {
|
||||
Log::error('BgmService: BGM 디렉토리 없음', ['dir' => $bgmDir]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$files = glob("{$bgmDir}/*.mp3");
|
||||
|
||||
if (empty($files)) {
|
||||
Log::error('BgmService: BGM 파일이 하나도 없음');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$selected = $files[array_rand($files)];
|
||||
|
||||
$dir = dirname($savePath);
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
copy($selected, $savePath);
|
||||
|
||||
return $savePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 무음 BGM 생성 (FFmpeg 사용, BGM 파일이 없을 때 폴백)
|
||||
*/
|
||||
public function generateSilence(int $durationSec, string $savePath): ?string
|
||||
{
|
||||
$dir = dirname($savePath);
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$cmd = sprintf(
|
||||
'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=stereo -t %d -c:a libmp3lame -q:a 9 %s 2>&1',
|
||||
$durationSec,
|
||||
escapeshellarg($savePath)
|
||||
);
|
||||
|
||||
exec($cmd, $output, $returnCode);
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
Log::error('BgmService: 무음 BGM 생성 실패', ['output' => implode("\n", $output)]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $savePath;
|
||||
}
|
||||
}
|
||||
187
app/Services/Video/GeminiScriptService.php
Normal file
187
app/Services/Video/GeminiScriptService.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Video;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GeminiScriptService
|
||||
{
|
||||
private string $apiKey;
|
||||
|
||||
private string $model = 'gemini-2.5-flash';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = config('services.gemini.api_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 키워드 → 트렌딩 제목 5개 생성
|
||||
*/
|
||||
public function generateTrendingTitles(string $keyword): array
|
||||
{
|
||||
$prompt = <<<PROMPT
|
||||
당신은 YouTube Shorts 전문 크리에이터입니다.
|
||||
키워드: "{$keyword}"
|
||||
|
||||
이 키워드로 YouTube Shorts에서 조회수를 극대화할 수 있는 매력적인 제목 5개를 생성해주세요.
|
||||
|
||||
요구사항:
|
||||
- 각 제목은 40자 이내
|
||||
- 호기심을 자극하는 제목
|
||||
- 한국어로 작성
|
||||
- 이모지 1-2개 포함 가능
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
|
||||
{
|
||||
"titles": [
|
||||
{"title": "제목1", "hook": "이 제목이 효과적인 이유 한줄"},
|
||||
{"title": "제목2", "hook": "이유"},
|
||||
{"title": "제목3", "hook": "이유"},
|
||||
{"title": "제목4", "hook": "이유"},
|
||||
{"title": "제목5", "hook": "이유"}
|
||||
]
|
||||
}
|
||||
PROMPT;
|
||||
|
||||
$result = $this->callGemini($prompt);
|
||||
|
||||
if (! $result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parsed = $this->parseJsonResponse($result);
|
||||
|
||||
return $parsed['titles'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 → 장면별 시나리오 생성
|
||||
*/
|
||||
public function generateScenario(string $title, string $keyword = ''): array
|
||||
{
|
||||
$prompt = <<<PROMPT
|
||||
당신은 YouTube Shorts 영상 시나리오 전문 작가입니다.
|
||||
|
||||
영상 제목: "{$title}"
|
||||
키워드: "{$keyword}"
|
||||
|
||||
이 제목으로 40초 분량의 YouTube Shorts 영상 시나리오를 작성해주세요.
|
||||
|
||||
요구사항:
|
||||
- 5~6개 장면 (각 6~8초)
|
||||
- 각 장면에 나레이션 텍스트 (한국어, 자막으로도 사용)
|
||||
- 각 장면에 Veo 3.1 영상 생성용 프롬프트 (영어, 구체적이고 시각적)
|
||||
- 총 길이 합계가 정확히 40초
|
||||
- 첫 장면은 강렬한 후크 (시청 유지율 극대화)
|
||||
- 마지막 장면은 CTA (좋아요/구독 유도)
|
||||
|
||||
visual_prompt 작성 규칙:
|
||||
- 영어로 작성
|
||||
- 카메라 앵글, 조명, 분위기를 구체적으로 묘사
|
||||
- "cinematic", "4K", "dramatic lighting" 등 품질 키워드 포함
|
||||
- 사람이 등장할 경우 외모, 표정, 동작을 구체적으로
|
||||
- 9:16 세로 영상에 적합한 구도
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
|
||||
{
|
||||
"title": "{$title}",
|
||||
"scenes": [
|
||||
{
|
||||
"scene_number": 1,
|
||||
"duration": 8,
|
||||
"narration": "나레이션 텍스트 (한국어)",
|
||||
"visual_prompt": "Detailed English prompt for Veo 3.1 video generation...",
|
||||
"mood": "exciting"
|
||||
}
|
||||
],
|
||||
"total_duration": 40,
|
||||
"bgm_mood": "upbeat, energetic"
|
||||
}
|
||||
PROMPT;
|
||||
|
||||
$result = $this->callGemini($prompt);
|
||||
|
||||
if (! $result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->parseJsonResponse($result) ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini API 호출
|
||||
*/
|
||||
private function callGemini(string $prompt): ?string
|
||||
{
|
||||
if (empty($this->apiKey)) {
|
||||
Log::error('GeminiScriptService: API 키가 설정되지 않았습니다.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent";
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'x-goog-api-key' => $this->apiKey,
|
||||
])->timeout(60)->post($url, [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt],
|
||||
],
|
||||
],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.9,
|
||||
'maxOutputTokens' => 4096,
|
||||
'responseMimeType' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('GeminiScriptService: API 호출 실패', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
|
||||
return $text;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('GeminiScriptService: 예외 발생', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 응답 파싱 (코드블록 제거 포함)
|
||||
*/
|
||||
private function parseJsonResponse(string $text): ?array
|
||||
{
|
||||
// 코드블록 제거 (```json ... ```)
|
||||
$text = preg_replace('/^```(?:json)?\s*/m', '', $text);
|
||||
$text = preg_replace('/```\s*$/m', '', $text);
|
||||
$text = trim($text);
|
||||
|
||||
$decoded = json_decode($text, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
Log::warning('GeminiScriptService: JSON 파싱 실패', [
|
||||
'error' => json_last_error_msg(),
|
||||
'text' => substr($text, 0, 500),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
122
app/Services/Video/TtsService.php
Normal file
122
app/Services/Video/TtsService.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Video;
|
||||
|
||||
use App\Services\GoogleCloudService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TtsService
|
||||
{
|
||||
private GoogleCloudService $googleCloud;
|
||||
|
||||
public function __construct(GoogleCloudService $googleCloud)
|
||||
{
|
||||
$this->googleCloud = $googleCloud;
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 → MP3 음성 파일 생성
|
||||
*
|
||||
* @return string|null 저장된 파일 경로
|
||||
*/
|
||||
public function synthesize(string $text, string $savePath, array $options = []): ?string
|
||||
{
|
||||
$token = $this->googleCloud->getAccessToken();
|
||||
if (! $token) {
|
||||
Log::error('TtsService: 액세스 토큰 획득 실패');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$languageCode = $options['language_code'] ?? 'ko-KR';
|
||||
$voiceName = $options['voice_name'] ?? 'ko-KR-Wavenet-A';
|
||||
$speakingRate = $options['speaking_rate'] ?? 1.0;
|
||||
$pitch = $options['pitch'] ?? 0.0;
|
||||
|
||||
$response = Http::withToken($token)
|
||||
->timeout(30)
|
||||
->post('https://texttospeech.googleapis.com/v1/text:synthesize', [
|
||||
'input' => [
|
||||
'text' => $text,
|
||||
],
|
||||
'voice' => [
|
||||
'languageCode' => $languageCode,
|
||||
'name' => $voiceName,
|
||||
],
|
||||
'audioConfig' => [
|
||||
'audioEncoding' => 'MP3',
|
||||
'speakingRate' => $speakingRate,
|
||||
'pitch' => $pitch,
|
||||
'sampleRateHertz' => 24000,
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('TtsService: TTS API 실패', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$audioContent = $data['audioContent'] ?? null;
|
||||
|
||||
if (! $audioContent) {
|
||||
Log::error('TtsService: 오디오 데이터 없음');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$dir = dirname($savePath);
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($savePath, base64_decode($audioContent));
|
||||
|
||||
Log::info('TtsService: 음성 파일 생성 완료', [
|
||||
'path' => $savePath,
|
||||
'text_length' => mb_strlen($text),
|
||||
]);
|
||||
|
||||
return $savePath;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('TtsService: 예외 발생', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 장면별 일괄 나레이션 생성
|
||||
*
|
||||
* @param array $scenes [{narration, scene_number}, ...]
|
||||
* @return array [scene_number => file_path, ...]
|
||||
*/
|
||||
public function synthesizeScenes(array $scenes, string $baseDir): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($scenes as $scene) {
|
||||
$sceneNum = $scene['scene_number'] ?? 0;
|
||||
$narration = $scene['narration'] ?? '';
|
||||
|
||||
if (empty($narration)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$savePath = "{$baseDir}/narration_{$sceneNum}.mp3";
|
||||
$result = $this->synthesize($narration, $savePath);
|
||||
|
||||
if ($result) {
|
||||
$results[$sceneNum] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
182
app/Services/Video/VeoVideoService.php
Normal file
182
app/Services/Video/VeoVideoService.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Video;
|
||||
|
||||
use App\Services\GoogleCloudService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class VeoVideoService
|
||||
{
|
||||
private GoogleCloudService $googleCloud;
|
||||
|
||||
private string $projectId;
|
||||
|
||||
private string $location;
|
||||
|
||||
public function __construct(GoogleCloudService $googleCloud)
|
||||
{
|
||||
$this->googleCloud = $googleCloud;
|
||||
$this->projectId = config('services.vertex_ai.project_id', 'codebridge-chatbot');
|
||||
$this->location = config('services.vertex_ai.location', 'us-central1');
|
||||
}
|
||||
|
||||
/**
|
||||
* 프롬프트 → 영상 클립 생성 요청 (비동기)
|
||||
*
|
||||
* @return array|null ['operationName' => '...'] or null
|
||||
*/
|
||||
public function generateClip(string $prompt, int $duration = 8): ?array
|
||||
{
|
||||
$token = $this->googleCloud->getAccessToken();
|
||||
if (! $token) {
|
||||
Log::error('VeoVideoService: 액세스 토큰 획득 실패');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$url = "https://{$this->location}-aiplatform.googleapis.com/v1/projects/{$this->projectId}/locations/{$this->location}/publishers/google/models/veo-3.1-generate-preview:predictLongRunning";
|
||||
|
||||
$response = Http::withToken($token)
|
||||
->timeout(60)
|
||||
->post($url, [
|
||||
'instances' => [
|
||||
[
|
||||
'prompt' => $prompt,
|
||||
],
|
||||
],
|
||||
'parameters' => [
|
||||
'aspectRatio' => '9:16',
|
||||
'duration' => "{$duration}s",
|
||||
'resolution' => '720p',
|
||||
'personGeneration' => 'allow_adult',
|
||||
'generateAudio' => false,
|
||||
'sampleCount' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('VeoVideoService: 영상 생성 요청 실패', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$operationName = $data['name'] ?? null;
|
||||
|
||||
if (! $operationName) {
|
||||
Log::error('VeoVideoService: Operation name 없음', ['response' => $data]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Log::info('VeoVideoService: 영상 생성 요청 성공', [
|
||||
'operationName' => $operationName,
|
||||
'prompt' => substr($prompt, 0, 100),
|
||||
]);
|
||||
|
||||
return ['operationName' => $operationName];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('VeoVideoService: 영상 생성 예외', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비동기 작업 상태 확인
|
||||
*
|
||||
* @return array ['done' => bool, 'video' => base64|null, 'error' => string|null]
|
||||
*/
|
||||
public function checkOperation(string $operationName): array
|
||||
{
|
||||
$token = $this->googleCloud->getAccessToken();
|
||||
if (! $token) {
|
||||
return ['done' => false, 'video' => null, 'error' => '토큰 획득 실패'];
|
||||
}
|
||||
|
||||
try {
|
||||
$url = "https://{$this->location}-aiplatform.googleapis.com/v1/{$operationName}";
|
||||
|
||||
$response = Http::withToken($token)->timeout(30)->get($url);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return ['done' => false, 'video' => null, 'error' => 'HTTP ' . $response->status()];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (! ($data['done'] ?? false)) {
|
||||
return ['done' => false, 'video' => null, 'error' => null];
|
||||
}
|
||||
|
||||
if (isset($data['error'])) {
|
||||
return ['done' => true, 'video' => null, 'error' => $data['error']['message'] ?? 'Unknown error'];
|
||||
}
|
||||
|
||||
// 영상 데이터 추출
|
||||
$predictions = $data['response']['predictions'] ?? [];
|
||||
if (empty($predictions)) {
|
||||
return ['done' => true, 'video' => null, 'error' => '영상 데이터 없음'];
|
||||
}
|
||||
|
||||
$videoBase64 = $predictions[0]['bytesBase64Encoded'] ?? null;
|
||||
|
||||
return ['done' => true, 'video' => $videoBase64, 'error' => null];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('VeoVideoService: 상태 확인 예외', ['error' => $e->getMessage()]);
|
||||
|
||||
return ['done' => false, 'video' => null, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상 생성 완료까지 폴링 대기
|
||||
*
|
||||
* @return string|null 저장된 파일 경로 또는 null
|
||||
*/
|
||||
public function waitAndSave(string $operationName, string $savePath, int $maxAttempts = 120): ?string
|
||||
{
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
sleep(10);
|
||||
|
||||
$result = $this->checkOperation($operationName);
|
||||
|
||||
if ($result['error'] && $result['done']) {
|
||||
Log::error('VeoVideoService: 영상 생성 실패', ['error' => $result['error']]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($result['done'] && $result['video']) {
|
||||
$videoData = base64_decode($result['video']);
|
||||
$dir = dirname($savePath);
|
||||
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($savePath, $videoData);
|
||||
|
||||
Log::info('VeoVideoService: 영상 저장 완료', [
|
||||
'path' => $savePath,
|
||||
'size' => strlen($videoData),
|
||||
]);
|
||||
|
||||
return $savePath;
|
||||
}
|
||||
}
|
||||
|
||||
Log::error('VeoVideoService: 영상 생성 타임아웃', [
|
||||
'operationName' => $operationName,
|
||||
'attempts' => $maxAttempts,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
384
app/Services/Video/VideoAssemblyService.php
Normal file
384
app/Services/Video/VideoAssemblyService.php
Normal file
@@ -0,0 +1,384 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@
|
||||
'location' => env('GOOGLE_STT_LOCATION', 'asia-southeast1'),
|
||||
],
|
||||
|
||||
'vertex_ai' => [
|
||||
'project_id' => env('VERTEX_AI_PROJECT_ID', 'codebridge-chatbot'),
|
||||
'location' => env('VERTEX_AI_LOCATION', 'us-central1'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 바로빌 API
|
||||
|
||||
619
resources/views/video/veo3/index.blade.php
Normal file
619
resources/views/video/veo3/index.blade.php
Normal file
@@ -0,0 +1,619 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'YouTube Shorts AI 생성기')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.step-badge { @apply inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold; }
|
||||
.step-active { @apply bg-indigo-600 text-white; }
|
||||
.step-done { @apply bg-green-500 text-white; }
|
||||
.step-pending { @apply bg-gray-200 text-gray-500; }
|
||||
.scene-card { @apply bg-white border rounded-lg p-4 mb-3 shadow-sm; }
|
||||
.progress-bar { @apply h-3 rounded-full transition-all duration-500 ease-out; }
|
||||
.title-option { @apply border rounded-lg p-3 cursor-pointer transition-all hover:border-indigo-400 hover:bg-indigo-50; }
|
||||
.title-option.selected { @apply border-indigo-600 bg-indigo-50 ring-2 ring-indigo-200; }
|
||||
.fade-in { animation: fadeIn 0.3s ease-in; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="veo3-root"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@include('partials.react-cdn')
|
||||
@verbatim
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback, useRef } = React;
|
||||
|
||||
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
|
||||
const api = (url, options = {}) => {
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': CSRF,
|
||||
'Accept': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
}).then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step Indicator
|
||||
// ============================================================
|
||||
const StepIndicator = ({ currentStep }) => {
|
||||
const steps = [
|
||||
{ num: 1, label: '키워드 입력' },
|
||||
{ num: 2, label: '제목 선택' },
|
||||
{ num: 3, label: '시나리오 확인' },
|
||||
{ num: 4, label: '영상 생성' },
|
||||
{ num: 5, label: '완성' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{steps.map((step, i) => (
|
||||
<React.Fragment key={step.num}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`step-badge ${currentStep > step.num ? 'step-done' : currentStep === step.num ? 'step-active' : 'step-pending'}`}>
|
||||
{currentStep > step.num ? '✓' : step.num}
|
||||
</div>
|
||||
<span className={`text-xs mt-1 ${currentStep >= step.num ? 'text-indigo-600 font-medium' : 'text-gray-400'}`}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div className={`w-12 h-0.5 mt-[-12px] ${currentStep > step.num ? 'bg-green-500' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 1: Keyword Input
|
||||
// ============================================================
|
||||
const KeywordInput = ({ onSubmit, loading }) => {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (keyword.trim()) onSubmit(keyword.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fade-in max-w-lg mx-auto">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">키워드를 입력하세요</h2>
|
||||
<p className="text-gray-500 mt-2">AI가 트렌딩 YouTube Shorts 제목을 생성합니다</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex gap-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="예: 다이어트, 주식투자, 여행..."
|
||||
className="flex-1 border border-gray-300 rounded-lg px-4 py-3 text-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
maxLength={100}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!keyword.trim() || loading}
|
||||
className="bg-indigo-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/></svg>
|
||||
생성 중...
|
||||
</span>
|
||||
) : '제목 생성'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 2: Title Selection
|
||||
// ============================================================
|
||||
const TitleSelection = ({ titles, onSelect, onBack, loading }) => {
|
||||
const [selectedIdx, setSelectedIdx] = useState(null);
|
||||
|
||||
return (
|
||||
<div className="fade-in max-w-lg mx-auto">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">제목을 선택하세요</h2>
|
||||
<p className="text-gray-500 mt-2">클릭하여 마음에 드는 제목을 선택합니다</p>
|
||||
</div>
|
||||
<div className="space-y-3 mb-6">
|
||||
{titles.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`title-option ${selectedIdx === i ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedIdx(i)}
|
||||
>
|
||||
<div className="font-medium text-gray-800">{item.title}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{item.hook}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
뒤로
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedIdx !== null && onSelect(titles[selectedIdx].title)}
|
||||
disabled={selectedIdx === null || loading}
|
||||
className="flex-1 bg-indigo-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? '시나리오 생성 중...' : '이 제목으로 시나리오 생성'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 3: Scenario Preview & Edit
|
||||
// ============================================================
|
||||
const ScenarioPreview = ({ scenario, onGenerate, onBack, loading }) => {
|
||||
const [scenes, setScenes] = useState(scenario.scenes || []);
|
||||
const [bgmMood, setBgmMood] = useState(scenario.bgm_mood || 'upbeat');
|
||||
|
||||
const updateScene = (idx, field, value) => {
|
||||
const updated = [...scenes];
|
||||
updated[idx] = { ...updated[idx], [field]: value };
|
||||
setScenes(updated);
|
||||
};
|
||||
|
||||
const totalDuration = scenes.reduce((sum, s) => sum + (s.duration || 0), 0);
|
||||
|
||||
const handleGenerate = () => {
|
||||
onGenerate({ ...scenario, scenes, bgm_mood: bgmMood, total_duration: totalDuration });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fade-in max-w-2xl mx-auto">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">시나리오 미리보기</h2>
|
||||
<p className="text-gray-500 mt-2">
|
||||
"{scenario.title}" | 총 {totalDuration}초 | {scenes.length}개 장면
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{scenes.map((scene, i) => (
|
||||
<div key={i} className="scene-card">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-indigo-600">
|
||||
장면 {scene.scene_number} ({scene.duration}초)
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 rounded-full text-gray-500">
|
||||
{scene.mood}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="text-xs text-gray-500 font-medium">나레이션 (자막)</label>
|
||||
<textarea
|
||||
value={scene.narration}
|
||||
onChange={(e) => updateScene(i, 'narration', e.target.value)}
|
||||
className="w-full border border-gray-200 rounded p-2 text-sm mt-1 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 font-medium">영상 프롬프트 (영어)</label>
|
||||
<textarea
|
||||
value={scene.visual_prompt}
|
||||
onChange={(e) => updateScene(i, 'visual_prompt', e.target.value)}
|
||||
className="w-full border border-gray-200 rounded p-2 text-sm mt-1 resize-none font-mono"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-6 p-3 bg-gray-50 rounded-lg">
|
||||
<label className="text-sm font-medium text-gray-600">BGM 분위기</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bgmMood}
|
||||
onChange={(e) => setBgmMood(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded p-2 text-sm mt-1"
|
||||
placeholder="upbeat, energetic, calm..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6 text-sm text-amber-700">
|
||||
예상 비용: ~$7.72 (Veo 3.1 Fast 기준) | 소요 시간: 약 10~20분
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
뒤로
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-green-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? '요청 중...' : '영상 생성 시작'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 4: Progress
|
||||
// ============================================================
|
||||
const GenerationProgress = ({ videoId }) => {
|
||||
const [status, setStatus] = useState(null);
|
||||
const intervalRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await api(`/video/veo3/status/${videoId}`);
|
||||
setStatus(data);
|
||||
|
||||
if (data.status === 'completed' || data.status === 'failed') {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Poll error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
intervalRef.current = setInterval(poll, 5000);
|
||||
|
||||
return () => clearInterval(intervalRef.current);
|
||||
}, [videoId]);
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<svg className="animate-spin h-10 w-10 mx-auto text-indigo-600" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
||||
</svg>
|
||||
<p className="mt-3 text-gray-500">상태 확인 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progressColor = status.status === 'failed' ? 'bg-red-500' : status.progress === 100 ? 'bg-green-500' : 'bg-indigo-600';
|
||||
|
||||
return (
|
||||
<div className="fade-in max-w-lg mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800">
|
||||
{status.status === 'completed' ? '영상 생성 완료!' : status.status === 'failed' ? '생성 실패' : '영상 생성 중...'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-gray-600">{status.current_step || '대기 중'}</span>
|
||||
<span className="font-bold text-indigo-600">{status.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div className={`progress-bar ${progressColor}`} style={{ width: `${status.progress}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{status.status === 'failed' && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-red-700 text-sm">{status.error_message || '알 수 없는 오류가 발생했습니다.'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost */}
|
||||
{status.cost_usd > 0 && (
|
||||
<div className="text-center text-sm text-gray-500 mb-4">
|
||||
예상 비용: ${parseFloat(status.cost_usd).toFixed(4)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status not completed yet */}
|
||||
{!['completed', 'failed'].includes(status.status) && (
|
||||
<div className="text-center text-sm text-gray-400">
|
||||
5초마다 자동으로 상태를 확인합니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 5: Completed
|
||||
// ============================================================
|
||||
const CompletedView = ({ videoId }) => {
|
||||
return (
|
||||
<div className="fade-in max-w-lg mx-auto text-center">
|
||||
<div className="mb-6">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">YouTube Shorts 영상 완성!</h2>
|
||||
<p className="text-gray-500 mt-2">영상을 미리보기하고 다운로드하세요</p>
|
||||
</div>
|
||||
|
||||
{/* Video Preview */}
|
||||
<div className="mb-6 bg-black rounded-lg overflow-hidden" style={{ maxHeight: '500px' }}>
|
||||
<video
|
||||
src={`/video/veo3/preview/${videoId}`}
|
||||
controls
|
||||
className="w-full mx-auto"
|
||||
style={{ maxHeight: '500px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
<a
|
||||
href={`/video/veo3/download/${videoId}`}
|
||||
className="bg-indigo-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-indigo-700 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
MP4 다운로드
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// History Table
|
||||
// ============================================================
|
||||
const HistoryTable = ({ onSelect }) => {
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api('/video/veo3/history')
|
||||
.then(data => setHistory(data.data || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="text-center py-4 text-gray-400">이력 로딩 중...</div>;
|
||||
if (history.length === 0) return null;
|
||||
|
||||
const statusLabels = {
|
||||
pending: '대기',
|
||||
titles_generated: '제목 생성됨',
|
||||
scenario_ready: '시나리오 준비',
|
||||
generating_tts: 'TTS 생성',
|
||||
generating_clips: '영상 생성',
|
||||
generating_bgm: 'BGM 생성',
|
||||
assembling: '합성 중',
|
||||
completed: '완료',
|
||||
failed: '실패',
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-12">
|
||||
<h3 className="text-lg font-bold text-gray-700 mb-4">생성 이력</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-gray-50">
|
||||
<th className="text-left py-2 px-3 font-medium text-gray-500">날짜</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-gray-500">키워드</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-gray-500">제목</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-gray-500">상태</th>
|
||||
<th className="text-right py-2 px-3 font-medium text-gray-500">비용</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-gray-500">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((item) => (
|
||||
<tr key={item.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 px-3 text-gray-600">{new Date(item.created_at).toLocaleDateString('ko-KR')}</td>
|
||||
<td className="py-2 px-3 font-medium">{item.keyword}</td>
|
||||
<td className="py-2 px-3 text-gray-600 truncate max-w-[200px]">{item.title || '-'}</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${statusColors[item.status] || 'bg-blue-100 text-blue-700'}`}>
|
||||
{statusLabels[item.status] || item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right text-gray-600">
|
||||
{item.cost_usd > 0 ? `$${parseFloat(item.cost_usd).toFixed(2)}` : '-'}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{item.status === 'completed' && (
|
||||
<a href={`/video/veo3/download/${item.id}`} className="text-indigo-600 hover:underline text-xs">
|
||||
다운로드
|
||||
</a>
|
||||
)}
|
||||
{['generating_tts','generating_clips','generating_bgm','assembling'].includes(item.status) && (
|
||||
<button onClick={() => onSelect(item.id)} className="text-blue-600 hover:underline text-xs">
|
||||
진행 확인
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Main App
|
||||
// ============================================================
|
||||
const App = () => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [videoId, setVideoId] = useState(null);
|
||||
const [titles, setTitles] = useState([]);
|
||||
const [scenario, setScenario] = useState(null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Step 1: Generate Titles
|
||||
const handleKeywordSubmit = async (kw) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setKeyword(kw);
|
||||
try {
|
||||
const data = await api('/video/veo3/titles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keyword: kw }),
|
||||
});
|
||||
setVideoId(data.video_id);
|
||||
setTitles(data.titles);
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Select Title → Generate Scenario
|
||||
const handleTitleSelect = async (title) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api('/video/veo3/scenario', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ video_id: videoId, title }),
|
||||
});
|
||||
setScenario(data.scenario);
|
||||
setStep(3);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 3: Start Generation
|
||||
const handleGenerate = async (editedScenario) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await api('/video/veo3/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ video_id: videoId, scenario: editedScenario }),
|
||||
});
|
||||
setStep(4);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// History item click → jump to progress
|
||||
const handleHistorySelect = (id) => {
|
||||
setVideoId(id);
|
||||
setStep(4);
|
||||
};
|
||||
|
||||
// Determine actual display step from polling status
|
||||
const ProgressWithCompletion = ({ videoId }) => {
|
||||
const [done, setDone] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const check = async () => {
|
||||
try {
|
||||
const data = await api(`/video/veo3/status/${videoId}`);
|
||||
if (data.status === 'completed') setDone(true);
|
||||
if (data.status === 'failed') setFailed(true);
|
||||
} catch (err) {}
|
||||
};
|
||||
check();
|
||||
const iv = setInterval(check, 5000);
|
||||
return () => clearInterval(iv);
|
||||
}, [videoId]);
|
||||
|
||||
if (done) return <CompletedView videoId={videoId} />;
|
||||
return <GenerationProgress videoId={videoId} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">YouTube Shorts AI 생성기</h1>
|
||||
<p className="text-gray-500 mt-2">키워드 하나로 AI가 YouTube Shorts 영상을 자동 생성합니다</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Gemini 2.5 Flash + Veo 3.1 + Google TTS</p>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<StepIndicator currentStep={step} />
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="max-w-lg mx-auto mb-6 bg-red-50 border border-red-200 rounded-lg p-3 text-red-700 text-sm">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 text-red-500 hover:text-red-700">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps */}
|
||||
{step === 1 && <KeywordInput onSubmit={handleKeywordSubmit} loading={loading} />}
|
||||
{step === 2 && <TitleSelection titles={titles} onSelect={handleTitleSelect} onBack={() => setStep(1)} loading={loading} />}
|
||||
{step === 3 && scenario && <ScenarioPreview scenario={scenario} onGenerate={handleGenerate} onBack={() => setStep(2)} loading={loading} />}
|
||||
{step >= 4 && videoId && <ProgressWithCompletion videoId={videoId} />}
|
||||
|
||||
{/* New Generation Button (after completion) */}
|
||||
{step >= 4 && (
|
||||
<div className="text-center mt-8">
|
||||
<button
|
||||
onClick={() => { setStep(1); setVideoId(null); setTitles([]); setScenario(null); setError(''); }}
|
||||
className="text-indigo-600 hover:text-indigo-800 text-sm font-medium"
|
||||
>
|
||||
+ 새 영상 만들기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<HistoryTable onSelect={handleHistorySelect} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('veo3-root')).render(<App />);
|
||||
</script>
|
||||
@endverbatim
|
||||
@endpush
|
||||
@@ -1457,6 +1457,22 @@
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| YouTube Shorts AI Generator (Veo 3.1)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::prefix('video/veo3')->name('video.veo3.')->middleware('auth')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Video\Veo3Controller::class, 'index'])->name('index');
|
||||
Route::post('/titles', [\App\Http\Controllers\Video\Veo3Controller::class, 'generateTitles'])->name('titles');
|
||||
Route::post('/scenario', [\App\Http\Controllers\Video\Veo3Controller::class, 'generateScenario'])->name('scenario');
|
||||
Route::post('/generate', [\App\Http\Controllers\Video\Veo3Controller::class, 'generate'])->name('generate');
|
||||
Route::get('/status/{id}', [\App\Http\Controllers\Video\Veo3Controller::class, 'status'])->name('status');
|
||||
Route::get('/download/{id}', [\App\Http\Controllers\Video\Veo3Controller::class, 'download'])->name('download');
|
||||
Route::get('/preview/{id}', [\App\Http\Controllers\Video\Veo3Controller::class, 'preview'])->name('preview');
|
||||
Route::get('/history', [\App\Http\Controllers\Video\Veo3Controller::class, 'history'])->name('history');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| SAM E-Sign Public Routes (인증 불필요 - 서명자용)
|
||||
|
||||
Reference in New Issue
Block a user