배경음악 기능 추가 및 예외 처리 수정
parent
cf27da30b4
commit
a155a98767
49
index.css
49
index.css
|
|
@ -8046,6 +8046,51 @@
|
||||||
background: #379599;
|
background: #379599;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lyrics-paragraphs {
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
scrollbar-color: #046266 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-paragraphs::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-paragraphs::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-paragraphs::-webkit-scrollbar-thumb {
|
||||||
|
background: #046266;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-tag {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
color: #379599;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-paragraph {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
color: #CEE5E6;
|
||||||
|
line-height: 1.9;
|
||||||
|
white-space: pre-line;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.lyrics-placeholder {
|
.lyrics-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -10572,6 +10617,10 @@
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.social-posting-channel-placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.social-posting-channel-menu {
|
.social-posting-channel-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
google-site-verification: google60b514c02fd6af4e.html
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
naver-site-verification: naver33dfe258205b0af1416aa0ac18c8c0b3.html
|
||||||
|
|
@ -142,6 +142,10 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const hasBeenOpenedRef = useRef(false);
|
const hasBeenOpenedRef = useRef(false);
|
||||||
|
const loadedForTaskIdRef = useRef<string | null>(null);
|
||||||
|
const loadedAtRef = useRef<number>(0);
|
||||||
|
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
|
||||||
|
const SEO_CACHE_TTL = 50 * 60 * 1000;
|
||||||
|
|
||||||
// Upload progress modal state
|
// Upload progress modal state
|
||||||
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
||||||
|
|
@ -207,11 +211,28 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
|
|
||||||
// 소셜 계정 로드
|
// 소셜 계정 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
loadSocialAccounts();
|
loadSocialAccounts();
|
||||||
|
|
||||||
|
const taskId = video?.task_id ?? null;
|
||||||
|
const expired = now - loadedAtRef.current > SEO_CACHE_TTL;
|
||||||
|
|
||||||
|
if (taskId && (taskId !== loadedForTaskIdRef.current || expired)) {
|
||||||
|
loadedForTaskIdRef.current = taskId;
|
||||||
|
loadedAtRef.current = now;
|
||||||
loadAutocomplete();
|
loadAutocomplete();
|
||||||
|
} else if (taskId) {
|
||||||
|
const cached = seoCache.current.get(taskId);
|
||||||
|
if (cached) {
|
||||||
|
setTitle(cached.title);
|
||||||
|
setDescription(cached.description);
|
||||||
|
setTags(cached.tags);
|
||||||
}
|
}
|
||||||
}, [isOpen, video]);
|
}
|
||||||
|
}, [isOpen, video?.task_id]);
|
||||||
|
|
||||||
const loadSocialAccounts = async () => {
|
const loadSocialAccounts = async () => {
|
||||||
setIsLoadingAccounts(true);
|
setIsLoadingAccounts(true);
|
||||||
|
|
@ -252,6 +273,11 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
||||||
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
||||||
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
||||||
|
seoCache.current.set(video.task_id, {
|
||||||
|
title: autoSeoResponse.title || '',
|
||||||
|
description: autoSeoResponse.description || '',
|
||||||
|
tags: autoSeoResponse.keywords?.join(',') || '',
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load autocomplete:', error);
|
console.error('Failed to load autocomplete:', error);
|
||||||
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
|
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
|
||||||
|
|
@ -530,7 +556,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)}
|
onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)}
|
||||||
>
|
>
|
||||||
<div className="social-posting-channel-selected">
|
<div className="social-posting-channel-selected">
|
||||||
{selectedAccount && (
|
{selectedAccount ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
src={getPlatformIcon(selectedAccount.platform)}
|
src={getPlatformIcon(selectedAccount.platform)}
|
||||||
|
|
@ -539,6 +565,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
/>
|
/>
|
||||||
<span>{selectedAccount.display_name}</span>
|
<span>{selectedAccount.display_name}</span>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="social-posting-channel-placeholder">{t('social.selectChannel')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none">
|
<svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none">
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@
|
||||||
"imageAlt": "Image",
|
"imageAlt": "Image",
|
||||||
"uploadBadge": "Uploaded",
|
"uploadBadge": "Uploaded",
|
||||||
"imageUpload": "Image Upload",
|
"imageUpload": "Image Upload",
|
||||||
"dragAndDrop": "Drag and drop\nimages to upload",
|
"dragAndDrop": "Drag & drop or\nclick to upload",
|
||||||
"videoRatio": "Video Ratio",
|
"videoRatio": "Video Ratio",
|
||||||
"minImages": "Min. 5 images",
|
"minImages": "Min. 5 images",
|
||||||
"youtubeShorts": "YouTube Shorts",
|
"youtubeShorts": "YouTube Shorts",
|
||||||
|
|
@ -234,6 +234,7 @@
|
||||||
"lyricsColumn": "Lyrics",
|
"lyricsColumn": "Lyrics",
|
||||||
"lyricsHint": "Select the lyrics area to edit",
|
"lyricsHint": "Select the lyrics area to edit",
|
||||||
"lyricsPlaceholder": "Lyrics will be displayed when sound is generated.",
|
"lyricsPlaceholder": "Lyrics will be displayed when sound is generated.",
|
||||||
|
"lyricsPlaceholderBGM": "Background music is generated without lyrics.",
|
||||||
"generateButton": "Generate Sound",
|
"generateButton": "Generate Sound",
|
||||||
"regenerateButton": "Regenerate",
|
"regenerateButton": "Regenerate",
|
||||||
"regenerateHint": "Press the regenerate button to create new lyrics and music.",
|
"regenerateHint": "Press the regenerate button to create new lyrics and music.",
|
||||||
|
|
@ -306,6 +307,7 @@
|
||||||
"resolution": "Resolution",
|
"resolution": "Resolution",
|
||||||
"lyrics": "Lyrics",
|
"lyrics": "Lyrics",
|
||||||
"sampleLyrics": "Loading lyrics...",
|
"sampleLyrics": "Loading lyrics...",
|
||||||
|
"noLyricsBGM": "Background music is generated without lyrics.",
|
||||||
"downloading": "Downloading...",
|
"downloading": "Downloading...",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"uploadToSocial": "Upload to Social"
|
"uploadToSocial": "Upload to Social"
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@
|
||||||
"imageAlt": "이미지",
|
"imageAlt": "이미지",
|
||||||
"uploadBadge": "업로드",
|
"uploadBadge": "업로드",
|
||||||
"imageUpload": "이미지 업로드",
|
"imageUpload": "이미지 업로드",
|
||||||
"dragAndDrop": "이미지를 드래그하여\n업로드",
|
"dragAndDrop": "이미지를 끌어다 놓거나\n클릭하여 업로드",
|
||||||
"videoRatio": "영상 비율",
|
"videoRatio": "영상 비율",
|
||||||
"minImages": "최소 5장",
|
"minImages": "최소 5장",
|
||||||
"youtubeShorts": "유튜브 쇼츠",
|
"youtubeShorts": "유튜브 쇼츠",
|
||||||
|
|
@ -234,6 +234,7 @@
|
||||||
"lyricsColumn": "가사",
|
"lyricsColumn": "가사",
|
||||||
"lyricsHint": "가사 영역을 선택해서 수정 가능해요",
|
"lyricsHint": "가사 영역을 선택해서 수정 가능해요",
|
||||||
"lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.",
|
"lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.",
|
||||||
|
"lyricsPlaceholderBGM": "배경음악은 가사 없이 생성됩니다.",
|
||||||
"generateButton": "사운드 생성",
|
"generateButton": "사운드 생성",
|
||||||
"regenerateButton": "재생성 하기",
|
"regenerateButton": "재생성 하기",
|
||||||
"regenerateHint": "재생성 버튼을 누르면 가사와 음악을 다시 만들 수 있어요.",
|
"regenerateHint": "재생성 버튼을 누르면 가사와 음악을 다시 만들 수 있어요.",
|
||||||
|
|
@ -306,6 +307,7 @@
|
||||||
"resolution": "규격",
|
"resolution": "규격",
|
||||||
"lyrics": "가사",
|
"lyrics": "가사",
|
||||||
"sampleLyrics": "가사를 불러오는 중...",
|
"sampleLyrics": "가사를 불러오는 중...",
|
||||||
|
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
|
||||||
"downloading": "다운로드 중...",
|
"downloading": "다운로드 중...",
|
||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"uploadToSocial": "소셜 채널 업로드"
|
"uploadToSocial": "소셜 채널 업로드"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,87 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getVideosList, deleteVideo } from '../../utils/api';
|
import { getVideosList, deleteVideo } from '../../utils/api';
|
||||||
import { VideoListItem } from '../../types/api';
|
import { VideoListItem } from '../../types/api';
|
||||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||||
|
|
||||||
|
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const isPlayingRef = useRef(false);
|
||||||
|
const pendingPlayRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
video.preload = 'auto';
|
||||||
|
} else {
|
||||||
|
video.preload = 'none';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(video);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const safePlay = useCallback((video: HTMLVideoElement) => {
|
||||||
|
const playPromise = video.play();
|
||||||
|
if (playPromise !== undefined) {
|
||||||
|
playPromise
|
||||||
|
.then(() => { isPlayingRef.current = true; })
|
||||||
|
.catch((error) => {
|
||||||
|
if (error.name !== 'AbortError') console.error(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCanPlay = useCallback(() => {
|
||||||
|
if (!pendingPlayRef.current || !videoRef.current) return;
|
||||||
|
pendingPlayRef.current = false;
|
||||||
|
safePlay(videoRef.current);
|
||||||
|
}, [safePlay]);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (video.readyState >= 3) {
|
||||||
|
safePlay(video);
|
||||||
|
} else {
|
||||||
|
pendingPlayRef.current = true;
|
||||||
|
}
|
||||||
|
}, [safePlay]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
pendingPlayRef.current = false;
|
||||||
|
if (isPlayingRef.current) {
|
||||||
|
video.pause();
|
||||||
|
video.currentTime = 0;
|
||||||
|
isPlayingRef.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={src}
|
||||||
|
className={className}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="none"
|
||||||
|
onCanPlay={handleCanPlay}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface ADO2ContentsPageProps {
|
interface ADO2ContentsPageProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onNavigate?: (item: string) => void;
|
onNavigate?: (item: string) => void;
|
||||||
|
|
@ -166,16 +243,9 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack, onNavigate
|
||||||
{/* Video Thumbnail */}
|
{/* Video Thumbnail */}
|
||||||
<div className="content-card-thumbnail">
|
<div className="content-card-thumbnail">
|
||||||
{video.result_movie_url ? (
|
{video.result_movie_url ? (
|
||||||
<video
|
<VideoPreviewCard
|
||||||
src={video.result_movie_url}
|
src={video.result_movie_url}
|
||||||
className="content-video-preview"
|
className="content-video-preview"
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
onMouseEnter={(e) => e.currentTarget.play()}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.pause();
|
|
||||||
e.currentTarget.currentTime = 0;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="content-no-video">
|
<div className="content-no-video">
|
||||||
|
|
|
||||||
|
|
@ -484,7 +484,9 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
<div className="comp2-lyrics-section">
|
<div className="comp2-lyrics-section">
|
||||||
<span className="comp2-meta-label">{t('completion.lyrics')}</span>
|
<span className="comp2-meta-label">{t('completion.lyrics')}</span>
|
||||||
<p className="comp2-lyrics-text">
|
<p className="comp2-lyrics-text">
|
||||||
{songCompletionData?.lyrics || t('completion.sampleLyrics')}
|
{songCompletionData
|
||||||
|
? (songCompletionData.lyrics || t('completion.noLyricsBGM'))
|
||||||
|
: t('completion.sampleLyrics')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -553,7 +553,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
<PlatformIcon platform={item.platform} size={16} />
|
<PlatformIcon platform={item.platform} size={16} />
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
|
||||||
color: '#9bcacc', lineHeight: 1,
|
color: '#9bcacc', lineHeight: 1.4,
|
||||||
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||||
}}>
|
}}>
|
||||||
{item.platform_username || item.platform_user_id || item.channel_name}
|
{item.platform_username || item.platform_user_id || item.channel_name}
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||||
|
const isInstrumental = selectedType === '배경음악';
|
||||||
|
|
||||||
// 1. 가사 생성 요청
|
// 1. 가사 생성 요청
|
||||||
console.log('[SoundStudio] Sending m_id:', mId);
|
console.log('[SoundStudio] Sending m_id:', mId);
|
||||||
|
|
@ -287,12 +288,27 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
region: businessInfo.region,
|
region: businessInfo.region,
|
||||||
task_id: imageTaskId,
|
task_id: imageTaskId,
|
||||||
orientation,
|
orientation,
|
||||||
|
...(isInstrumental && { instrumental: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!lyricResponse.success || !lyricResponse.task_id) {
|
if (!lyricResponse.success || !lyricResponse.task_id) {
|
||||||
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
|
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const genreMap: Record<string, string> = {
|
||||||
|
'자동 선택': 'pop',
|
||||||
|
'K-POP': 'kpop',
|
||||||
|
'발라드': 'ballad',
|
||||||
|
'Hip-Hop': 'hip-hop',
|
||||||
|
'R&B': 'rnb',
|
||||||
|
'EDM': 'edm',
|
||||||
|
'JAZZ': 'jazz',
|
||||||
|
'ROCK': 'rock',
|
||||||
|
};
|
||||||
|
|
||||||
|
let songLyrics: string | undefined;
|
||||||
|
|
||||||
|
if (!isInstrumental) {
|
||||||
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
||||||
setStatusMessage(t('soundStudio.generatingLyrics'));
|
setStatusMessage(t('soundStudio.generatingLyrics'));
|
||||||
const lyricDetailResponse = await waitForLyricComplete(
|
const lyricDetailResponse = await waitForLyricComplete(
|
||||||
|
|
@ -314,25 +330,15 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
setLyrics(lyricDetailResponse.lyric_result);
|
setLyrics(lyricDetailResponse.lyric_result);
|
||||||
|
songLyrics = lyricDetailResponse.lyric_result;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus('generating_song');
|
setStatus('generating_song');
|
||||||
setStatusMessage(t('soundStudio.generatingSong'));
|
setStatusMessage(t('soundStudio.generatingSong'));
|
||||||
|
|
||||||
const genreMap: Record<string, string> = {
|
|
||||||
'자동 선택': 'pop',
|
|
||||||
'K-POP': 'kpop',
|
|
||||||
'발라드': 'ballad',
|
|
||||||
'Hip-Hop': 'hip-hop',
|
|
||||||
'R&B': 'rnb',
|
|
||||||
'EDM': 'edm',
|
|
||||||
'JAZZ': 'jazz',
|
|
||||||
'ROCK': 'rock',
|
|
||||||
};
|
|
||||||
|
|
||||||
const songResponse = await generateSong(imageTaskId, {
|
const songResponse = await generateSong(imageTaskId, {
|
||||||
genre: genreMap[selectedGenre] || 'pop',
|
genre: genreMap[selectedGenre] || 'pop',
|
||||||
language,
|
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
|
||||||
lyrics: lyricDetailResponse.lyric_result,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!songResponse.success) {
|
if (!songResponse.success) {
|
||||||
|
|
@ -351,7 +357,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
setStatus('polling');
|
setStatus('polling');
|
||||||
setStatusMessage(t('soundStudio.generatingSong'));
|
setStatusMessage(t('soundStudio.generatingSong'));
|
||||||
|
|
||||||
await resumePolling(songResponse.task_id, songResponse.song_id, lyricDetailResponse.lyric_result, 0);
|
await resumePolling(songResponse.task_id, songResponse.song_id, songLyrics ?? '', 0);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Music generation failed:', error);
|
console.error('Music generation failed:', error);
|
||||||
|
|
@ -412,7 +418,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
<div className="sound-type-grid">
|
<div className="sound-type-grid">
|
||||||
{[
|
{[
|
||||||
{ key: '보컬', label: t('soundStudio.soundTypeVocal'), disabled: false },
|
{ key: '보컬', label: t('soundStudio.soundTypeVocal'), disabled: false },
|
||||||
{ key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: true },
|
{ key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: false },
|
||||||
].map(({ key, label, disabled }) => (
|
].map(({ key, label, disabled }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
|
|
@ -483,8 +489,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
{['한국어', 'English', '中文'].map((lang) => (
|
{['한국어', 'English', '中文'].map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={lang}
|
key={lang}
|
||||||
onClick={() => !isGenerating && setSelectedLang(lang)}
|
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating || selectedType === '배경음악'}
|
||||||
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
||||||
|
|
@ -495,8 +501,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
|
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={lang}
|
key={lang}
|
||||||
onClick={() => !isGenerating && setSelectedLang(lang)}
|
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating || selectedType === '배경음악'}
|
||||||
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
||||||
|
|
@ -598,16 +604,28 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
|
|
||||||
{/* Lyrics Display */}
|
{/* Lyrics Display */}
|
||||||
<div className="lyrics-display">
|
<div className="lyrics-display">
|
||||||
{lyrics ? (
|
{lyrics ? (() => {
|
||||||
<textarea
|
const lines = lyrics.split('\n').filter(l => l.trim());
|
||||||
value={lyrics}
|
const size = Math.ceil(lines.length / 3);
|
||||||
readOnly
|
const outroLines = lines.slice(size * 2).slice(0, 2);
|
||||||
className="lyrics-textarea"
|
const sections = [
|
||||||
placeholder={t('soundStudio.lyricsPlaceholder')}
|
{ tag: '[Verse]', lines: lines.slice(0, size) },
|
||||||
/>
|
{ tag: '[Chorus]', lines: lines.slice(size, size * 2) },
|
||||||
) : (
|
{ tag: '[Outro]', lines: outroLines },
|
||||||
|
].filter(s => s.lines.length > 0);
|
||||||
|
return (
|
||||||
|
<div className="lyrics-paragraphs">
|
||||||
|
{sections.map((section, i) => (
|
||||||
|
<div key={i} className="lyrics-section">
|
||||||
|
<span className="lyrics-tag">{section.tag}</span>
|
||||||
|
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : (
|
||||||
<div className="lyrics-placeholder">
|
<div className="lyrics-placeholder">
|
||||||
{t('soundStudio.lyricsPlaceholder')}
|
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export interface LyricGenerateRequest {
|
||||||
region: string;
|
region: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
orientation?: 'vertical' | 'horizontal';
|
orientation?: 'vertical' | 'horizontal';
|
||||||
|
instrumental?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 가사 생성 응답
|
// 가사 생성 응답
|
||||||
|
|
@ -103,8 +104,9 @@ export interface LyricDetailResponse {
|
||||||
// 노래 생성 요청
|
// 노래 생성 요청
|
||||||
export interface SongGenerateRequest {
|
export interface SongGenerateRequest {
|
||||||
genre: string;
|
genre: string;
|
||||||
language: string;
|
language?: string;
|
||||||
lyrics: string;
|
lyrics?: string;
|
||||||
|
instrumental?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 노래 생성 응답
|
// 노래 생성 응답
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,8 @@ export async function generateSong(taskId: string, request: SongGenerateRequest)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.json().catch(() => null);
|
||||||
|
console.error('[generateSong] 422 detail:', JSON.stringify(errorBody));
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,14 +479,20 @@ export async function authenticatedFetch(
|
||||||
|
|
||||||
let response = await fetch(url, { ...options, headers });
|
let response = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
// 401 에러 시 토큰 갱신 시도
|
// 401 에러 시 에러 코드에 따라 처리
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
const errorBody = await response.json().catch(() => null);
|
||||||
|
const errorCode = errorBody?.detail?.code;
|
||||||
|
|
||||||
|
if (errorCode !== 'TOKEN_EXPIRED') {
|
||||||
|
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
|
||||||
|
redirectToLogin();
|
||||||
|
throw new Error(errorCode ?? 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 이미 갱신 중이면 기존 Promise 재사용 (중복 요청 방지)
|
|
||||||
// refreshPromise가 존재하면 재사용, 없으면 새로 생성
|
|
||||||
if (!refreshPromise) {
|
if (!refreshPromise) {
|
||||||
refreshPromise = refreshAccessToken().finally(() => {
|
refreshPromise = refreshAccessToken().finally(() => {
|
||||||
// 성공/실패 상관없이 Promise 초기화 (다음 갱신을 위해)
|
|
||||||
refreshPromise = null;
|
refreshPromise = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -501,7 +509,6 @@ export async function authenticatedFetch(
|
||||||
response = await fetch(url, { ...options, headers: newHeaders });
|
response = await fetch(url, { ...options, headers: newHeaders });
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
console.error('Token refresh failed:', refreshError);
|
console.error('Token refresh failed:', refreshError);
|
||||||
// 토큰 갱신 실패 시 로그인 페이지로 리다이렉트
|
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
throw refreshError;
|
throw refreshError;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue