/// 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, 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); // 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]); const handleServerDownload = async () => { if (isServerDownloading) return; setIsServerDownloading(true); setDownloadProgress(10); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 300000); try { const urlToBase64 = async (url: string): Promise => { if (!url) return undefined; try { // blob URL이든 외부 URL이든 모두 처리 let response: Response; if (url.startsWith('blob:')) { response = await fetch(url); } else if (url.startsWith('http')) { // 외부 URL은 프록시를 통해 가져옴 (CORS 우회) 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('Audio URL to Base64 failed:', e); return undefined; } }; setDownloadProgress(20); const posterBase64 = await urlToBase64(assets.posterUrl); const audioBase64 = await urlToBase64(assets.audioUrl); setDownloadProgress(30); 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(40); const payload = { ...assets, historyId: assets.id, posterBase64, audioBase64, imagesBase64 }; setIsRendering(true); setDownloadProgress(50); const token = localStorage.getItem('token'); const response = await fetch('/render', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, body: JSON.stringify(payload), signal: controller.signal }); setDownloadProgress(80); if (!response.ok) { const err = await response.text(); throw new Error(`서버 오류: ${err}`); } const folderNameHeader = response.headers.get('X-Project-Folder'); if (folderNameHeader) { setLastProjectFolder(decodeURIComponent(folderNameHeader)); } setDownloadProgress(90); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `CastAD_${assets.businessName}_Final.mp4`; document.body.appendChild(a); a.click(); a.remove(); setDownloadProgress(100); } catch (e: any) { console.error(e); if (e.name === 'AbortError') { alert("영상 생성 시간 초과 (5분). 서버 부하가 높거나 네트워크 문제일 수 있습니다."); } else { alert("영상 생성 실패: 서버가 실행 중인지 확인해주세요."); } } finally { clearTimeout(timeoutId); setIsServerDownloading(false); setIsRendering(false); setTimeout(() => setDownloadProgress(0), 2000); } }; 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`; let response; if (connData.connected) { // 사용자 채널에 업로드 (새 API) setUploadStatus("내 채널에 업로드 중..."); 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 }) }); } else { // 레거시 업로드 (시스템 채널) setUploadStatus("시스템 채널에 업로드 중..."); response = await fetch('/api/youtube/upload', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, body: JSON.stringify({ videoPath, seoData: { title, description, tags }, categoryId }) }); } if (!response.ok) { const errData = await response.json(); throw new Error(errData.error || "업로드 실패"); } const data = await response.json(); setYoutubeUrl(data.youtubeUrl); setUploadStatus(connData.connected ? "내 채널에 업로드 완료!" : "업로드 완료!"); } catch (e: any) { console.error(e); setUploadStatus(e.message || "업로드 실패"); alert(`YouTube 업로드 실패: ${e.message}`); } finally { setIsUploading(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'}
)}
{/* 다운로드 진행률 */} {downloadProgress > 0 && downloadProgress < 100 && (

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

)}
)} {!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;