CASTAD-v0.1/components/ResultPlayer.tsx

950 lines
38 KiB
TypeScript

/// <reference lib="dom" />
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<ResultPlayerProps> = ({ assets, onReset, autoPlay = false }) => {
const { t } = useLanguage();
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(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<TextPosition>('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<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState('');
const [youtubeUrl, setYoutubeUrl] = useState<string | null>(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<string | undefined> => {
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 (
<span className={`inline-block ${commonClasses} ${getEffectClass()}`} data-text={text}>
{text}
</span>
);
}
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 (
<span
key={idx}
className={`block mb-3 ${commonClasses} ${!noBgEffects.includes(assets.textEffect) ? 'bg-black/20 backdrop-blur-sm' : ''} ${getEffectClass()}`}
style={style}
>
{line.trim()}
</span>
);
});
};
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 (
<div className="w-full max-w-lg mx-auto pb-20 flex flex-col items-center justify-center min-h-[60vh]">
<Card className="w-full border-border/50 shadow-2xl overflow-hidden">
<CardContent className="p-8 text-center relative">
{/* 배경 효과 */}
<div className="absolute inset-0 bg-gradient-to-b from-primary/10 to-transparent" />
<div className={cn(
"absolute inset-0 flex items-center justify-center transition-opacity duration-1000 pointer-events-none",
isPlaying ? 'opacity-40' : 'opacity-10'
)}>
<div className="w-48 h-48 bg-primary rounded-full blur-[80px] animate-pulse" />
</div>
<div className="relative z-10 flex flex-col items-center">
{/* 앨범 커버 */}
<div className={cn(
"w-32 h-32 rounded-full bg-gradient-to-br from-card to-background border-4 border-border flex items-center justify-center shadow-xl mb-6",
isPlaying && 'animate-spin-slow'
)}>
{assets.audioMode === 'Song'
? <Music className="w-12 h-12 text-primary" />
: <Mic className="w-12 h-12 text-accent" />
}
</div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-foreground mb-2">{assets.businessName}</h2>
<Badge variant="secondary">
{assets.audioMode === 'Song' ? 'AI Generated Song' : 'AI Voice Narration'}
</Badge>
</div>
{/* 컨트롤 */}
<div className="flex gap-4 items-center mb-6">
<Button
size="icon"
variant="outline"
onClick={toggleMute}
className="w-12 h-12 rounded-full"
>
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</Button>
<Button
onClick={togglePlay}
className="w-16 h-16 rounded-full bg-foreground text-background hover:opacity-90"
>
{isPlaying ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6 ml-1" />}
</Button>
</div>
{/* 다운로드 */}
<Button
variant="outline"
onClick={handleAudioDownload}
className="w-full"
>
<Download className="w-4 h-4 mr-2" />
MP3 {t('textStyle') === '자막 스타일' ? '다운로드' : 'Download'}
</Button>
</div>
<audio ref={audioRef} src={assets.audioUrl} onEnded={() => setIsPlaying(false)} />
</CardContent>
</Card>
<Button variant="ghost" onClick={onReset} className="mt-6">
<ArrowLeft className="w-4 h-4 mr-2" />
{t('textStyle') === '자막 스타일' ? '처음으로' : 'Go Back'}
</Button>
</div>
);
}
// [비디오 모드]
return (
<div className={autoPlay ? "fixed inset-0 w-screen h-screen z-[9999] bg-black flex items-center justify-center overflow-hidden" : `w-full ${wrapperClass} mx-auto pb-10`}>
{/* 탭 */}
{!autoPlay && (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'poster')} className="w-full mb-6">
<TabsList className="grid w-full max-w-md mx-auto grid-cols-2">
<TabsTrigger value="video" className="flex items-center gap-2">
<VideoIcon className="w-4 h-4" />
{t('textStyle') === '자막 스타일' ? '광고 영상' : 'Video'}
</TabsTrigger>
<TabsTrigger value="poster" className="flex items-center gap-2">
<ImageIcon className="w-4 h-4" />
{t('textStyle') === '자막 스타일' ? '광고 포스터' : 'Poster'}
</TabsTrigger>
</TabsList>
</Tabs>
)}
<Card className={cn(
"overflow-hidden border-border/50 shadow-2xl",
autoPlay && "border-none rounded-none"
)}>
<CardContent className={cn("p-0", autoPlay && "h-full")}>
{/* 콘텐츠 영역 */}
<div className={cn(
"relative overflow-hidden mx-auto bg-black",
autoPlay ? "w-full h-full" : `${containerClass} rounded-lg`
)}>
{/* 비디오/슬라이드쇼 뷰 */}
<div className={cn(
"absolute inset-0 transition-opacity duration-700",
activeTab === 'video' ? 'opacity-100 z-10' : 'opacity-0 z-0'
)}>
{assets.visualStyle === 'Slideshow' && assets.images && assets.images.length > 0 ? (
<SlideshowBackground
images={assets.images}
durationPerImage={5000}
effect={assets.transitionEffect}
/>
) : (
<video
ref={videoRef}
src={assets.videoUrl}
className="w-full h-full object-cover"
playsInline
muted
loop
preload="auto"
/>
)}
{/* 텍스트 오버레이 */}
<div className={cn(
"absolute flex pointer-events-none z-20 p-8 transition-all duration-500",
getPositionClasses()
)}>
<div className={cn(
"transition-all duration-700 transform ease-out",
showText ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-8 scale-95',
isVertical ? 'w-full px-4' : 'max-w-4xl'
)}>
<div
className={cn(
"text-white",
isVertical ? 'text-3xl leading-snug' : 'text-4xl md:text-6xl leading-tight'
)}
style={{
fontFamily: assets.textEffect === 'Typewriter' ? "'Fira Code', monospace" : "'Noto Sans KR', sans-serif",
letterSpacing: '-0.02em',
textShadow: '0 4px 30px rgba(0,0,0,0.5)'
}}
>
{formatText(assets.adCopy[currentTextIndex])}
</div>
</div>
</div>
{/* 재생 버튼 오버레이 */}
{!isPlaying && !autoPlay && (
<button
type="button"
className="absolute inset-0 flex items-center justify-center bg-black/60 z-50 cursor-pointer backdrop-blur-[2px] transition-all hover:bg-black/50"
onClick={togglePlay}
>
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-2xl hover:scale-110 transition-transform">
<Play className="w-8 h-8 text-primary-foreground fill-current ml-1" />
</div>
</button>
)}
{/* 가사 Marquee */}
<div className={cn(
"absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black via-black/80 to-transparent z-20 flex items-end p-6",
isVertical ? 'h-40 pb-10' : 'h-32'
)}>
<div className="w-full flex items-end gap-4">
<div className="hidden md:block w-1.5 h-16 bg-gradient-to-b from-primary to-accent rounded-full" />
<div className="flex-1 overflow-hidden">
<div className="flex items-center gap-2 mb-2">
{assets.audioMode === 'Song' ? (
<>
<Music className="w-4 h-4 text-primary" />
<span className="text-primary text-xs font-bold uppercase tracking-widest">Original AI Sound</span>
</>
) : (
<>
<Mic className="w-4 h-4 text-accent" />
<span className="text-accent text-xs font-bold uppercase tracking-widest">AI Voice Narration</span>
</>
)}
</div>
<div className="relative h-12 overflow-hidden w-full mask-linear-fade">
<div
className="flex whitespace-nowrap"
style={{
animation: `marquee ${marqueeDuration} linear infinite`
}}
>
<span className="text-white/90 text-lg font-medium mr-12 inline-block">
{cleanLyrics}
</span>
<span className="text-white/90 text-lg font-medium mr-12 inline-block">
{cleanLyrics}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* 포스터 뷰 */}
<div className={cn(
"absolute inset-0 bg-black flex items-center justify-center transition-opacity duration-700",
activeTab === 'poster' ? 'opacity-100 z-10' : 'opacity-0 z-0'
)}>
<img src={assets.posterUrl} alt="AI Generated Poster" className="w-full h-full object-contain" />
</div>
<audio ref={audioRef} src={assets.audioUrl} onEnded={() => setIsPlaying(false)} />
</div>
</CardContent>
</Card>
{/* 컨트롤 바 - 2줄 레이아웃 */}
{!autoPlay && (
<Card className="mt-4 border-border/50">
<CardContent className="p-4 space-y-4">
{/* 1줄: 재생 컨트롤 */}
<div className="flex items-center justify-center gap-4">
{/* 뒤로가기 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={onReset}
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4 mr-1" />
{t('textStyle') === '자막 스타일' ? '처음으로' : 'Back'}
</Button>
<Separator orientation="vertical" className="h-8" />
{/* 재생/일시정지 버튼 - 중앙에 크게 */}
<Button
variant={isPlaying ? "default" : "outline"}
size="icon"
onClick={togglePlay}
className={cn(
"h-14 w-14 rounded-full transition-all duration-300",
isPlaying
? "bg-primary hover:bg-primary/90 shadow-lg shadow-primary/30 scale-110"
: "hover:bg-primary/10 hover:border-primary hover:scale-105"
)}
>
{isPlaying ? (
<Pause className="w-6 h-6" />
) : (
<Play className="w-6 h-6 ml-0.5" />
)}
</Button>
<div className="min-w-[100px]">
<p className="text-[10px] text-muted-foreground mb-0.5 text-center">
{isPlaying ? 'NOW PLAYING' : 'READY TO PLAY'}
</p>
<p className="text-xs font-medium text-foreground text-center">
{activeTab === 'video'
? (t('textStyle') === '자막 스타일' ? 'AI 광고 영상' : 'AI Ad Video')
: (t('textStyle') === '자막 스타일' ? 'AI 포스터' : 'AI Poster')
}
</p>
</div>
</div>
<Separator />
{/* 2줄: 저장 버튼들 */}
<div className="flex items-center justify-center gap-2 flex-wrap">
<TooltipProvider>
{/* 서버 다운로드 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleServerDownload}
disabled={isServerDownloading}
className="gap-2"
>
{isServerDownloading ? (
<><Loader2 className="w-4 h-4 animate-spin" /> {t('textStyle') === '자막 스타일' ? '생성중...' : 'Rendering...'}</>
) : (
<><Download className="w-4 h-4" /> {t('textStyle') === '자막 스타일' ? '영상 저장' : 'Save Video'}</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('textStyle') === '자막 스타일' ? '텍스트 효과가 포함된 영상 저장' : 'Save with text effects'}</p>
</TooltipContent>
</Tooltip>
{/* YouTube 업로드 */}
{!youtubeUrl ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
onClick={handleYoutubeClick}
disabled={isUploading || !lastProjectFolder}
className={cn(
"gap-2",
lastProjectFolder && "bg-red-600 hover:bg-red-500 text-white border-red-600"
)}
>
{isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Youtube className="w-4 h-4" />}
YouTube
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{!lastProjectFolder
? (t('textStyle') === '자막 스타일' ? '먼저 영상을 저장하세요' : 'Save video first')
: (t('textStyle') === '자막 스타일' ? 'YouTube에 업로드' : 'Upload to YouTube')
}</p>
</TooltipContent>
</Tooltip>
) : (
<div className="flex items-center gap-2">
<Button
variant="default"
asChild
className="bg-red-600 hover:bg-red-500 gap-2"
>
<a href={youtubeUrl} target="_blank" rel="noopener noreferrer">
<Youtube className="w-4 h-4" />
{t('textStyle') === '자막 스타일' ? '보러가기' : 'Watch'}
</a>
</Button>
<Badge variant="outline" className="text-green-500 border-green-500/50">
{t('textStyle') === '자막 스타일' ? '업로드 완료' : 'Uploaded'}
</Badge>
</div>
)}
</TooltipProvider>
</div>
{/* 다운로드 진행률 */}
{downloadProgress > 0 && downloadProgress < 100 && (
<div className="w-full pt-2 border-t border-border/50">
<Progress value={downloadProgress} className="h-2" />
<p className="text-xs text-muted-foreground text-center mt-1">
{t('textStyle') === '자막 스타일' ? '렌더링 중...' : 'Rendering...'} {downloadProgress}%
</p>
</div>
)}
</CardContent>
</Card>
)}
{!autoPlay && showShareModal && (
<ShareModal
videoUrl={assets.videoUrl}
posterUrl={assets.posterUrl}
businessName={assets.businessName}
onClose={() => setShowShareModal(false)}
/>
)}
{/* YouTube SEO Preview Modal */}
<YouTubeSEOPreview
isOpen={showSEOPreview}
onClose={() => 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 && (
<div id="playback-completed" className="hidden"></div>
)}
</div>
);
};
export default ResultPlayer;