- TutorialVideo 모델 (상태 관리, TenantScope) - GeminiScriptService에 callGeminiWithParts() 멀티모달 지원 추가 - ScreenAnalysisService: Gemini Vision 스크린샷 AI 분석 - SlideAnnotationService: PHP GD 이미지 어노테이션 (마커, 캡션) - TutorialAssemblyService: FFmpeg 이미지→영상 합성 (crossfade) - TutorialVideoJob: 분석→슬라이드→TTS→BGM→합성 파이프라인 - TutorialVideoController: 업로드/분석/생성/상태/다운로드/이력 API - React-in-Blade UI: 3단계 (업로드→분석확인→생성모니터링) + 이력 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
637 lines
28 KiB
PHP
637 lines
28 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', 'SAM 매뉴얼 영상 생성기')
|
|
|
|
@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; }
|
|
.progress-bar { height: 0.75rem; border-radius: 9999px; transition: all 0.5s ease-out; }
|
|
.fade-in { animation: fadeIn 0.3s ease-in; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
|
.upload-zone {
|
|
border: 2px dashed #d1d5db; border-radius: 0.75rem; padding: 2rem;
|
|
text-align: center; cursor: pointer; transition: all 0.2s ease;
|
|
}
|
|
.upload-zone:hover, .upload-zone.dragover { border-color: #818cf8; background: #eef2ff; }
|
|
.screenshot-thumb {
|
|
position: relative; width: 120px; height: 90px; border-radius: 0.5rem;
|
|
overflow: hidden; border: 2px solid #e5e7eb; cursor: grab;
|
|
}
|
|
.screenshot-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
|
.screenshot-thumb .remove-btn {
|
|
position: absolute; top: 2px; right: 2px; width: 20px; height: 20px;
|
|
background: rgba(239,68,68,0.9); color: #fff; border: none; border-radius: 9999px;
|
|
font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.screenshot-thumb .order-badge {
|
|
position: absolute; bottom: 2px; left: 2px; width: 20px; height: 20px;
|
|
background: rgba(79,70,229,0.9); color: #fff; border-radius: 9999px;
|
|
font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.analysis-card {
|
|
background: #fff; border: 1px solid #e5e7eb; border-radius: 0.75rem;
|
|
padding: 1rem; margin-bottom: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
|
}
|
|
.ui-element-tag {
|
|
display: inline-flex; align-items: center; gap: 0.25rem;
|
|
padding: 0.125rem 0.5rem; background: #f3f4f6; border-radius: 9999px;
|
|
font-size: 0.75rem; color: #4b5563;
|
|
}
|
|
</style>
|
|
@endpush
|
|
|
|
@section('content')
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<div id="tutorial-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 = {}) => {
|
|
const headers = { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', ...options.headers };
|
|
if (!(options.body instanceof FormData)) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
return fetch(url, { 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: 'AI 분석' },
|
|
{ num: 3, 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 ? '\u2713' : 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: Screenshot Upload
|
|
// ============================================================
|
|
const ScreenshotUpload = ({ onUploadComplete, loading, setLoading }) => {
|
|
const [files, setFiles] = useState([]);
|
|
const [previews, setPreviews] = useState([]);
|
|
const [dragover, setDragover] = useState(false);
|
|
const fileInputRef = useRef(null);
|
|
|
|
const handleFiles = useCallback((newFiles) => {
|
|
const imageFiles = Array.from(newFiles).filter(f => f.type.startsWith('image/'));
|
|
const total = [...files, ...imageFiles].slice(0, 10);
|
|
setFiles(total);
|
|
|
|
const newPreviews = total.map(f => URL.createObjectURL(f));
|
|
setPreviews(prev => { prev.forEach(URL.revokeObjectURL); return newPreviews; });
|
|
}, [files]);
|
|
|
|
const removeFile = (index) => {
|
|
const newFiles = files.filter((_, i) => i !== index);
|
|
setFiles(newFiles);
|
|
setPreviews(prev => {
|
|
URL.revokeObjectURL(prev[index]);
|
|
return prev.filter((_, i) => i !== index);
|
|
});
|
|
};
|
|
|
|
const handleUpload = async () => {
|
|
if (files.length === 0) return;
|
|
setLoading(true);
|
|
try {
|
|
const formData = new FormData();
|
|
files.forEach(f => formData.append('screenshots[]', f));
|
|
const data = await api('/video/tutorial/upload', {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json' },
|
|
});
|
|
onUploadComplete(data.paths);
|
|
} catch (err) {
|
|
alert(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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">SAM 화면 스크린샷 업로드</h2>
|
|
<p className="text-gray-500 mt-2">AI가 화면을 분석하여 사용자 매뉴얼 영상을 자동 생성합니다</p>
|
|
</div>
|
|
|
|
<div
|
|
className={`upload-zone ${dragover ? 'dragover' : ''}`}
|
|
onDragOver={(e) => { e.preventDefault(); setDragover(true); }}
|
|
onDragLeave={() => setDragover(false)}
|
|
onDrop={(e) => { e.preventDefault(); setDragover(false); handleFiles(e.dataTransfer.files); }}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => handleFiles(e.target.files)}
|
|
/>
|
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
|
</svg>
|
|
<p className="mt-2 text-sm text-gray-600">
|
|
클릭하거나 파일을 드래그하세요
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-1">PNG, JPG, GIF (최대 10장, 각 10MB)</p>
|
|
</div>
|
|
|
|
{previews.length > 0 && (
|
|
<div className="mt-4">
|
|
<div className="text-sm font-medium text-gray-700 mb-2">
|
|
업로드된 스크린샷 ({previews.length}/10)
|
|
</div>
|
|
<div className="flex flex-wrap gap-3">
|
|
{previews.map((src, i) => (
|
|
<div key={i} className="screenshot-thumb">
|
|
<img src={src} alt={`Screenshot ${i+1}`} />
|
|
<button className="remove-btn" onClick={(e) => { e.stopPropagation(); removeFile(i); }}>
|
|
x
|
|
</button>
|
|
<div className="order-badge">{i + 1}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{files.length > 0 && (
|
|
<div className="mt-6 text-center">
|
|
<button
|
|
onClick={handleUpload}
|
|
disabled={loading}
|
|
className="px-6 py-3 bg-indigo-600 text-white 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-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>
|
|
) : `${files.length}장 업로드 & AI 분석 시작`}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// Step 2: Analysis Review
|
|
// ============================================================
|
|
const AnalysisReview = ({ analysis, paths, onConfirm, onBack, loading, setLoading }) => {
|
|
const [editedAnalysis, setEditedAnalysis] = useState(analysis);
|
|
const [title, setTitle] = useState('SAM 사용자 매뉴얼');
|
|
|
|
const updateNarration = (index, newNarration) => {
|
|
const updated = [...editedAnalysis];
|
|
updated[index] = { ...updated[index], narration: newNarration };
|
|
setEditedAnalysis(updated);
|
|
};
|
|
|
|
const handleGenerate = () => {
|
|
onConfirm(editedAnalysis, title);
|
|
};
|
|
|
|
return (
|
|
<div className="fade-in max-w-3xl mx-auto">
|
|
<div className="text-center mb-6">
|
|
<h2 className="text-2xl font-bold text-gray-800">AI 분석 결과 확인</h2>
|
|
<p className="text-gray-500 mt-2">나레이션을 편집하고 영상을 생성하세요</p>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">영상 제목</label>
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
placeholder="영상 제목을 입력하세요"
|
|
/>
|
|
</div>
|
|
|
|
{editedAnalysis.map((screen, i) => (
|
|
<div key={i} className="analysis-card">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<span className="step-badge step-active">{screen.screen_number || i + 1}</span>
|
|
<div>
|
|
<div className="font-medium text-gray-800">{screen.title}</div>
|
|
<div className="text-xs text-gray-400">표시 시간: {screen.duration}초</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-3">
|
|
<label className="block text-xs font-medium text-gray-500 mb-1">나레이션 (편집 가능)</label>
|
|
<textarea
|
|
value={screen.narration}
|
|
onChange={(e) => updateNarration(i, e.target.value)}
|
|
rows={2}
|
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
|
|
/>
|
|
<div className="text-xs text-gray-400 mt-1">{screen.narration?.length || 0}자</div>
|
|
</div>
|
|
|
|
{screen.ui_elements && screen.ui_elements.length > 0 && (
|
|
<div>
|
|
<div className="text-xs font-medium text-gray-500 mb-1">인식된 UI 요소</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{screen.ui_elements.map((el, j) => (
|
|
<span key={j} className="ui-element-tag">
|
|
<span className="text-indigo-500 font-bold">{j + 1}</span>
|
|
{el.label || el.type}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
<div className="flex gap-3 mt-6 justify-center">
|
|
<button
|
|
onClick={onBack}
|
|
className="px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
|
>
|
|
이전
|
|
</button>
|
|
<button
|
|
onClick={handleGenerate}
|
|
disabled={loading}
|
|
className="px-6 py-2.5 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{loading ? '생성 시작 중...' : '영상 생성 시작'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// Step 3: Generation Progress
|
|
// ============================================================
|
|
const GenerationProgress = ({ tutorialId, onComplete, onReset }) => {
|
|
const [status, setStatus] = useState(null);
|
|
const [polling, setPolling] = useState(true);
|
|
const intervalRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!tutorialId || !polling) return;
|
|
|
|
const poll = async () => {
|
|
try {
|
|
const data = await api(`/video/tutorial/status/${tutorialId}`);
|
|
setStatus(data);
|
|
if (data.status === 'completed' || data.status === 'failed') {
|
|
setPolling(false);
|
|
if (data.status === 'completed') onComplete?.();
|
|
}
|
|
} catch (err) {
|
|
console.error('Polling error:', err);
|
|
}
|
|
};
|
|
|
|
poll();
|
|
intervalRef.current = setInterval(poll, 2000);
|
|
return () => clearInterval(intervalRef.current);
|
|
}, [tutorialId, polling]);
|
|
|
|
const isCompleted = status?.status === 'completed';
|
|
const isFailed = status?.status === 'failed';
|
|
const progress = status?.progress || 0;
|
|
|
|
return (
|
|
<div className="fade-in max-w-lg mx-auto text-center">
|
|
{!isCompleted && !isFailed && (
|
|
<>
|
|
<div className="mb-4">
|
|
<svg className="animate-spin h-8 w-8 text-indigo-600 mx-auto" 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>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-800 mb-2">영상 생성 중...</h3>
|
|
<p className="text-sm text-gray-500 mb-4">{status?.current_step || '대기 중...'}</p>
|
|
</>
|
|
)}
|
|
|
|
<div className="w-full bg-gray-200 rounded-full mb-2">
|
|
<div
|
|
className={`progress-bar ${isCompleted ? 'bg-green-500' : isFailed ? 'bg-red-500' : 'bg-indigo-600'}`}
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-sm text-gray-500 mb-6">{progress}%</div>
|
|
|
|
{isCompleted && (
|
|
<div className="fade-in">
|
|
<div className="text-green-600 text-lg font-bold mb-4">영상 생성 완료!</div>
|
|
|
|
<div className="mb-6 rounded-xl overflow-hidden border border-gray-200 shadow-sm">
|
|
<video
|
|
controls
|
|
className="w-full"
|
|
src={`/video/tutorial/preview/${tutorialId}`}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-3 justify-center">
|
|
<a
|
|
href={`/video/tutorial/download/${tutorialId}`}
|
|
className="px-5 py-2.5 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors inline-flex items-center gap-2"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
|
다운로드
|
|
</a>
|
|
<button
|
|
onClick={onReset}
|
|
className="px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
|
>
|
|
새로 만들기
|
|
</button>
|
|
</div>
|
|
|
|
{status?.cost_usd > 0 && (
|
|
<div className="mt-3 text-xs text-gray-400">예상 비용: ${Number(status.cost_usd).toFixed(4)}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{isFailed && (
|
|
<div className="fade-in">
|
|
<div className="text-red-600 text-lg font-bold mb-2">생성 실패</div>
|
|
<p className="text-sm text-red-500 mb-4">{status?.error_message || '알 수 없는 오류'}</p>
|
|
<button
|
|
onClick={onReset}
|
|
className="px-5 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
|
>
|
|
다시 시도
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// History Table
|
|
// ============================================================
|
|
const HistoryTable = ({ refreshKey }) => {
|
|
const [items, setItems] = useState([]);
|
|
const [selected, setSelected] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const fetchHistory = async () => {
|
|
try {
|
|
const data = await api('/video/tutorial/history');
|
|
setItems(data.data || []);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { fetchHistory(); }, [refreshKey]);
|
|
|
|
const toggleSelect = (id) => {
|
|
setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
|
};
|
|
|
|
const toggleAll = () => {
|
|
setSelected(selected.length === items.length ? [] : items.map(i => i.id));
|
|
};
|
|
|
|
const deleteSelected = async () => {
|
|
if (selected.length === 0 || !confirm(`${selected.length}개 항목을 삭제하시겠습니까?`)) return;
|
|
setLoading(true);
|
|
try {
|
|
await api('/video/tutorial/history', {
|
|
method: 'DELETE',
|
|
body: JSON.stringify({ ids: selected }),
|
|
});
|
|
setSelected([]);
|
|
fetchHistory();
|
|
} catch (err) {
|
|
alert(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const statusLabel = (status) => {
|
|
const map = {
|
|
pending: { label: '대기', cls: 'bg-gray-100 text-gray-600' },
|
|
analyzing: { label: '분석중', cls: 'bg-blue-100 text-blue-700' },
|
|
generating_slides: { label: '슬라이드', cls: 'bg-blue-100 text-blue-700' },
|
|
generating_tts: { label: 'TTS', cls: 'bg-blue-100 text-blue-700' },
|
|
generating_bgm: { label: 'BGM', cls: 'bg-blue-100 text-blue-700' },
|
|
assembling: { label: '합성중', cls: 'bg-yellow-100 text-yellow-700' },
|
|
completed: { label: '완료', cls: 'bg-green-100 text-green-700' },
|
|
failed: { label: '실패', cls: 'bg-red-100 text-red-700' },
|
|
};
|
|
const s = map[status] || { label: status, cls: 'bg-gray-100 text-gray-600' };
|
|
return <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${s.cls}`}>{s.label}</span>;
|
|
};
|
|
|
|
if (items.length === 0) return null;
|
|
|
|
return (
|
|
<div className="mt-10 max-w-4xl mx-auto">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-lg font-bold text-gray-800">생성 이력</h3>
|
|
{selected.length > 0 && (
|
|
<button
|
|
onClick={deleteSelected}
|
|
disabled={loading}
|
|
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 disabled:opacity-50"
|
|
>
|
|
{selected.length}개 삭제
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-sm">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-gray-50 text-gray-600">
|
|
<tr>
|
|
<th className="px-3 py-2.5 text-left w-8">
|
|
<input type="checkbox" checked={selected.length === items.length && items.length > 0} onChange={toggleAll} className="rounded" />
|
|
</th>
|
|
<th className="px-3 py-2.5 text-left">제목</th>
|
|
<th className="px-3 py-2.5 text-center">상태</th>
|
|
<th className="px-3 py-2.5 text-center">진행</th>
|
|
<th className="px-3 py-2.5 text-center">비용</th>
|
|
<th className="px-3 py-2.5 text-center">생성일</th>
|
|
<th className="px-3 py-2.5 text-center">액션</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{items.map(item => (
|
|
<tr key={item.id} className="hover:bg-gray-50">
|
|
<td className="px-3 py-2">
|
|
<input type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelect(item.id)} className="rounded" />
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-800 font-medium">{item.title || `#${item.id}`}</td>
|
|
<td className="px-3 py-2 text-center">{statusLabel(item.status)}</td>
|
|
<td className="px-3 py-2 text-center text-gray-500">{item.progress}%</td>
|
|
<td className="px-3 py-2 text-center text-gray-500">${Number(item.cost_usd || 0).toFixed(3)}</td>
|
|
<td className="px-3 py-2 text-center text-gray-400 text-xs">
|
|
{item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'}
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{item.status === 'completed' && (
|
|
<div className="flex gap-1 justify-center">
|
|
<a href={`/video/tutorial/preview/${item.id}`} target="_blank" className="text-indigo-600 hover:text-indigo-800 text-xs font-medium">보기</a>
|
|
<span className="text-gray-300">|</span>
|
|
<a href={`/video/tutorial/download/${item.id}`} className="text-indigo-600 hover:text-indigo-800 text-xs font-medium">저장</a>
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================
|
|
// Main App
|
|
// ============================================================
|
|
const TutorialApp = () => {
|
|
const [step, setStep] = useState(1);
|
|
const [loading, setLoading] = useState(false);
|
|
const [paths, setPaths] = useState([]);
|
|
const [analysis, setAnalysis] = useState(null);
|
|
const [tutorialId, setTutorialId] = useState(null);
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
|
|
const handleUploadComplete = async (uploadedPaths) => {
|
|
setPaths(uploadedPaths);
|
|
setLoading(true);
|
|
try {
|
|
const data = await api('/video/tutorial/analyze', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ paths: uploadedPaths }),
|
|
});
|
|
setAnalysis(data.analysis);
|
|
setStep(2);
|
|
} catch (err) {
|
|
alert('AI 분석 실패: ' + err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleGenerate = async (editedAnalysis, title) => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await api('/video/tutorial/generate', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ paths, analysis: editedAnalysis, title }),
|
|
});
|
|
setTutorialId(data.id);
|
|
setStep(3);
|
|
} catch (err) {
|
|
alert('생성 시작 실패: ' + err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setStep(1);
|
|
setPaths([]);
|
|
setAnalysis(null);
|
|
setTutorialId(null);
|
|
setRefreshKey(k => k + 1);
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto py-6 px-4">
|
|
<div className="text-center mb-8">
|
|
<h1 className="text-3xl font-bold text-gray-900">SAM 매뉴얼 영상 생성기</h1>
|
|
<p className="text-gray-500 mt-1">스크린샷을 업로드하면 AI가 자동으로 튜토리얼 영상을 만들어 드립니다</p>
|
|
</div>
|
|
|
|
<StepIndicator currentStep={step} />
|
|
|
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 mb-8">
|
|
{step === 1 && (
|
|
<ScreenshotUpload
|
|
onUploadComplete={handleUploadComplete}
|
|
loading={loading}
|
|
setLoading={setLoading}
|
|
/>
|
|
)}
|
|
{step === 2 && analysis && (
|
|
<AnalysisReview
|
|
analysis={analysis}
|
|
paths={paths}
|
|
onConfirm={handleGenerate}
|
|
onBack={() => setStep(1)}
|
|
loading={loading}
|
|
setLoading={setLoading}
|
|
/>
|
|
)}
|
|
{step === 3 && tutorialId && (
|
|
<GenerationProgress
|
|
tutorialId={tutorialId}
|
|
onComplete={() => setRefreshKey(k => k + 1)}
|
|
onReset={handleReset}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<HistoryTable refreshKey={refreshKey} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('tutorial-root'));
|
|
root.render(<TutorialApp />);
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|