/// import React, { useEffect, useRef, useState } from 'react'; import { GeneratedAssets } from '../types'; import { Play, Pause, RefreshCw, Download, Image as ImageIcon, Video as VideoIcon, Music, Mic, Loader2, Film, Share2, Youtube, Instagram, ArrowLeft, Volume2, VolumeX } from 'lucide-react'; import { mergeVideoAndAudio } from '../services/ffmpegService'; import ShareModal from './ShareModal'; import SlideshowBackground from './SlideshowBackground'; import YouTubeSEOPreview from '../src/components/YouTubeSEOPreview'; // shadcn/ui components import { cn } from '../src/lib/utils'; import { Button } from '../src/components/ui/button'; import { Card, CardContent } from '../src/components/ui/card'; import { Badge } from '../src/components/ui/badge'; import { Tabs, TabsList, TabsTrigger } from '../src/components/ui/tabs'; import { Separator } from '../src/components/ui/separator'; import { Progress } from '../src/components/ui/progress'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../src/components/ui/tooltip'; import { useLanguage } from '../src/contexts/LanguageContext'; interface ResultPlayerProps { assets: GeneratedAssets; onReset: () => void; autoPlay?: boolean; } type TextPosition = 'center' | 'bottom-left' | 'top-right' | 'center-left'; const ResultPlayer: React.FC = ({ assets, onReset, autoPlay = false }) => { const { t } = useLanguage(); const videoRef = useRef(null); const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTextIndex, setCurrentTextIndex] = useState(0); const [showText, setShowText] = useState(false); const [activeTab, setActiveTab] = useState<'video' | 'poster'>('video'); const [textPosition, setTextPosition] = useState('center'); const [hasPlayed, setHasPlayed] = useState(false); const [isMuted, setIsMuted] = useState(false); const [isMerging, setIsMerging] = useState(false); const [mergeProgress, setMergeProgress] = useState(''); const [isServerDownloading, setIsServerDownloading] = useState(false); const [isRendering, setIsRendering] = useState(false); const [downloadProgress, setDownloadProgress] = useState(0); const [lastProjectFolder, setLastProjectFolder] = useState(null); const [isUploading, setIsUploading] = useState(false); const [uploadStatus, setUploadStatus] = useState(''); const [youtubeUrl, setYoutubeUrl] = useState(null); const [showShareModal, setShowShareModal] = useState(false); const [showSEOPreview, setShowSEOPreview] = useState(false); // Instagram 업로드 상태 const [isUploadingInstagram, setIsUploadingInstagram] = useState(false); const [instagramUploadStatus, setInstagramUploadStatus] = useState(''); const [instagramUrl, setInstagramUrl] = useState(null); // 렌더링 큐 상태 const [currentJobId, setCurrentJobId] = useState(null); const [renderStatus, setRenderStatus] = useState<'idle' | 'submitting' | 'processing' | 'completed' | 'failed'>('idle'); const [renderMessage, setRenderMessage] = useState(''); // AutoPlay 로직 useEffect(() => { const video = videoRef.current; const audio = audioRef.current; if (autoPlay && audio) { if (video) video.volume = 1.0; audio.volume = 1.0; let isStarted = false; const stopPlay = () => { if (video) video.pause(); audio.pause(); setIsPlaying(false); setHasPlayed(true); }; const handleEnded = () => { stopPlay(); }; const startPlay = () => { if (isStarted) return; isStarted = true; if (video) video.currentTime = 0; audio.currentTime = 0; audio.addEventListener('ended', handleEnded); const playPromises = [ audio.play().catch(e => console.error("오디오 재생 실패", e)) ]; if (video) { playPromises.push(video.play().catch(e => console.error("비디오 재생 실패", e))); } Promise.all(playPromises).then(() => { setActiveTab('video'); setIsPlaying(true); setHasPlayed(true); }); }; const checkReady = () => { const videoReady = video ? video.readyState >= 3 : true; const audioReady = audio.readyState >= 3; if (videoReady && audioReady) { startPlay(); } }; checkReady(); if (video) video.addEventListener('canplay', checkReady); audio.addEventListener('canplay', checkReady); const timer = setTimeout(() => { console.warn("AutoPlay 타임아웃 - 강제 재생 시도"); startPlay(); }, 10000); return () => { if (video) video.removeEventListener('canplay', checkReady); audio.removeEventListener('canplay', checkReady); audio.removeEventListener('ended', handleEnded); clearTimeout(timer); }; } }, [autoPlay, assets.aspectRatio]); const togglePlay = () => { if (audioRef.current) { if (isPlaying) { videoRef.current?.pause(); audioRef.current.pause(); } else { videoRef.current?.play(); audioRef.current.play(); setActiveTab('video'); setHasPlayed(true); } setIsPlaying(!isPlaying); } }; const toggleMute = () => { if (audioRef.current) { audioRef.current.muted = !isMuted; setIsMuted(!isMuted); } }; useEffect(() => { const video = videoRef.current; const audio = audioRef.current; if (audio) { if (video) video.loop = true; const handleEnded = () => { setIsPlaying(false); video?.pause(); setCurrentTextIndex(0); setShowText(false); }; audio.addEventListener('ended', handleEnded); return () => { audio.removeEventListener('ended', handleEnded); }; } }, []); useEffect(() => { const mediaRef = videoRef.current || audioRef.current; if (autoPlay && !isPlaying && mediaRef && mediaRef.currentTime > 0) { // 재생 종료 상태 } }, [autoPlay, isPlaying]); useEffect(() => { if (!isPlaying) return; const positions: TextPosition[] = ['center', 'bottom-left', 'center-left', 'top-right']; const interval = setInterval(() => { setShowText(false); setTimeout(() => { setCurrentTextIndex((prev) => (prev + 1) % assets.adCopy.length); const randomPos = positions[Math.floor(Math.random() * positions.length)]; setTextPosition(randomPos); setShowText(true); }, 600); }, 4500); setShowText(true); return () => clearInterval(interval); }, [isPlaying, assets.adCopy.length]); // URL을 Base64로 변환하는 유틸리티 함수 const urlToBase64 = async (url: string): Promise => { if (!url) return undefined; try { let response: Response; if (url.startsWith('blob:')) { response = await fetch(url); } else if (url.startsWith('http')) { response = await fetch(`/api/proxy/audio?url=${encodeURIComponent(url)}`); } else { return undefined; } const blob = await response.blob(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => { const result = reader.result as string; resolve(result.split(',')[1]); }; reader.onerror = reject; reader.readAsDataURL(blob); }); } catch (e) { console.error('URL to Base64 failed:', e); return undefined; } }; // 렌더링 작업 상태 폴링 const pollJobStatus = async (jobId: string) => { const token = localStorage.getItem('token'); let pollCount = 0; const maxPolls = 180; // 최대 3분 (1초 간격) const poll = async () => { if (pollCount >= maxPolls) { setRenderStatus('failed'); setRenderMessage('렌더링 시간 초과'); setIsServerDownloading(false); setIsRendering(false); return; } try { const res = await fetch(`/api/render/status/${jobId}`, { headers: { 'Authorization': token ? `Bearer ${token}` : '' } }); const data = await res.json(); if (!data.success) { throw new Error(data.error); } const { job } = data; setDownloadProgress(job.progress || 0); if (job.status === 'completed') { setRenderStatus('completed'); setRenderMessage('렌더링 완료!'); setLastProjectFolder(job.downloadUrl?.split('/')[2] || null); setIsServerDownloading(false); setIsRendering(false); // 자동 다운로드 if (job.downloadUrl) { const a = document.createElement('a'); a.href = job.downloadUrl; a.download = `CastAD_${assets.businessName}_Final.mp4`; document.body.appendChild(a); a.click(); a.remove(); } return; } if (job.status === 'failed') { setRenderStatus('failed'); setRenderMessage(job.error_message || '렌더링 실패'); setIsServerDownloading(false); setIsRendering(false); alert(`렌더링 실패: ${job.error_message || '알 수 없는 오류'}\n크레딧이 환불되었습니다.`); return; } // 계속 폴링 pollCount++; setTimeout(poll, 1000); } catch (error: any) { console.error('폴링 오류:', error); pollCount++; setTimeout(poll, 2000); // 오류 시 2초 후 재시도 } }; poll(); }; const handleServerDownload = async () => { if (isServerDownloading) return; setIsServerDownloading(true); setRenderStatus('submitting'); setRenderMessage('렌더링 요청 중...'); setDownloadProgress(5); try { // 데이터 준비 setDownloadProgress(10); const posterBase64 = await urlToBase64(assets.posterUrl); const audioBase64 = await urlToBase64(assets.audioUrl); setDownloadProgress(15); let imagesBase64: string[] = []; if (assets.images && assets.images.length > 0) { imagesBase64 = await Promise.all( assets.images.map(async (img) => { if (img.startsWith('blob:')) { return (await urlToBase64(img)) || img; } if (img.startsWith('data:')) { return img.split(',')[1]; } return img; }) ); } setDownloadProgress(20); const payload = { posterBase64, audioBase64, imagesBase64, adCopy: assets.adCopy, textEffect: assets.textEffect || 'effect-fade', businessName: assets.businessName, aspectRatio: assets.aspectRatio || '9:16', pensionId: assets.pensionId }; const token = localStorage.getItem('token'); // 렌더링 작업 시작 요청 const response = await fetch('/api/render/start', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, body: JSON.stringify(payload) }); const data = await response.json(); if (!response.ok || !data.success) { // 이미 진행 중인 작업이 있는 경우 if (data.errorCode === 'RENDER_IN_PROGRESS') { setCurrentJobId(data.existingJobId); setRenderStatus('processing'); setRenderMessage(`기존 작업 진행 중 (${data.existingJobProgress || 0}%)`); setIsRendering(true); setDownloadProgress(data.existingJobProgress || 0); // 기존 작업 상태 폴링 시작 pollJobStatus(data.existingJobId); return; } throw new Error(data.error || '렌더링 요청 실패'); } // 작업 ID 저장 및 폴링 시작 setCurrentJobId(data.jobId); setRenderStatus('processing'); setRenderMessage(`렌더링 중... (크레딧 ${data.creditsCharged} 차감)`); setIsRendering(true); setDownloadProgress(25); // 상태 폴링 시작 pollJobStatus(data.jobId); } catch (e: any) { console.error(e); setRenderStatus('failed'); setRenderMessage(e.message || '렌더링 요청 실패'); setIsServerDownloading(false); setIsRendering(false); setDownloadProgress(0); if (e.message?.includes('크레딧')) { alert(e.message); } else { alert(`렌더링 요청 실패: ${e.message}`); } } }; const handleYoutubeClick = () => { if (!lastProjectFolder) { alert("먼저 영상을 생성(다운로드)해야 업로드할 수 있습니다."); return; } setShowSEOPreview(true); }; const handleYoutubeUpload = async (seoData?: any) => { if (!lastProjectFolder) { alert("먼저 영상을 생성(다운로드)해야 업로드할 수 있습니다."); return; } setShowSEOPreview(false); setIsUploading(true); setUploadStatus("YouTube 연결 확인 중..."); setYoutubeUrl(null); const token = localStorage.getItem('token'); try { // 먼저 사용자의 YouTube 연결 상태 확인 const connRes = await fetch('/api/youtube/connection', { headers: { 'Authorization': token ? `Bearer ${token}` : '' } }); const connData = await connRes.json(); // SEO 데이터가 있으면 사용, 없으면 기본값 const title = seoData?.snippet?.title_ko || `${assets.businessName} - AI Marketing Video`; const description = seoData?.snippet?.description_ko || assets.description; const tags = seoData?.snippet?.tags_ko || []; const categoryId = seoData?.snippet?.categoryId || '19'; // 19 = Travel & Events const pinnedComment = seoData?.pinned_comment_ko || ''; // 비디오 경로 생성 const videoPath = `downloads/${lastProjectFolder}/final.mp4`; // YouTube 연결 확인 if (!connData.connected) { throw new Error("YouTube 계정이 연결되지 않았습니다. 설정에서 YouTube 계정을 먼저 연결해주세요."); } // 사용자 채널에 업로드 setUploadStatus("내 채널에 업로드 중..."); const response = await fetch('/api/youtube/my-upload', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, body: JSON.stringify({ videoPath, seoData: { title, description, tags, pinnedComment }, historyId: assets.id, categoryId }) }); if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || "업로드 실패"); } const data = await response.json(); setYoutubeUrl(data.youtubeUrl || data.url); setUploadStatus("내 채널에 업로드 완료!"); } catch (e: any) { console.error(e); setUploadStatus(e.message || "업로드 실패"); alert(`YouTube 업로드 실패: ${e.message}`); } finally { setIsUploading(false); } }; // Instagram 업로드 핸들러 const handleInstagramUpload = async () => { if (!lastProjectFolder) { alert("먼저 영상을 생성(다운로드)해야 업로드할 수 있습니다."); return; } setIsUploadingInstagram(true); setInstagramUploadStatus("Instagram 연결 확인 중..."); setInstagramUrl(null); const token = localStorage.getItem('token'); try { // Instagram 연결 상태 확인 const statusRes = await fetch('/api/instagram/status', { headers: { 'Authorization': token ? `Bearer ${token}` : '' } }); const statusData = await statusRes.json(); if (!statusData.connected) { throw new Error("Instagram 계정이 연결되지 않았습니다. 설정에서 Instagram 계정을 먼저 연결해주세요."); } // 캡션 생성 (광고 카피) const adCopyText = assets.adCopy?.join('\n') || assets.businessName || ''; const hashtags = `#${assets.businessName?.replace(/\s+/g, '')} #펜션 #숙소 #여행 #힐링 #휴가 #AI마케팅 #CaStAD`; setInstagramUploadStatus("Instagram Reels 업로드 중..."); const response = await fetch('/api/instagram/upload', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, body: JSON.stringify({ history_id: assets.id, caption: adCopyText, hashtags: hashtags }) }); if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || "Instagram 업로드 실패"); } const data = await response.json(); setInstagramUrl(data.mediaUrl || data.url || 'uploaded'); setInstagramUploadStatus("Instagram Reels 업로드 완료!"); } catch (e: any) { console.error(e); setInstagramUploadStatus(e.message || "업로드 실패"); alert(`Instagram 업로드 실패: ${e.message}`); } finally { setIsUploadingInstagram(false); } }; const handleMergeDownload = async () => { if (isMerging) return; setIsMerging(true); setMergeProgress('초기화 중...'); try { const mergedUrl = await mergeVideoAndAudio(assets.videoUrl, assets.audioUrl, (msg) => setMergeProgress(msg)); const a = document.createElement('a'); a.href = mergedUrl; a.download = `CastAD_Campaign_Full.mp4`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setMergeProgress('완료!'); setTimeout(() => { setIsMerging(false); setMergeProgress(''); }, 2000); } catch (e: any) { alert(e.message || "다운로드 중 오류가 발생했습니다."); setIsMerging(false); setMergeProgress(''); } }; const handleAudioDownload = async () => { try { if (assets.audioUrl.startsWith('blob:')) { const a = document.createElement('a'); a.href = assets.audioUrl; a.download = `CastAD_${assets.businessName}_Audio.mp3`; document.body.appendChild(a); a.click(); a.remove(); } else { const response = await fetch(assets.audioUrl); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `CastAD_${assets.businessName}_Audio.mp3`; document.body.appendChild(a); a.click(); a.remove(); } } catch (e) { console.error("오디오 다운로드 실패:", e); alert("오디오 다운로드 실패"); } }; const getEffectClass = () => { switch(assets.textEffect) { case 'Neon': return 'effect-neon text-white font-black tracking-wider'; case 'Glitch': return 'effect-glitch font-bold tracking-tighter uppercase'; case 'Typewriter': return 'effect-typewriter font-mono bg-black/50 p-2'; case 'Cinematic': return 'effect-cinematic font-light tracking-[0.2em] uppercase text-gray-100'; case 'Bold': return 'effect-bold text-white font-black italic tracking-tighter'; case 'Motion': return 'effect-motion font-extrabold italic tracking-tight text-5xl md:text-7xl'; case 'LineReveal': return 'effect-line-reveal font-bold uppercase tracking-widest py-4'; case 'Boxed': return 'effect-boxed font-bold uppercase tracking-wider'; case 'Elegant': return 'effect-elegant font-light text-4xl md:text-6xl'; case 'BlockReveal': return 'effect-block-reveal font-black uppercase italic text-5xl md:text-7xl px-4 py-1'; case 'Custom': return 'effect-custom custom-effect'; default: return 'effect-cinematic'; } }; const getRandomColor = () => { const colors = ['#FF00DE', '#00FF94', '#00F0FF', '#FFFD00', '#FF5C00', '#9D00FF']; return colors[Math.floor(Math.random() * colors.length)]; }; useEffect(() => { if (assets.textEffect === 'Custom' && assets.customStyleCSS) { let styleTag = document.getElementById('generated-custom-style'); if (!styleTag) { styleTag = document.createElement('style'); styleTag.id = 'generated-custom-style'; document.head.appendChild(styleTag); } styleTag.textContent = assets.customStyleCSS; } }, [assets.textEffect, assets.customStyleCSS]); const formatText = (text: string) => { const commonClasses = "block drop-shadow-xl px-4 rounded-lg text-center mx-auto whitespace-pre-wrap"; if (assets.textEffect === 'Typewriter' || assets.textEffect === 'Glitch') { return ( {text} ); } const lines = text.split('\n'); const noBgEffects = ['Motion', 'LineReveal', 'Boxed', 'Elegant', 'BlockReveal']; return lines.map((line, idx) => { const style: React.CSSProperties = {}; if (assets.textEffect === 'Motion') { style.animationDelay = `${idx * 0.2}s`; } else if (assets.textEffect === 'BlockReveal') { style.animationDelay = `${idx * 0.3}s`; (style as any)['--block-color'] = getRandomColor(); } return ( {line.trim()} ); }); }; const getPositionClasses = () => { return 'inset-0 items-center justify-center text-center'; }; const cleanLyrics = assets.lyrics .split('\n') .filter(line => { const trimmed = line.trim(); if (!trimmed) return false; if (trimmed.startsWith('[') || trimmed.startsWith('(')) return false; if (/^(Narrator|나레이터|성우|Speaker|Woman|Man).*?:/i.test(trimmed)) return false; return true; }) .map(line => line.replace(/\*\*/g, "").replace(/\*/g, "").replace(/[•*-]/g, "").trim()) .join(" • "); const marqueeDuration = `${Math.max(20, cleanLyrics.length * 0.3)}s`; const isVertical = assets.aspectRatio === '9:16'; const containerClass = isVertical ? "max-w-md aspect-[9/16]" : "max-w-5xl aspect-video"; const wrapperClass = isVertical ? "max-w-md" : "max-w-5xl"; // [오디오 전용 모드] if (assets.creationMode === 'AudioOnly') { return (
{/* 배경 효과 */}
{/* 앨범 커버 */}
{assets.audioMode === 'Song' ? : }

