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 */}
+
+
+
+
상태
+
{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');
});