- Veo3Controller에 show 엔드포인트 추가 (시나리오/프롬프트 상세 데이터 반환) - YouTube Shorts 제목/설명/해시태그 자동 생성 (완료된 영상) - DetailModal 컴포넌트: 탭 UI (시나리오/프롬프트 | YouTube 텍스트) - 이력 테이블 행 클릭 시 상세 모달 표시 - 복사 버튼으로 YouTube 텍스트 클립보드 복사 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1214 lines
59 KiB
PHP
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>💚</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">💚 건강 트렌드 키워드 (클릭하여 선택)</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">×</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
|