1143 lines
47 KiB
TypeScript
1143 lines
47 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, 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<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);
|
|
|
|
// Instagram 업로드 상태
|
|
const [isUploadingInstagram, setIsUploadingInstagram] = useState(false);
|
|
const [instagramUploadStatus, setInstagramUploadStatus] = useState('');
|
|
const [instagramUrl, setInstagramUrl] = useState<string | null>(null);
|
|
|
|
// 렌더링 큐 상태
|
|
const [currentJobId, setCurrentJobId] = useState<string | null>(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<string | undefined> => {
|
|
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 (
|
|
<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>
|
|
)}
|
|
|
|
{/* Instagram 업로드 */}
|
|
{!instagramUrl ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleInstagramUpload}
|
|
disabled={isUploadingInstagram || !lastProjectFolder}
|
|
className={cn(
|
|
"gap-2",
|
|
lastProjectFolder && "bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 hover:from-purple-600 hover:via-pink-600 hover:to-orange-600 text-white border-0"
|
|
)}
|
|
>
|
|
{isUploadingInstagram ? <Loader2 className="w-4 h-4 animate-spin" /> : <Instagram className="w-4 h-4" />}
|
|
Instagram
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{!lastProjectFolder
|
|
? (t('textStyle') === '자막 스타일' ? '먼저 영상을 저장하세요' : 'Save video first')
|
|
: (t('textStyle') === '자막 스타일' ? 'Instagram Reels 업로드' : 'Upload to Instagram Reels')
|
|
}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="default"
|
|
className="bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 gap-2"
|
|
>
|
|
<Instagram className="w-4 h-4" />
|
|
{t('textStyle') === '자막 스타일' ? '완료' : 'Done'}
|
|
</Button>
|
|
<Badge variant="outline" className="text-green-500 border-green-500/50">
|
|
{t('textStyle') === '자막 스타일' ? '업로드 완료' : 'Uploaded'}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</TooltipProvider>
|
|
</div>
|
|
|
|
{/* 렌더링 진행률 및 상태 */}
|
|
{(downloadProgress > 0 || renderStatus === 'processing') && (
|
|
<div className="w-full pt-2 border-t border-border/50">
|
|
<Progress value={downloadProgress} className="h-2" />
|
|
<div className="flex items-center justify-center gap-2 mt-1">
|
|
{renderStatus === 'processing' && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
<p className="text-xs text-muted-foreground">
|
|
{renderMessage || (t('textStyle') === '자막 스타일' ? '렌더링 중...' : 'Rendering...')} {downloadProgress}%
|
|
</p>
|
|
</div>
|
|
{renderStatus === 'processing' && (
|
|
<p className="text-xs text-muted-foreground/70 text-center mt-1">
|
|
{t('textStyle') === '자막 스타일'
|
|
? '페이지를 나가도 렌더링은 계속됩니다'
|
|
: 'Rendering continues even if you leave'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Instagram 업로드 상태 */}
|
|
{isUploadingInstagram && instagramUploadStatus && (
|
|
<div className="w-full pt-2 border-t border-border/50">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin text-pink-500" />
|
|
<p className="text-xs text-muted-foreground">{instagramUploadStatus}</p>
|
|
</div>
|
|
</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;
|