Files
sam-manage/resources/views/video/veo3/index.blade.php
김보곤 0859a14e33 feat:생성 이력 상세보기 모달 + YouTube Shorts 텍스트 생성
- Veo3Controller에 show 엔드포인트 추가 (시나리오/프롬프트 상세 데이터 반환)
- YouTube Shorts 제목/설명/해시태그 자동 생성 (완료된 영상)
- DetailModal 컴포넌트: 탭 UI (시나리오/프롬프트 | YouTube 텍스트)
- 이력 테이블 행 클릭 시 상세 모달 표시
- 복사 버튼으로 YouTube 텍스트 클립보드 복사

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:58:56 +09:00

1214 lines
59 KiB
PHP

@extends('layouts.app')
@section('title', 'YouTube Shorts AI 생성기')
@push('styles')
<style>
.step-badge {
display: inline-flex; align-items: center; justify-content: center;
width: 2rem; height: 2rem; border-radius: 9999px; font-size: 0.875rem; font-weight: 700;
}
.step-active { background: #4f46e5; color: #fff; }
.step-done { background: #22c55e; color: #fff; }
.step-pending { background: #e5e7eb; color: #6b7280; }
.scene-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 1rem; margin-bottom: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,.05); }
.progress-bar { height: 0.75rem; border-radius: 9999px; transition: all 0.5s ease-out; }
.title-option {
border: 2px solid #e5e7eb; border-radius: 0.75rem; padding: 1rem; cursor: pointer;
transition: all 0.2s ease; position: relative; background: #fff;
}
.title-option:hover { border-color: #818cf8; background: #eef2ff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79,70,229,.1); }
.title-option.selected {
border-color: #4f46e5; background: #eef2ff;
box-shadow: 0 0 0 3px rgba(79,70,229,.15), 0 4px 12px rgba(79,70,229,.1);
}
.title-option .radio-dot {
width: 1.25rem; height: 1.25rem; border-radius: 9999px; border: 2px solid #d1d5db;
display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.2s ease;
}
.title-option:hover .radio-dot { border-color: #818cf8; }
.title-option.selected .radio-dot { border-color: #4f46e5; background: #4f46e5; }
.title-option.selected .radio-dot::after {
content: ''; width: 0.5rem; height: 0.5rem; border-radius: 9999px; background: #fff;
}
.title-option .check-badge {
position: absolute; top: -0.5rem; right: -0.5rem; width: 1.5rem; height: 1.5rem;
background: #4f46e5; border-radius: 9999px; display: none;
align-items: center; justify-content: center; color: #fff; font-size: 0.75rem;
}
.title-option.selected .check-badge { display: flex; }
.fade-in { animation: fadeIn 0.3s ease-in; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
</style>
@endpush
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="veo3-root"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
@verbatim
<script type="text/babel">
const { useState, useEffect, useCallback, useRef } = React;
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content;
const api = (url, options = {}) => {
return fetch(url, {
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF,
'Accept': 'application/json',
...options.headers,
},
...options,
}).then(async (res) => {
const data = await res.json();
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
return data;
});
};
// ============================================================
// Step Indicator
// ============================================================
const StepIndicator = ({ currentStep }) => {
const steps = [
{ num: 1, label: '키워드 입력' },
{ num: 2, label: '제목 선택' },
{ num: 3, label: '시나리오 확인' },
{ num: 4, label: '영상 생성' },
{ num: 5, label: '완성' },
];
return (
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((step, i) => (
<React.Fragment key={step.num}>
<div className="flex flex-col items-center">
<div className={`step-badge ${currentStep > step.num ? 'step-done' : currentStep === step.num ? 'step-active' : 'step-pending'}`}>
{currentStep > step.num ? '✓' : step.num}
</div>
<span className={`text-xs mt-1 ${currentStep >= step.num ? 'text-indigo-600 font-medium' : 'text-gray-400'}`}>
{step.label}
</span>
</div>
{i < steps.length - 1 && (
<div className={`w-12 h-0.5 mt-[-12px] ${currentStep > step.num ? 'bg-green-500' : 'bg-gray-200'}`} />
)}
</React.Fragment>
))}
</div>
);
};
// ============================================================
// Step 1: Keyword Input
// ============================================================
const KeywordInput = ({ onSubmit, loading }) => {
const [keyword, setKeyword] = useState('');
const [trendingKeywords, setTrendingKeywords] = useState([]);
const [trendingLoading, setTrendingLoading] = useState(false);
const [trendingContext, setTrendingContext] = useState(null);
const [trendingError, setTrendingError] = useState('');
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
const handleSubmit = (e) => {
e.preventDefault();
if (keyword.trim()) onSubmit(keyword.trim(), trendingContext);
};
const fetchTrending = async () => {
setTrendingLoading(true);
setTrendingError('');
try {
const data = await api('/video/veo3/trending');
setTrendingKeywords(data.keywords || []);
if ((data.keywords || []).length === 0) {
setTrendingError('트렌딩 키워드를 가져오지 못했습니다.');
}
} catch (err) {
setTrendingError(err.message);
} finally {
setTrendingLoading(false);
}
};
const selectTrending = (item) => {
setKeyword(item.suggested_topic || item.keyword);
setTrendingContext({
keyword: item.keyword,
news_title: item.news_title,
traffic: item.traffic,
pub_date: item.pub_date,
health_angle: item.health_angle || '',
suggested_topic: item.suggested_topic || '',
});
inputRef.current?.focus();
};
return (
<div className="fade-in max-w-lg mx-auto">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-800">키워드를 입력하세요</h2>
<p className="text-gray-500 mt-2">AI가 건강 채널용 YouTube Shorts 제목을 생성합니다</p>
</div>
{/* Trending Button */}
<div className="text-center mb-4">
<button
type="button"
onClick={fetchTrending}
disabled={trendingLoading}
className="inline-flex items-center gap-2 px-4 py-2 border border-green-300 bg-green-50 text-green-700 rounded-lg text-sm font-medium hover:bg-green-100 hover:border-green-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{trendingLoading ? (
<>
<svg className="animate-spin h-4 w-4" 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>
불러오는 ...
</>
) : (
<>
<span>&#128154;</span>
건강 트렌드 자동추출
</>
)}
</button>
</div>
{/* Trending Error */}
{trendingError && (
<div className="mb-4 text-center text-sm text-red-500">{trendingError}</div>
)}
{/* Trending Keyword Chips */}
{trendingKeywords.length > 0 && (
<div className="mb-5 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="text-xs text-gray-500 mb-2 font-medium">&#128154; 건강 트렌드 키워드 (클릭하여 선택)</div>
<div className="flex flex-wrap gap-2">
{trendingKeywords.map((item, i) => {
const isSelected = trendingContext?.keyword === item.keyword;
return (
<button
key={i}
type="button"
onClick={() => selectTrending(item)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-all cursor-pointer border ${
isSelected
? 'bg-green-600 text-white border-green-600 shadow-sm'
: 'bg-white text-gray-700 border-gray-300 hover:border-green-400 hover:bg-green-50'
}`}
>
<span className="font-medium">{item.keyword}</span>
{item.health_angle && (
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
isSelected ? 'bg-green-500 text-green-100' : 'bg-green-100 text-green-600'
}`}>
{item.health_angle}
</span>
)}
{!item.health_angle && item.traffic && (
<span className={`text-xs ${isSelected ? 'text-green-200' : 'text-gray-400'}`}>
{item.traffic}
</span>
)}
</button>
);
})}
</div>
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-3">
<input
ref={inputRef}
type="text"
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
if (trendingContext && e.target.value !== (trendingContext.suggested_topic || trendingContext.keyword)) {
setTrendingContext(null);
}
}}
placeholder="예: 다이어트, 주식투자, 여행..."
className="flex-1 border border-gray-300 rounded-lg px-4 py-3 text-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
maxLength={100}
disabled={loading}
/>
<button
type="submit"
disabled={!keyword.trim() || loading}
className="bg-indigo-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-5 w-5" 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>
생성 ...
</span>
) : '제목 생성'}
</button>
</form>
</div>
);
};
// ============================================================
// Step 2: Title Selection
// ============================================================
const TitleSelection = ({ titles, onSelect, onBack, loading }) => {
const [selectedIdx, setSelectedIdx] = useState(null);
return (
<div className="fade-in max-w-lg mx-auto">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-800">제목을 선택하세요</h2>
<p className="text-gray-500 mt-2">클릭하여 마음에 드는 제목을 선택합니다</p>
</div>
<div className="space-y-3 mb-6">
{titles.map((item, i) => (
<div
key={i}
className={`title-option ${selectedIdx === i ? 'selected' : ''}`}
onClick={() => setSelectedIdx(i)}
>
<div className="check-badge"></div>
<div className="flex items-start gap-3">
<div className="radio-dot mt-0.5"><span /></div>
<div className="flex-1">
<div className="font-medium text-gray-800">{item.title}</div>
<div className="text-sm text-gray-500 mt-1">{item.hook}</div>
</div>
<div className="text-xs text-gray-400 mt-1 flex-shrink-0">#{i + 1}</div>
</div>
</div>
))}
</div>
<div className="flex gap-3">
<button
onClick={onBack}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50"
>
뒤로
</button>
<button
onClick={() => selectedIdx !== null && onSelect(titles[selectedIdx].title)}
disabled={selectedIdx === null || loading}
className="flex-1 bg-indigo-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? '시나리오 생성 중...' : '이 제목으로 시나리오 생성'}
</button>
</div>
</div>
);
};
// ============================================================
// Step 3: Scenario Preview & Edit
// ============================================================
const ScenarioPreview = ({ scenario, onGenerate, onBack, loading }) => {
const [scenes, setScenes] = useState(scenario.scenes || []);
const [bgmMood, setBgmMood] = useState(scenario.bgm_mood || 'upbeat');
const updateScene = (idx, field, value) => {
const updated = [...scenes];
updated[idx] = { ...updated[idx], [field]: value };
setScenes(updated);
};
const totalDuration = scenes.reduce((sum, s) => sum + (s.duration || 0), 0);
const handleGenerate = () => {
onGenerate({ ...scenario, scenes, bgm_mood: bgmMood, total_duration: totalDuration });
};
return (
<div className="fade-in max-w-2xl mx-auto">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-800">시나리오 미리보기</h2>
<p className="text-gray-500 mt-2">
"{scenario.title}" | {totalDuration} | {scenes.length} 장면
</p>
</div>
<div className="space-y-3 mb-6">
{scenes.map((scene, i) => (
<div key={i} className="scene-card">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-indigo-600">
장면 {scene.scene_number} ({scene.duration})
</span>
<span className="text-xs px-2 py-1 bg-gray-100 rounded-full text-gray-500">
{scene.mood}
</span>
</div>
<div className="mb-2">
<label className="text-xs text-gray-500 font-medium">나레이션 (자막)</label>
<textarea
value={scene.narration}
onChange={(e) => updateScene(i, 'narration', e.target.value)}
className="w-full border border-gray-200 rounded p-2 text-sm mt-1 resize-none"
rows={2}
/>
</div>
<div>
<label className="text-xs text-gray-500 font-medium">영상 프롬프트 (영어)</label>
<textarea
value={scene.visual_prompt}
onChange={(e) => updateScene(i, 'visual_prompt', e.target.value)}
className="w-full border border-gray-200 rounded p-2 text-sm mt-1 resize-none font-mono"
rows={3}
/>
</div>
</div>
))}
</div>
<div className="mb-6 p-3 bg-gray-50 rounded-lg">
<label className="text-sm font-medium text-gray-600">BGM 분위기</label>
<input
type="text"
value={bgmMood}
onChange={(e) => setBgmMood(e.target.value)}
className="w-full border border-gray-200 rounded p-2 text-sm mt-1"
placeholder="upbeat, energetic, calm..."
/>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6 text-sm text-amber-700">
예상 비용: ~$7.72 (Veo 3.1 Fast 기준) | 소요 시간: 10~20
</div>
<div className="flex gap-3">
<button
onClick={onBack}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50"
>
뒤로
</button>
<button
onClick={handleGenerate}
disabled={loading}
className="flex-1 bg-green-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? '요청 중...' : '영상 생성 시작'}
</button>
</div>
</div>
);
};
// ============================================================
// Step 4: Progress (경과 시간 + 멈춤 감지 + 타임라인)
// ============================================================
const STEP_LABELS = {
pending: { icon: '⏳', label: 'Queue 대기', order: 0 },
scenario_ready: { icon: '📝', label: '시나리오 생성', order: 1 },
generating_tts: { icon: '🎙️', label: '나레이션 생성', order: 2 },
generating_clips: { icon: '🎬', label: '영상 클립 생성', order: 3 },
generating_bgm: { icon: '🎵', label: 'BGM 준비', order: 4 },
assembling: { icon: '🔧', label: '최종 합성', order: 5 },
completed: { icon: '✅', label: '완료', order: 6 },
failed: { icon: '❌', label: '실패', order: -1 },
};
const formatElapsed = (seconds) => {
if (seconds < 60) return `${seconds}초`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}분 ${s}초`;
};
const GenerationProgress = ({ videoId }) => {
const [status, setStatus] = useState(null);
const [elapsed, setElapsed] = useState(0);
const [lastChange, setLastChange] = useState(0);
const [pollCount, setPollCount] = useState(0);
const [stale, setStale] = useState(false);
const intervalRef = useRef(null);
const timerRef = useRef(null);
const prevProgressRef = useRef(null);
const startTimeRef = useRef(Date.now());
useEffect(() => {
const poll = async () => {
try {
const data = await api(`/video/veo3/status/${videoId}`);
setStatus(data);
setPollCount(c => c + 1);
if (prevProgressRef.current !== null &&
(data.progress !== prevProgressRef.current || data.current_step !== prevProgressRef.currentStep)) {
setLastChange(0);
setStale(false);
}
prevProgressRef.current = data.progress;
prevProgressRef.currentStep = data.current_step;
if (data.status === 'completed' || data.status === 'failed') {
clearInterval(intervalRef.current);
}
} catch (err) {
console.error('Poll error:', err);
}
};
poll();
intervalRef.current = setInterval(poll, 5000);
timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
setLastChange(c => {
const next = c + 1;
if (next >= 120) setStale(true);
return next;
});
}, 1000);
return () => { clearInterval(intervalRef.current); clearInterval(timerRef.current); };
}, [videoId]);
if (!status) {
return (
<div className="text-center py-12">
<svg className="animate-spin h-10 w-10 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">상태 확인 ...</p>
</div>
);
}
const isFailed = status.status === 'failed';
const isDone = status.status === 'completed';
const isRunning = !isFailed && !isDone;
const currentOrder = STEP_LABELS[status.status]?.order ?? 0;
const progressColor = isFailed ? 'bg-red-500' : isDone ? 'bg-green-500' : 'bg-indigo-600';
return (
<div className="fade-in max-w-xl mx-auto">
{/* Header with elapsed time */}
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-800">
{isDone ? '영상 생성 완료!' : isFailed ? '생성 실패' : '영상 생성 중...'}
</h2>
{isRunning && (
<div className="flex items-center justify-center gap-2 mt-2 text-sm text-gray-500">
<span className="inline-block w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
경과 시간: <span className="font-mono font-medium text-indigo-600">{formatElapsed(elapsed)}</span>
</div>
)}
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-700 font-medium">{status.current_step || '대기 중'}</span>
<span className="font-bold text-indigo-600">{status.progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div className={`progress-bar ${progressColor}`} style={{ width: `${Math.max(status.progress, isRunning ? 2 : 0)}%` }}>
{isRunning && <div style={{
width: '100%', height: '100%',
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
animation: 'shimmer 1.5s infinite',
}}></div>}
</div>
</div>
</div>
{/* Step Timeline */}
{isRunning && (
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<div className="space-y-2">
{Object.entries(STEP_LABELS).filter(([k]) => !['failed','completed'].includes(k)).map(([key, step]) => {
const isActive = status.status === key;
const isDoneStep = step.order < currentOrder;
return (
<div key={key} className={`flex items-center gap-3 text-sm py-1 px-2 rounded ${isActive ? 'bg-indigo-50 font-medium' : ''}`}>
<span className="w-5 text-center">
{isDoneStep ? '✅' : isActive ? step.icon : '○'}
</span>
<span className={isDoneStep ? 'text-gray-400 line-through' : isActive ? 'text-indigo-700' : 'text-gray-400'}>
{step.label}
</span>
{isActive && (
<span className="ml-auto text-xs text-indigo-500 animate-pulse">진행 ...</span>
)}
</div>
);
})}
</div>
</div>
)}
{/* Stale Warning */}
{stale && isRunning && (
<div className="bg-amber-50 border border-amber-300 rounded-lg p-3 mb-4 text-sm text-amber-700">
⚠️ <span className="font-medium">{formatElapsed(lastChange)}</span> 동안 진행 상태가 변하지 않고 있습니다.
<span className="block mt-1 text-xs text-amber-600">
Veo 영상 생성은 클립당 5~10 소요될 있습니다. 5 이상 변화가 없으면 서버 로그를 확인해주세요.
</span>
</div>
)}
{/* Error */}
{isFailed && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<div className="font-medium text-red-700 mb-1">오류 발생</div>
<p className="text-red-600 text-sm">{status.error_message || '알 수 없는 오류가 발생했습니다.'}</p>
</div>
)}
{/* Footer info */}
{isRunning && (
<div className="text-center text-xs text-gray-400 space-y-1">
<div className="flex items-center justify-center gap-1">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse"></span>
5초마다 자동 확인 (폴링 #{pollCount})
</div>
{status.updated_at && (
<div>서버 마지막 업데이트: {new Date(status.updated_at).toLocaleTimeString('ko-KR')}</div>
)}
</div>
)}
{/* Cost */}
{status.cost_usd > 0 && (
<div className="text-center text-sm text-gray-500 mt-3">
예상 비용: ${parseFloat(status.cost_usd).toFixed(4)}
</div>
)}
</div>
);
};
// ============================================================
// Step 5: Completed
// ============================================================
const CompletedView = ({ videoId }) => {
return (
<div className="fade-in max-w-lg mx-auto text-center">
<div className="mb-6">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-800">YouTube Shorts 영상 완성!</h2>
<p className="text-gray-500 mt-2">영상을 미리보기하고 다운로드하세요</p>
</div>
{/* Video Preview */}
<div className="mb-6 bg-black rounded-lg overflow-hidden" style={{ maxHeight: '500px' }}>
<video
src={`/video/veo3/preview/${videoId}`}
controls
className="w-full mx-auto"
style={{ maxHeight: '500px' }}
/>
</div>
<div className="flex gap-3 justify-center">
<a
href={`/video/veo3/download/${videoId}`}
className="bg-indigo-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-indigo-700 transition-colors inline-flex items-center gap-2"
>
<svg className="w-5 h-5" 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>
MP4 다운로드
</a>
</div>
</div>
);
};
// ============================================================
// History Table
// ============================================================
const HistoryTable = ({ onSelect, onDetail }) => {
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
const [checked, setChecked] = useState(new Set());
const [deleting, setDeleting] = useState(false);
const fetchHistory = () => {
api('/video/veo3/history')
.then(data => { setHistory(data.data || []); setChecked(new Set()); })
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchHistory(); }, []);
const toggleCheck = (id) => {
setChecked(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleAll = () => {
if (checked.size === history.length) {
setChecked(new Set());
} else {
setChecked(new Set(history.map(h => h.id)));
}
};
const handleDelete = async () => {
if (checked.size === 0) return;
if (!confirm(`선택한 ${checked.size}개 이력을 삭제하시겠습니까?`)) return;
setDeleting(true);
try {
await api('/video/veo3/history', {
method: 'DELETE',
body: JSON.stringify({ ids: Array.from(checked) }),
});
fetchHistory();
} catch (err) {
alert('삭제 실패: ' + err.message);
} finally {
setDeleting(false);
}
};
const formatKST = (dateStr) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
hour12: false,
});
};
const formatDuration = (created, updated, status) => {
if (!created || !updated) return '-';
if (!['completed', 'failed'].includes(status)) return '진행 중';
const diffSec = Math.floor((new Date(updated) - new Date(created)) / 1000);
if (diffSec < 60) return `${diffSec}초`;
const m = Math.floor(diffSec / 60);
const s = diffSec % 60;
return `${m}분 ${s}초`;
};
if (loading) return <div className="text-center py-4 text-gray-400">이력 로딩 ...</div>;
if (history.length === 0) return null;
const statusLabels = {
pending: '대기',
titles_generated: '제목 생성됨',
scenario_ready: '시나리오 준비',
generating_tts: 'TTS 생성',
generating_clips: '영상 생성',
generating_bgm: 'BGM 생성',
assembling: '합성 중',
completed: '완료',
failed: '실패',
};
const statusColors = {
completed: 'bg-green-100 text-green-700',
failed: 'bg-red-100 text-red-700',
};
return (
<div className="mt-12">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-700">생성 이력</h3>
{checked.size > 0 && (
<button
onClick={handleDelete}
disabled={deleting}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500 text-white text-xs font-medium rounded-lg hover:bg-red-600 disabled:opacity-50 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{deleting ? '삭제 중...' : `${checked.size}개 삭제`}
</button>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-gray-50">
<th className="py-2 px-3 w-10">
<input
type="checkbox"
checked={history.length > 0 && checked.size === history.length}
onChange={toggleAll}
className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
/>
</th>
<th className="text-left py-2 px-3 font-medium text-gray-500">생성일시</th>
<th className="text-left py-2 px-3 font-medium text-gray-500">제작시간</th>
<th className="text-left py-2 px-3 font-medium text-gray-500">키워드</th>
<th className="text-left py-2 px-3 font-medium text-gray-500">제목</th>
<th className="text-left py-2 px-3 font-medium text-gray-500">상태</th>
<th className="text-right py-2 px-3 font-medium text-gray-500">비용</th>
<th className="text-center py-2 px-3 font-medium text-gray-500">액션</th>
</tr>
</thead>
<tbody>
{history.map((item) => (
<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)}
onChange={() => toggleCheck(item.id)}
className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
/>
</td>
<td className="py-2 px-3 text-gray-600 whitespace-nowrap">{formatKST(item.created_at)}</td>
<td className="py-2 px-3 text-gray-600 whitespace-nowrap">{formatDuration(item.created_at, item.updated_at, item.status)}</td>
<td className="py-2 px-3 font-medium">{item.keyword}</td>
<td className="py-2 px-3 text-gray-600 truncate max-w-[200px]">{item.title || '-'}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded-full text-xs ${statusColors[item.status] || 'bg-blue-100 text-blue-700'}`}>
{statusLabels[item.status] || item.status}
</span>
</td>
<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" onClick={e => e.stopPropagation()}>
{item.status === 'completed' && (
<a href={`/video/veo3/download/${item.id}`} className="text-indigo-600 hover:underline text-xs">
다운로드
</a>
)}
{['generating_tts','generating_clips','generating_bgm','assembling'].includes(item.status) && (
<button onClick={() => onSelect(item.id)} className="text-blue-600 hover:underline text-xs">
진행 확인
</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>
))}
</tbody>
</table>
</div>
</div>
);
};
// ============================================================
// 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
// ============================================================
const App = () => {
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [videoId, setVideoId] = useState(null);
const [titles, setTitles] = useState([]);
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) => {
setLoading(true);
setError('');
setKeyword(kw);
try {
const body = { keyword: kw };
if (trendingContext) body.trending_context = trendingContext;
const data = await api('/video/veo3/titles', {
method: 'POST',
body: JSON.stringify(body),
});
setVideoId(data.video_id);
setTitles(data.titles);
setStep(2);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Step 2: Select Title → Generate Scenario
const handleTitleSelect = async (title) => {
setLoading(true);
setError('');
try {
const data = await api('/video/veo3/scenario', {
method: 'POST',
body: JSON.stringify({ video_id: videoId, title }),
});
setScenario(data.scenario);
setStep(3);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Step 3: Start Generation
const handleGenerate = async (editedScenario) => {
setLoading(true);
setError('');
try {
await api('/video/veo3/generate', {
method: 'POST',
body: JSON.stringify({ video_id: videoId, scenario: editedScenario }),
});
setStep(4);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// History item click → jump to progress
const handleHistorySelect = (id) => {
setVideoId(id);
setStep(4);
};
// Determine actual display step from polling status
const ProgressWithCompletion = ({ videoId }) => {
const [done, setDone] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
const check = async () => {
try {
const data = await api(`/video/veo3/status/${videoId}`);
if (data.status === 'completed') setDone(true);
if (data.status === 'failed') setFailed(true);
} catch (err) {}
};
check();
const iv = setInterval(check, 5000);
return () => clearInterval(iv);
}, [videoId]);
if (done) return <CompletedView videoId={videoId} />;
return <GenerationProgress videoId={videoId} />;
};
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">YouTube Shorts AI 생성기</h1>
<p className="text-gray-500 mt-2">키워드 하나로 AI가 YouTube Shorts 영상을 자동 생성합니다</p>
<p className="text-xs text-gray-400 mt-1">Gemini 3.0 Flash + Veo 3.1 + Google TTS</p>
</div>
{/* Step Indicator */}
<StepIndicator currentStep={step} />
{/* Error */}
{error && (
<div className="max-w-lg mx-auto mb-6 bg-red-50 border border-red-200 rounded-lg p-3 text-red-700 text-sm">
{error}
<button onClick={() => setError('')} className="ml-2 text-red-500 hover:text-red-700"></button>
</div>
)}
{/* Steps */}
{step === 1 && <KeywordInput onSubmit={handleKeywordSubmit} loading={loading} />}
{step === 2 && <TitleSelection titles={titles} onSelect={handleTitleSelect} onBack={() => setStep(1)} loading={loading} />}
{step === 3 && scenario && <ScenarioPreview scenario={scenario} onGenerate={handleGenerate} onBack={() => setStep(2)} loading={loading} />}
{step >= 4 && videoId && <ProgressWithCompletion videoId={videoId} />}
{/* New Generation Button (after completion) */}
{step >= 4 && (
<div className="text-center mt-8">
<button
onClick={() => { setStep(1); setVideoId(null); setTitles([]); setScenario(null); setError(''); }}
className="text-indigo-600 hover:text-indigo-800 text-sm font-medium"
>
+ 영상 만들기
</button>
</div>
)}
{/* History */}
<HistoryTable onSelect={handleHistorySelect} onDetail={(id) => setDetailId(id)} />
{/* Detail Modal */}
{detailId && <DetailModal videoId={detailId} onClose={() => setDetailId(null)} />}
</div>
);
};
ReactDOM.createRoot(document.getElementById('veo3-root')).render(<App />);
</script>
@endverbatim
@endpush