header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('video.veo3.index')); } return view('video.veo3.index'); } /** * 실시간 급상승 키워드 목록 (건강 채널용 필터링) */ public function fetchTrending(): JsonResponse { // 1단계: 원본 트렌딩 키워드 수집 (자체 캐시 있음) $rawKeywords = $this->trendingService->fetchTrendingKeywords(20); if (empty($rawKeywords)) { return response()->json([ 'success' => false, 'keywords' => [], 'reason' => 'rss_fail', 'message' => 'Google 트렌드 데이터를 가져올 수 없습니다. 잠시 후 다시 시도해주세요.', ]); } // 2단계: Gemini 필터링 (성공 결과만 캐시) $cached = Cache::get('health_trending'); if ($cached !== null && ! empty($cached)) { return response()->json([ 'success' => true, 'keywords' => $cached, ]); } // 직접 필터링 시도 $healthKeywords = $this->geminiService->filterHealthTrending($rawKeywords); // 건강 키워드가 없으면 리프레이밍 폴백 시도 if (empty($healthKeywords)) { $healthKeywords = $this->geminiService->reframeAsHealthTrending($rawKeywords); } if (! empty($healthKeywords)) { Cache::put('health_trending', $healthKeywords, 1800); return response()->json([ 'success' => true, 'keywords' => $healthKeywords, ]); } // 최종 폴백: 원본 키워드 상위 5개를 건강 없이 반환 $fallback = collect($rawKeywords)->take(5)->map(fn ($item) => [ 'keyword' => $item['keyword'], 'health_angle' => '', 'suggested_topic' => $item['keyword'], 'traffic' => $item['traffic'] ?? '', 'news_title' => $item['news_title'] ?? '', 'pub_date' => $item['pub_date'] ?? null, 'is_raw' => true, ])->values()->toArray(); return response()->json([ 'success' => true, 'keywords' => $fallback, 'reason' => 'no_health_match', 'message' => '건강 관련 트렌딩이 없어 인기 키워드를 표시합니다.', ]); } /** * 키워드 → 제목 후보 생성 (trending_context 옵션 지원) */ public function generateTitles(Request $request): JsonResponse { $request->validate([ 'keyword' => 'required|string|max:100', 'trending_context' => 'nullable|array', ]); $keyword = $request->input('keyword'); $trendingContext = $request->input('trending_context'); if ($trendingContext) { $titles = $this->geminiService->generateTrendingHookTitles($keyword, $trendingContext); } else { $titles = $this->geminiService->generateTrendingTitles($keyword); } if (empty($titles)) { return response()->json([ 'success' => false, 'message' => '제목 생성에 실패했습니다. API 키를 확인해주세요.', ], 500); } // DB에 pending 레코드 생성 $video = VideoGeneration::create([ 'tenant_id' => session('selected_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, 'updated_at' => $video->updated_at?->toIso8601String(), 'created_at' => $video->created_at?->toIso8601String(), ]); } /** * 완성 영상 다운로드 */ public function download(int $id): BinaryFileResponse|RedirectResponse|JsonResponse { $video = VideoGeneration::findOrFail($id); if ($video->status !== VideoGeneration::STATUS_COMPLETED || ! $video->output_path) { return response()->json(['message' => '아직 영상이 완성되지 않았습니다.'], 404); } // GCS 서명URL로 리다이렉트 if ($video->gcs_path && $this->gcsService->isAvailable()) { $signedUrl = $this->gcsService->getSignedUrl($video->gcs_path, 30); if ($signedUrl) { return redirect()->away($signedUrl); } } // 로컬 파일 폴백 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|RedirectResponse|JsonResponse { $video = VideoGeneration::findOrFail($id); // GCS 서명URL로 리다이렉트 if ($video->gcs_path && $this->gcsService->isAvailable()) { $signedUrl = $this->gcsService->getSignedUrl($video->gcs_path, 60); if ($signedUrl) { return redirect()->away($signedUrl); } } // 로컬 파일 폴백 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', ]); } /** * 생성 이력 상세 (시나리오, 프롬프트, YouTube 텍스트 등) */ public function show(int $id): JsonResponse { $video = VideoGeneration::where('user_id', auth()->id())->findOrFail($id); $scenario = $video->scenario ?? []; $scenes = $scenario['scenes'] ?? []; // YouTube Shorts 텍스트 생성 (완료된 영상만) $youtubeText = null; if ($video->status === VideoGeneration::STATUS_COMPLETED) { $youtubeText = $this->buildYoutubeText($video, $scenario, $scenes); } return response()->json([ 'success' => true, 'data' => [ 'id' => $video->id, 'keyword' => $video->keyword, 'title' => $video->title, 'status' => $video->status, 'progress' => $video->progress, 'current_step' => $video->current_step, 'error_message' => $video->error_message, 'cost_usd' => $video->cost_usd, 'created_at' => $video->created_at?->toIso8601String(), 'updated_at' => $video->updated_at?->toIso8601String(), 'scenario' => $scenario, 'scenes' => array_map(function ($scene) { return [ 'scene_number' => $scene['scene_number'] ?? null, 'scene_type' => $scene['scene_type'] ?? null, 'narration' => $scene['narration'] ?? '', 'visual_prompt' => $scene['visual_prompt'] ?? '', 'duration' => $scene['duration'] ?? 0, 'mood' => $scene['mood'] ?? '', ]; }, $scenes), 'clips_data' => $video->clips_data, 'youtube_text' => $youtubeText, ], ]); } /** * YouTube Shorts 제목 + 설명 텍스트 빌드 */ private function buildYoutubeText(VideoGeneration $video, array $scenario, array $scenes): array { $title = $video->title ?? ''; $keyword = $video->keyword ?? ''; // 해시태그 생성 $hashtags = ['#shorts', '#쇼츠']; if ($keyword) { $hashtags[] = '#'.str_replace(' ', '', $keyword); } // 시나리오에서 추가 태그 추출 $bgmMood = $scenario['bgm_mood'] ?? ''; if ($bgmMood) { $hashtags[] = '#'.$bgmMood; } $hashtags = array_merge($hashtags, ['#건강', '#건강정보', '#헬스']); // 설명란 텍스트 $descLines = []; $descLines[] = $title; $descLines[] = ''; // 핵심 내용 요약 (나레이션에서 추출) foreach ($scenes as $scene) { $narration = $scene['narration'] ?? ''; if ($narration && ($scene['scene_type'] ?? '') !== 'HOOK') { $descLines[] = '- '.mb_substr($narration, 0, 60); } } $descLines[] = ''; $descLines[] = implode(' ', array_unique($hashtags)); return [ 'title' => $title, 'description' => implode("\n", $descLines), 'hashtags' => array_unique($hashtags), ]; } /** * 생성 이력 목록 */ 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', 'updated_at']); return response()->json([ 'success' => true, 'data' => $videos, ]); } /** * 생성 이력 삭제 (복수) */ public function destroy(Request $request): JsonResponse { $request->validate([ 'ids' => 'required|array|min:1', 'ids.*' => 'integer', ]); $videos = VideoGeneration::where('user_id', auth()->id()) ->whereIn('id', $request->input('ids')) ->get(); $deleted = 0; foreach ($videos as $video) { // GCS 파일 삭제 if ($video->gcs_path && $this->gcsService->isAvailable()) { $this->gcsService->delete($video->gcs_path); } // 로컬 작업 디렉토리 삭제 (클립, 나레이션, 최종 영상 등) $workDir = storage_path("app/video_gen/{$video->id}"); if (is_dir($workDir)) { $files = glob("{$workDir}/*"); foreach ($files as $file) { if (is_file($file)) { @unlink($file); } } @rmdir($workDir); } $video->delete(); $deleted++; } return response()->json([ 'success' => true, 'deleted' => $deleted, ]); } }