diff --git a/app/Http/Controllers/Video/Veo3Controller.php b/app/Http/Controllers/Video/Veo3Controller.php new file mode 100644 index 00000000..e1117f70 --- /dev/null +++ b/app/Http/Controllers/Video/Veo3Controller.php @@ -0,0 +1,221 @@ +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, + ]); + } +} diff --git a/app/Jobs/VideoGenerationJob.php b/app/Jobs/VideoGenerationJob.php new file mode 100644 index 00000000..79caf6f7 --- /dev/null +++ b/app/Jobs/VideoGenerationJob.php @@ -0,0 +1,241 @@ +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()); + } +} diff --git a/app/Models/VideoGeneration.php b/app/Models/VideoGeneration.php new file mode 100644 index 00000000..e6d2239a --- /dev/null +++ b/app/Models/VideoGeneration.php @@ -0,0 +1,85 @@ + '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, + ]); + } +} diff --git a/app/Services/GoogleCloudService.php b/app/Services/GoogleCloudService.php index bce1ff40..f9406d75 100644 --- a/app/Services/GoogleCloudService.php +++ b/app/Services/GoogleCloudService.php @@ -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) { diff --git a/app/Services/Video/BgmService.php b/app/Services/Video/BgmService.php new file mode 100644 index 00000000..3f685be1 --- /dev/null +++ b/app/Services/Video/BgmService.php @@ -0,0 +1,136 @@ + ['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; + } +} diff --git a/app/Services/Video/GeminiScriptService.php b/app/Services/Video/GeminiScriptService.php new file mode 100644 index 00000000..e354fded --- /dev/null +++ b/app/Services/Video/GeminiScriptService.php @@ -0,0 +1,187 @@ +apiKey = config('services.gemini.api_key', ''); + } + + /** + * 키워드 → 트렌딩 제목 5개 생성 + */ + public function generateTrendingTitles(string $keyword): array + { + $prompt = <<callGemini($prompt); + + if (! $result) { + return []; + } + + $parsed = $this->parseJsonResponse($result); + + return $parsed['titles'] ?? []; + } + + /** + * 제목 → 장면별 시나리오 생성 + */ + public function generateScenario(string $title, string $keyword = ''): array + { + $prompt = <<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; + } +} diff --git a/app/Services/Video/TtsService.php b/app/Services/Video/TtsService.php new file mode 100644 index 00000000..d9d8756f --- /dev/null +++ b/app/Services/Video/TtsService.php @@ -0,0 +1,122 @@ +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; + } +} diff --git a/app/Services/Video/VeoVideoService.php b/app/Services/Video/VeoVideoService.php new file mode 100644 index 00000000..bc9aceb0 --- /dev/null +++ b/app/Services/Video/VeoVideoService.php @@ -0,0 +1,182 @@ +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; + } +} diff --git a/app/Services/Video/VideoAssemblyService.php b/app/Services/Video/VideoAssemblyService.php new file mode 100644 index 00000000..2639dba6 --- /dev/null +++ b/app/Services/Video/VideoAssemblyService.php @@ -0,0 +1,384 @@ + $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); + } + } + } +} diff --git a/config/services.php b/config/services.php index 5d36fecb..aed0f8e0 100644 --- a/config/services.php +++ b/config/services.php @@ -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 diff --git a/resources/views/video/veo3/index.blade.php b/resources/views/video/veo3/index.blade.php new file mode 100644 index 00000000..5e550ee2 --- /dev/null +++ b/resources/views/video/veo3/index.blade.php @@ -0,0 +1,619 @@ +@extends('layouts.app') + +@section('title', 'YouTube Shorts AI 생성기') + +@push('styles') + +@endpush + +@section('content') + +
+@endsection + +@push('scripts') +@include('partials.react-cdn') +@verbatim + +@endverbatim +@endpush diff --git a/routes/web.php b/routes/web.php index e7f98e53..5311c798 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 (인증 불필요 - 서명자용)