{assets.businessName}

{assets.audioMode === 'Song' ? 'AI Generated Song' : 'AI Voice Narration'}
{/* 컨트롤 */}
{/* 다운로드 */}
); } // [비디오 모드] return (
{/* 탭 */} {!autoPlay && ( setActiveTab(v as 'video' | 'poster')} className="w-full mb-6"> {t('textStyle') === '자막 스타일' ? '광고 영상' : 'Video'} {t('textStyle') === '자막 스타일' ? '광고 포스터' : 'Poster'} )} {/* 콘텐츠 영역 */}
{/* 비디오/슬라이드쇼 뷰 */}
{assets.visualStyle === 'Slideshow' && assets.images && assets.images.length > 0 ? ( ) : (
{/* 컨트롤 바 - 2줄 레이아웃 */} {!autoPlay && ( {/* 1줄: 재생 컨트롤 */}
{/* 뒤로가기 버튼 */} {/* 재생/일시정지 버튼 - 중앙에 크게 */}

{isPlaying ? 'NOW PLAYING' : 'READY TO PLAY'}

{activeTab === 'video' ? (t('textStyle') === '자막 스타일' ? 'AI 광고 영상' : 'AI Ad Video') : (t('textStyle') === '자막 스타일' ? 'AI 포스터' : 'AI Poster') }

{/* 2줄: 저장 버튼들 */}
{/* 서버 다운로드 */}

{t('textStyle') === '자막 스타일' ? '텍스트 효과가 포함된 영상 저장' : 'Save with text effects'}

{/* YouTube 업로드 */} {!youtubeUrl ? (

{!lastProjectFolder ? (t('textStyle') === '자막 스타일' ? '먼저 영상을 저장하세요' : 'Save video first') : (t('textStyle') === '자막 스타일' ? 'YouTube에 업로드' : 'Upload to YouTube') }

) : (
{t('textStyle') === '자막 스타일' ? '업로드 완료' : 'Uploaded'}
)} {/* Instagram 업로드 */} {!instagramUrl ? (

{!lastProjectFolder ? (t('textStyle') === '자막 스타일' ? '먼저 영상을 저장하세요' : 'Save video first') : (t('textStyle') === '자막 스타일' ? 'Instagram Reels 업로드' : 'Upload to Instagram Reels') }

) : (
{t('textStyle') === '자막 스타일' ? '업로드 완료' : 'Uploaded'}
)}
{/* 렌더링 진행률 및 상태 */} {(downloadProgress > 0 || renderStatus === 'processing') && (
{renderStatus === 'processing' && }

{renderMessage || (t('textStyle') === '자막 스타일' ? '렌더링 중...' : 'Rendering...')} {downloadProgress}%

{renderStatus === 'processing' && (

{t('textStyle') === '자막 스타일' ? '페이지를 나가도 렌더링은 계속됩니다' : 'Rendering continues even if you leave'}

)}
)} {/* Instagram 업로드 상태 */} {isUploadingInstagram && instagramUploadStatus && (

{instagramUploadStatus}

)}
)} {!autoPlay && showShareModal && ( setShowShareModal(false)} /> )} {/* YouTube SEO Preview Modal */} setShowSEOPreview(false)} businessInfo={{ businessName: assets.businessName, description: assets.description || assets.adCopy?.join(' ') || '', categories: assets.pensionCategories || [], address: assets.address || '', language: assets.language || 'KO' }} videoPath={lastProjectFolder || undefined} videoDuration={60} onUpload={handleYoutubeUpload} /> {/* Puppeteer 녹화 시그널 */} {autoPlay && hasPlayed && !isPlaying && (
)}
); }; export default ResultPlayer;