diff --git a/app/Http/Controllers/Video/Veo3Controller.php b/app/Http/Controllers/Video/Veo3Controller.php index eb132ca1..eb69308f 100644 --- a/app/Http/Controllers/Video/Veo3Controller.php +++ b/app/Http/Controllers/Video/Veo3Controller.php @@ -256,6 +256,95 @@ public function preview(int $id): Response|RedirectResponse|JsonResponse ]); } + /** + * 생성 이력 상세 (시나리오, 프롬프트, 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), + ]; + } + /** * 생성 이력 목록 */ diff --git a/resources/views/video/veo3/index.blade.php b/resources/views/video/veo3/index.blade.php index 6b41c087..1f065dd4 100644 --- a/resources/views/video/veo3/index.blade.php +++ b/resources/views/video/veo3/index.blade.php @@ -633,7 +633,7 @@ className="bg-indigo-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-in // ============================================================ // History Table // ============================================================ -const HistoryTable = ({ onSelect }) => { +const HistoryTable = ({ onSelect, onDetail }) => { const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); const [checked, setChecked] = useState(new Set()); @@ -762,8 +762,12 @@ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 {history.map((item) => ( - - + onDetail(item.id)} + > + e.stopPropagation()}> {item.cost_usd > 0 ? `$${parseFloat(item.cost_usd).toFixed(2)}` : '-'} - + e.stopPropagation()}> {item.status === 'completed' && ( 다운로드 @@ -794,6 +798,11 @@ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 진행 확인 )} + {!['completed','generating_tts','generating_clips','generating_bgm','assembling'].includes(item.status) && ( + + )} ))} @@ -804,6 +813,255 @@ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 ); }; +// ============================================================ +// Detail Modal (이력 상세보기 + YouTube 텍스트) +// ============================================================ +const DetailModal = ({ videoId, onClose }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [copiedField, setCopiedField] = useState(''); + const [activeTab, setActiveTab] = useState('scenario'); + + useEffect(() => { + if (!videoId) return; + setLoading(true); + setError(''); + api(`/video/veo3/detail/${videoId}`) + .then(res => setData(res.data)) + .catch(err => setError(err.message)) + .finally(() => setLoading(false)); + }, [videoId]); + + const copyToClipboard = (text, field) => { + navigator.clipboard.writeText(text).then(() => { + setCopiedField(field); + setTimeout(() => setCopiedField(''), 2000); + }); + }; + + const statusLabels = { + pending: '대기', titles_generated: '제목 생성됨', scenario_ready: '시나리오 준비', + generating_tts: 'TTS 생성', generating_clips: '영상 생성', generating_bgm: 'BGM 생성', + assembling: '합성 중', completed: '완료', failed: '실패', + }; + + if (!videoId) return null; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

생성 상세 정보

+ +
+ + {/* Content */} +
+ {loading && ( +
+ +

데이터 로딩 중...

+
+ )} + + {error && ( +
{error}
+ )} + + {data && !loading && ( + <> + {/* Summary */} +
+
+
키워드
+
{data.keyword}
+
+
+
상태
+
{statusLabels[data.status] || data.status}
+
+
+
비용
+
{data.cost_usd > 0 ? `$${parseFloat(data.cost_usd).toFixed(2)}` : '-'}
+
+
+
생성일시
+
{data.created_at ? new Date(data.created_at).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) : '-'}
+
+
+ + {/* Title */} +
+
제목
+
{data.title || '-'}
+
+ + {/* Error (if failed) */} + {data.status === 'failed' && data.error_message && ( +
+
에러 메시지
+
{data.error_message}
+
+ )} + + {/* Tabs */} +
+
+ {[ + { id: 'scenario', label: '시나리오/프롬프트' }, + ...(data.youtube_text ? [{ id: 'youtube', label: 'YouTube 텍스트' }] : []), + ].map(tab => ( + + ))} +
+
+ + {/* Tab: Scenario */} + {activeTab === 'scenario' && data.scenes && data.scenes.length > 0 && ( +
+ {/* BGM Mood */} + {data.scenario?.bgm_mood && ( +
+ BGM + {data.scenario.bgm_mood} +
+ )} + + {data.scenes.map((scene, i) => ( +
+
+ + #{scene.scene_number} {scene.scene_type || ''} ({scene.duration}s) + + {scene.mood} +
+
+
+
나레이션
+
{scene.narration || '(없음)'}
+
+
+
Visual Prompt
+
{scene.visual_prompt || '(없음)'}
+
+
+
+ ))} +
+ )} + + {activeTab === 'scenario' && (!data.scenes || data.scenes.length === 0) && ( +
시나리오 데이터가 없습니다
+ )} + + {/* Tab: YouTube Text */} + {activeTab === 'youtube' && data.youtube_text && ( +
+ {/* YouTube Title */} +
+
+ + +
+
+ {data.youtube_text.title} +
+
+ + {/* YouTube Description */} +
+
+ + +
+
+                                            {data.youtube_text.description}
+                                        
+
+ + {/* Hashtags */} +
+
+ + +
+
+ {data.youtube_text.hashtags.map((tag, i) => ( + {tag} + ))} +
+
+
+ )} + + )} +
+ + {/* Footer */} + {data && data.status === 'completed' && ( +
+ + 미리보기 + + + + + + 다운로드 + +
+ )} +
+
+ ); +}; + // ============================================================ // Main App // ============================================================ @@ -815,6 +1073,7 @@ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 const [scenario, setScenario] = useState(null); const [keyword, setKeyword] = useState(''); const [error, setError] = useState(''); + const [detailId, setDetailId] = useState(null); // Step 1: Generate Titles const handleKeywordSubmit = async (kw, trendingContext = null) => { @@ -940,7 +1199,10 @@ className="text-indigo-600 hover:text-indigo-800 text-sm font-medium" )} {/* History */} - + setDetailId(id)} /> + + {/* Detail Modal */} + {detailId && setDetailId(null)} />} ); }; diff --git a/routes/web.php b/routes/web.php index ae9639c2..45107abe 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1471,6 +1471,7 @@ 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('/detail/{id}', [\App\Http\Controllers\Video\Veo3Controller::class, 'show'])->name('show'); Route::get('/history', [\App\Http\Controllers\Video\Veo3Controller::class, 'history'])->name('history'); Route::delete('/history', [\App\Http\Controllers\Video\Veo3Controller::class, 'destroy'])->name('destroy'); });