feat:생성 이력 상세보기 모달 + YouTube Shorts 텍스트 생성

- Veo3Controller에 show 엔드포인트 추가 (시나리오/프롬프트 상세 데이터 반환)
- YouTube Shorts 제목/설명/해시태그 자동 생성 (완료된 영상)
- DetailModal 컴포넌트: 탭 UI (시나리오/프롬프트 | YouTube 텍스트)
- 이력 테이블 행 클릭 시 상세 모달 표시
- 복사 버튼으로 YouTube 텍스트 클립보드 복사

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-15 13:58:56 +09:00
parent 06c6005771
commit 0859a14e33
3 changed files with 357 additions and 5 deletions

View File

@@ -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),
];
}
/**
* 생성 이력 목록
*/

View File

@@ -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
</thead>
<tbody>
{history.map((item) => (
<tr key={item.id} className={`border-b hover:bg-gray-50 ${checked.has(item.id) ? 'bg-indigo-50' : ''}`}>
<td className="py-2 px-3">
<tr
key={item.id}
className={`border-b hover:bg-gray-50 cursor-pointer ${checked.has(item.id) ? 'bg-indigo-50' : ''}`}
onClick={() => onDetail(item.id)}
>
<td className="py-2 px-3" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={checked.has(item.id)}
@@ -783,7 +787,7 @@ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500
<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">
<td className="py-2 px-3 text-center" onClick={e => e.stopPropagation()}>
{item.status === 'completed' && (
<a href={`/video/veo3/download/${item.id}`} className="text-indigo-600 hover:underline text-xs">
다운로드
@@ -794,6 +798,11 @@ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500
진행 확인
</button>
)}
{!['completed','generating_tts','generating_clips','generating_bgm','assembling'].includes(item.status) && (
<button onClick={() => onDetail(item.id)} className="text-gray-500 hover:underline text-xs">
상세
</button>
)}
</td>
</tr>
))}
@@ -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 (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-gray-50">
<h3 className="text-lg font-bold text-gray-800">생성 상세 정보</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{loading && (
<div className="text-center py-12">
<svg className="animate-spin h-8 w-8 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 text-sm">데이터 로딩 ...</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm">{error}</div>
)}
{data && !loading && (
<>
{/* Summary */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-gray-50 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">키워드</div>
<div className="font-medium text-gray-800">{data.keyword}</div>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">상태</div>
<div className="font-medium">{statusLabels[data.status] || data.status}</div>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">비용</div>
<div className="font-medium text-gray-800">{data.cost_usd > 0 ? `$${parseFloat(data.cost_usd).toFixed(2)}` : '-'}</div>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">생성일시</div>
<div className="font-medium text-gray-800 text-sm">{data.created_at ? new Date(data.created_at).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) : '-'}</div>
</div>
</div>
{/* Title */}
<div className="mb-6 bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<div className="text-xs text-indigo-600 font-medium mb-1">제목</div>
<div className="text-lg font-bold text-gray-800">{data.title || '-'}</div>
</div>
{/* Error (if failed) */}
{data.status === 'failed' && data.error_message && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-xs text-red-600 font-medium mb-1">에러 메시지</div>
<div className="text-sm text-red-700">{data.error_message}</div>
</div>
)}
{/* Tabs */}
<div className="border-b mb-4">
<div className="flex gap-1">
{[
{ id: 'scenario', label: '시나리오/프롬프트' },
...(data.youtube_text ? [{ id: 'youtube', label: 'YouTube 텍스트' }] : []),
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Tab: Scenario */}
{activeTab === 'scenario' && data.scenes && data.scenes.length > 0 && (
<div className="space-y-4">
{/* BGM Mood */}
{data.scenario?.bgm_mood && (
<div className="flex items-center gap-2 text-sm text-gray-600 mb-2">
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded-full text-xs font-medium">BGM</span>
{data.scenario.bgm_mood}
</div>
)}
{data.scenes.map((scene, i) => (
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-2 flex items-center justify-between">
<span className="text-sm font-bold text-indigo-600">
#{scene.scene_number} {scene.scene_type || ''} ({scene.duration}s)
</span>
<span className="text-xs text-gray-500">{scene.mood}</span>
</div>
<div className="p-4 space-y-3">
<div>
<div className="text-xs text-gray-500 font-medium mb-1">나레이션</div>
<div className="text-sm text-gray-800 bg-yellow-50 p-2 rounded">{scene.narration || '(없음)'}</div>
</div>
<div>
<div className="text-xs text-gray-500 font-medium mb-1">Visual Prompt</div>
<div className="text-xs text-gray-700 bg-blue-50 p-2 rounded font-mono whitespace-pre-wrap">{scene.visual_prompt || '(없음)'}</div>
</div>
</div>
</div>
))}
</div>
)}
{activeTab === 'scenario' && (!data.scenes || data.scenes.length === 0) && (
<div className="text-center py-8 text-gray-400 text-sm">시나리오 데이터가 없습니다</div>
)}
{/* Tab: YouTube Text */}
{activeTab === 'youtube' && data.youtube_text && (
<div className="space-y-6">
{/* YouTube Title */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-bold text-gray-700">Shorts 제목</label>
<button
onClick={() => copyToClipboard(data.youtube_text.title, 'title')}
className={`text-xs px-3 py-1 rounded-full font-medium transition-all ${
copiedField === 'title'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{copiedField === 'title' ? '복사됨!' : '복사'}
</button>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm text-gray-800 font-medium">
{data.youtube_text.title}
</div>
</div>
{/* YouTube Description */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-bold text-gray-700">Shorts 설명란</label>
<button
onClick={() => copyToClipboard(data.youtube_text.description, 'desc')}
className={`text-xs px-3 py-1 rounded-full font-medium transition-all ${
copiedField === 'desc'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{copiedField === 'desc' ? '복사됨!' : '복사'}
</button>
</div>
<pre className="bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm text-gray-800 whitespace-pre-wrap font-sans">
{data.youtube_text.description}
</pre>
</div>
{/* Hashtags */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-bold text-gray-700">해시태그</label>
<button
onClick={() => copyToClipboard(data.youtube_text.hashtags.join(' '), 'tags')}
className={`text-xs px-3 py-1 rounded-full font-medium transition-all ${
copiedField === 'tags'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{copiedField === 'tags' ? '복사됨!' : '복사'}
</button>
</div>
<div className="flex flex-wrap gap-2">
{data.youtube_text.hashtags.map((tag, i) => (
<span key={i} className="px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-xs font-medium">{tag}</span>
))}
</div>
</div>
</div>
)}
</>
)}
</div>
{/* Footer */}
{data && data.status === 'completed' && (
<div className="flex items-center justify-between px-6 py-3 border-t bg-gray-50">
<a
href={`/video/veo3/preview/${data.id}`}
target="_blank"
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
>
미리보기
</a>
<a
href={`/video/veo3/download/${data.id}`}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors"
>
<svg className="w-4 h-4" 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>
다운로드
</a>
</div>
)}
</div>
</div>
);
};
// ============================================================
// 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 */}
<HistoryTable onSelect={handleHistorySelect} />
<HistoryTable onSelect={handleHistorySelect} onDetail={(id) => setDetailId(id)} />
{/* Detail Modal */}
{detailId && <DetailModal videoId={detailId} onClose={() => setDetailId(null)} />}
</div>
);
};

View File

@@ -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');
});