Files
sam-manage/resources/views/video/veo3/index.blade.php
김보곤 b00fd45650 fix:트렌딩 키워드 가져오기 실패 개선 (빈 결과 캐시 방지 + 리프레이밍 폴백)
- 빈 결과를 30분간 캐시하는 문제 수정 (성공 결과만 캐시)
- 건강 키워드가 없을 때 리프레이밍 폴백 추가 (트렌드를 건강 관점으로 재해석)
- 최종 폴백: 원본 인기 키워드 표시
- 프론트엔드 에러/안내 메시지 분리 (오류 vs 건강 키워드 없음)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 06:16:13 +09:00

1240 lines
61 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@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 [trendingInfo, setTrendingInfo] = useState('');
const fetchTrending = async () => {
setTrendingLoading(true);
setTrendingError('');
setTrendingInfo('');
try {
const data = await api('/video/veo3/trending');
setTrendingKeywords(data.keywords || []);
if ((data.keywords || []).length === 0) {
setTrendingError(data.message || '트렌딩 키워드를 가져오지 못했습니다.');
} else if (data.reason === 'no_health_match') {
setTrendingInfo(data.message || '건강 관련 트렌딩이 없어 인기 키워드를 표시합니다.');
}
} 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 Info (non-error message) */}
{trendingInfo && !trendingError && (
<div className="mb-4 text-center text-sm text-amber-600 bg-amber-50 py-2 px-3 rounded-lg border border-amber-200">{trendingInfo}</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">
{trendingKeywords.some(k => k.is_raw) ? '🔥 실시간 인기 키워드 (클릭하여 선택)' : trendingKeywords.some(k => k.is_reframed) ? '💡 트렌드 × 건강 키워드 (클릭하여 선택)' : '\u{1F49A} 건강 트렌드 키워드 (클릭하여 선택)'}
</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 [refreshing, setRefreshing] = useState(false);
const fetchHistory = (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
api('/video/veo3/history')
.then(data => { setHistory(data.data || []); setChecked(new Set()); })
.catch(() => {})
.finally(() => { setLoading(false); setRefreshing(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">
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-gray-700">생성 이력</h3>
<button
onClick={() => fetchHistory(true)}
disabled={refreshing}
className="p-1.5 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors disabled:opacity-50"
title="새로고침"
>
<svg className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
{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