From 2f720dfa9273d2abbfe1880844fb44204be25ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Fri, 3 Jul 2026 11:49:16 +0900 Subject: [PATCH] =?UTF-8?q?=ED=94=84=EB=A1=9C=EA=B7=B8=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=B0=94=20=EC=88=98=EC=A0=95,=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EB=B6=84=EB=A5=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SearchInputForm.tsx | 7 +-- src/locales/en.json | 2 +- src/locales/ko.json | 2 +- src/pages/Analysis/LoadingSection.tsx | 2 +- .../Dashboard/AssetManagementContent.tsx | 11 +++- src/pages/Dashboard/CompletionContent.tsx | 23 ++++---- src/pages/Dashboard/GenerationFlow.tsx | 10 ++-- src/pages/Dashboard/SoundStudioContent.tsx | 52 ++++++++++++++----- src/styles/contents-social.css | 2 +- src/styles/studio-assets.css | 8 +-- src/types/api.ts | 12 ++++- src/utils/api.ts | 10 ++++ 12 files changed, 98 insertions(+), 43 deletions(-) diff --git a/src/components/SearchInputForm.tsx b/src/components/SearchInputForm.tsx index 7c07e6c..15ccc59 100644 --- a/src/components/SearchInputForm.tsx +++ b/src/components/SearchInputForm.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../utils/api'; +import { searchAccommodation, cleanSearchTitle, AccommodationSearchItem, AutocompleteRequest } from '../utils/api'; import { useSearchHistory } from './SearchHistory/useSearchHistory'; import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown'; import BusinessNameInputModal from './BusinessNameInputModal'; @@ -80,8 +80,9 @@ const SearchInputForm: React.FC = ({ }, []); const handleSelectAutocomplete = (item: AccommodationSearchItem) => { - setInputValue(item.title.replace(/<[^>]*>/g, '')); - setSelectedItem(item); + const cleanTitle = cleanSearchTitle(item.title); + setInputValue(cleanTitle); + setSelectedItem({ ...item, title: cleanTitle }); setShowAutocomplete(false); setAutocompleteResults([]); setHighlightedIndex(-1); diff --git a/src/locales/en.json b/src/locales/en.json index 23cf1cb..eafe413 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -265,7 +265,7 @@ "back": "Go Back", "loadMore": "Load more", "uploadFailed": "Image upload failed.", - "uploading": "Uploading... (30 sec – 2 min)", + "uploading": "Uploading... (30 sec – 1 min)", "nextStep": "Next Step" }, "soundStudio": { diff --git a/src/locales/ko.json b/src/locales/ko.json index 10bdf02..301322c 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -264,7 +264,7 @@ "back": "뒤로가기", "loadMore": "더보기", "uploadFailed": "이미지 업로드에 실패했습니다.", - "uploading": "업로드 중 (30초~2분 소요)", + "uploading": "업로드 중 (30~60초 소요)", "nextStep": "다음 단계" }, "soundStudio": { diff --git a/src/pages/Analysis/LoadingSection.tsx b/src/pages/Analysis/LoadingSection.tsx index 013b903..f51e405 100755 --- a/src/pages/Analysis/LoadingSection.tsx +++ b/src/pages/Analysis/LoadingSection.tsx @@ -22,7 +22,7 @@ const LoadingSection: React.FC = ({ onComplete, isComplete const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1; return Math.min(prev + increment, 100); }); - }, 100); + }, 150); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; diff --git a/src/pages/Dashboard/AssetManagementContent.tsx b/src/pages/Dashboard/AssetManagementContent.tsx index a1db487..50e57a4 100755 --- a/src/pages/Dashboard/AssetManagementContent.tsx +++ b/src/pages/Dashboard/AssetManagementContent.tsx @@ -44,7 +44,7 @@ const AssetManagementContent: React.FC = ({ }; const getImageSrc = (item: ImageItem): string => { - return item.type === 'url' ? item.url : item.preview; + return item.type === 'url' ? item.preview_url : item.preview; }; const handleNextWithUpload = async () => { @@ -59,7 +59,7 @@ const AssetManagementContent: React.FC = ({ if (prev >= 99) return prev; return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11); }); - }, 300); + }, 200); try { const urlImages: ImageUrlItem[] = imageList @@ -74,6 +74,12 @@ const AssetManagementContent: React.FC = ({ const response = await uploadImages(urlImages, fileImages); clearInterval(interval); setUploadProgress(100); + // 이미지 변경 시 이전 음악/영상 생성 결과 초기화 — 반드시 재생성하도록 강제 + localStorage.removeItem('castad_song_generation'); + localStorage.removeItem('castad_song_completion'); + localStorage.removeItem('castad_song_task_id'); + localStorage.removeItem('castad_video_generation'); + localStorage.removeItem('castad_video_complete'); onNext(response.task_id); } } catch (error) { @@ -181,6 +187,7 @@ const AssetManagementContent: React.FC = ({ {`${t('assetManagement.imageAlt')} {item.type === 'file' && (
{t('assetManagement.uploadBadge')}
diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx index 09da515..5a082d8 100755 --- a/src/pages/Dashboard/CompletionContent.tsx +++ b/src/pages/Dashboard/CompletionContent.tsx @@ -500,19 +500,22 @@ const CompletionContent: React.FC = ({ return

{t('completion.noLyricsBGM')}

; } const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim()); - const outro = lines.slice(-1); - const body = lines.slice(0, -1); - const half = Math.ceil(body.length / 2); - const sections = [ - { tag: '[Verse]', lines: body.slice(0, half) }, - { tag: '[Chorus]', lines: body.slice(half) }, - { tag: '[Outro]', lines: outro }, - ].filter(s => s.lines.length > 0); + const sections: { tag: string | null; lines: string[] }[] = []; + lines.forEach((line: string) => { + const tagMatch = line.trim().match(/^\[(.+)\]$/); + if (tagMatch) { + sections.push({ tag: `[${tagMatch[1]}]`, lines: [] }); + } else if (sections.length === 0) { + sections.push({ tag: null, lines: [line] }); + } else { + sections[sections.length - 1].lines.push(line); + } + }); return (
- {sections.map((section, i) => ( + {sections.filter(s => s.lines.length > 0).map((section, i) => (
- {section.tag} + {section.tag && {section.tag}}

{section.lines.join('\n')}

))} diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 25a3d0d..78025ae 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -14,7 +14,7 @@ import MyInfoContent from './MyInfoContent'; import ContentCalendarContent from './ContentCalendarContent'; import LoadingSection from '../Analysis/LoadingSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection'; -import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api'; +import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api'; import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api'; import { useTutorial } from '../../components/Tutorial/useTutorial'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; @@ -52,7 +52,7 @@ interface BusinessInfo { interface GenerationFlowProps { onHome: () => void; initialActiveItem?: string; - initialImageList?: string[]; + initialImageList?: ImageListItem[]; businessInfo?: BusinessInfo; initialAnalysisData?: CrawlingResponse | null; } @@ -169,9 +169,9 @@ const GenerationFlow: React.FC = ({ // URL 이미지를 ImageItem 형태로 변환하여 초기화 const getInitialImageList = (): ImageItem[] => { if (analysisData?.image_list && analysisData.image_list.length > 0) { - return analysisData.image_list.map(url => ({ type: 'url', url })); + return analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview })); } - return initialImageList.map(url => ({ type: 'url', url })); + return initialImageList.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview })); }; const [imageList, setImageList] = useState(getInitialImageList()); @@ -183,7 +183,7 @@ const GenerationFlow: React.FC = ({ console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id); if (analysisData?.image_list && analysisData.image_list.length > 0) { if (prevAnalysisMIdRef.current !== analysisData.m_id) { - setImageList(analysisData.image_list.map(url => ({ type: 'url', url }))); + setImageList(analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }))); } } prevAnalysisMIdRef.current = analysisData?.m_id; diff --git a/src/pages/Dashboard/SoundStudioContent.tsx b/src/pages/Dashboard/SoundStudioContent.tsx index 05acaff..fb67fca 100755 --- a/src/pages/Dashboard/SoundStudioContent.tsx +++ b/src/pages/Dashboard/SoundStudioContent.tsx @@ -52,6 +52,21 @@ const getEffectiveGenre = (selectedGenre: string): string => { return genreMap[selectedGenre] || 'pop'; }; +// 실제 영상 생성에 사용된 genre 값(kpop, hip-hop 등)을 화면 표시용 라벨로 변환 +const getGenreDisplayLabel = (genreValue: string, t: (key: string) => string): string => { + const displayMap: Record = { + kpop: 'K-POP', + pop: 'POP', + ballad: t('soundStudio.genreBallad'), + 'hip-hop': 'Hip-Hop', + rnb: 'R&B', + edm: 'EDM', + jazz: 'JAZZ', + rock: 'ROCK', + }; + return displayMap[genreValue] || genreValue; +}; + const sanitizeError = (message: string, fallback: string): string => { if ( message.includes('Traceback') || @@ -110,7 +125,8 @@ const SoundStudioContent: React.FC = ({ const saveSongCompletionData = () => { const completionData = { businessName: businessInfo?.customer_name || '알 수 없음', - genre: selectedGenre, + // '자동 선택'처럼 UI에서 고른 값이 아니라, 실제 영상 생성에 사용된 genre(usedGenre)를 저장 + genre: usedGenre ? getGenreDisplayLabel(usedGenre, t) : selectedGenre, lyrics: lyrics, timestamp: Date.now(), }; @@ -367,6 +383,9 @@ const SoundStudioContent: React.FC = ({ try { const language = LANGUAGE_MAP[selectedLang] || 'Korean'; const isInstrumental = selectedType === '배경음악'; + // 수동 선택한 장르는 가사 생성 시점부터 넘겨서, 그 장르 템포에 맞는 줄 수로 가사를 쓰게 한다. + // '자동 선택'이면 넘기지 않고, 가사 생성 결과로 받은 recommended genre를 그대로 쓴다. + const manualGenre = selectedGenre === '자동 선택' ? undefined : genreMap[selectedGenre]; // 1. 가사 생성 요청 console.log('[SoundStudio] Sending m_id:', mId); @@ -380,6 +399,7 @@ const SoundStudioContent: React.FC = ({ industry, orientation, ...(isInstrumental && { instrumental: true }), + ...(manualGenre && { genre: manualGenre }), }); if (!lyricResponse.success || !lyricResponse.task_id) { @@ -387,6 +407,9 @@ const SoundStudioContent: React.FC = ({ } let songLyrics: string | undefined; + // BGM 모드는 백엔드가 가사/장르 추천 없이 즉시 completed 처리하므로 GPT 추천 장르가 없다. + // 이 경우에만 기존 랜덤 선택 로직으로 폴백한다. + let effectiveGenre = manualGenre || getEffectiveGenre(selectedGenre); if (!isInstrumental) { // 2. 가사 생성 상태 폴링 → 완료 시 상세 조회 @@ -411,12 +434,12 @@ const SoundStudioContent: React.FC = ({ setLyrics(lyricDetailResponse.lyric_result); songLyrics = lyricDetailResponse.lyric_result; + // 가사와 함께 확정된 장르(수동 선택값 또는 GPT 추천값)를 그대로 음악 생성에 사용 + effectiveGenre = lyricDetailResponse.genre || effectiveGenre; } setStatus('generating_song'); setStatusMessage(t('soundStudio.generatingSong')); - - const effectiveGenre = getEffectiveGenre(selectedGenre); setUsedGenre(effectiveGenre); const songResponse = await generateSong(imageTaskId, { @@ -698,19 +721,22 @@ const SoundStudioContent: React.FC = ({
{lyrics ? (() => { const lines = lyrics.split('\n').filter(l => l.trim()); - const outro = lines.slice(-1); - const body = lines.slice(0, -1); - const half = Math.ceil(body.length / 2); - const sections = [ - { tag: '[Verse]', lines: body.slice(0, half) }, - { tag: '[Chorus]', lines: body.slice(half) }, - { tag: '[Outro]', lines: outro }, - ].filter(s => s.lines.length > 0); + const sections: { tag: string | null; lines: string[] }[] = []; + lines.forEach(line => { + const tagMatch = line.trim().match(/^\[(.+)\]$/); + if (tagMatch) { + sections.push({ tag: `[${tagMatch[1]}]`, lines: [] }); + } else if (sections.length === 0) { + sections.push({ tag: null, lines: [line] }); + } else { + sections[sections.length - 1].lines.push(line); + } + }); return (
- {sections.map((section, i) => ( + {sections.filter(s => s.lines.length > 0).map((section, i) => (
- {section.tag} + {section.tag && {section.tag}}

{section.lines.join('\n')}

))} diff --git a/src/styles/contents-social.css b/src/styles/contents-social.css index a35ab5b..e9cec4a 100644 --- a/src/styles/contents-social.css +++ b/src/styles/contents-social.css @@ -1419,7 +1419,7 @@ flex: 1; padding: 2rem; max-width: 900px; - background-color: #002224; + background-color: var(--color-bg-darker); } .myinfo-title { diff --git a/src/styles/studio-assets.css b/src/styles/studio-assets.css index 3f8e91f..809ffdd 100644 --- a/src/styles/studio-assets.css +++ b/src/styles/studio-assets.css @@ -547,7 +547,7 @@ .lyrics-paragraphs { width: 100%; overflow-y: auto; - padding: 1rem; + padding: 0 1rem; display: flex; flex-direction: column; gap: 1.25rem; @@ -574,14 +574,14 @@ } .lyrics-tag { - font-size: var(--text-xs); + font-size: 14px; font-weight: 700; color: #379599; letter-spacing: 0.05em; } .lyrics-paragraph { - font-size: var(--text-sm); + font-size: 14px; font-weight: 400; color: #CEE5E6; line-height: 1.9; @@ -594,7 +594,7 @@ display: flex; align-items: center; justify-content: center; - font-size: var(--text-sm); + font-size: 14px; font-weight: 400; color: #6AB0B3; text-align: center; diff --git a/src/types/api.ts b/src/types/api.ts index 3f8d653..6825d5c 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -32,8 +32,13 @@ export interface MarketingAnalysis { target_keywords: string[]; } +export interface ImageListItem { + preview: string; // 미리보기용 CDN 리사이즈 URL + original: string; // 업로드용 원본 URL +} + export interface CrawlingResponse { - image_list: string[]; + image_list: ImageListItem[] | null; image_count: number; m_id: number; industry: string; @@ -49,7 +54,8 @@ export interface CrawlingResponse { // URL 이미지 (서버에서 가져온 이미지) export interface UrlImage { type: 'url'; - url: string; + url: string; // 업로드용 원본 URL + preview_url: string; // 미리보기용 CDN 리사이즈 URL } // 업로드된 파일 이미지 @@ -72,6 +78,7 @@ export interface LyricGenerateRequest { industry?: string; orientation?: 'vertical' | 'horizontal'; instrumental?: boolean; + genre?: string; // 사용자가 수동으로 고른 장르. '자동 선택'이면 생략 (GPT가 추천) } // 가사 생성 응답 @@ -101,6 +108,7 @@ export interface LyricDetailResponse { status: string; lyric_prompt: string; lyric_result: string; + genre?: string | null; // 확정된 장르 (수동 선택값 또는 GPT 추천값) created_at: string; } diff --git a/src/utils/api.ts b/src/utils/api.ts index 76ec24f..415b412 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -844,6 +844,16 @@ export interface AccommodationSearchItem { title: string; } +// 네이버 검색 API는 title에 태그와 & 같은 HTML 엔티티를 함께 내려주므로 +// 태그 제거 후 엔티티까지 디코딩해야 "&" 같은 원문이 그대로 노출되지 않는다. +// textarea는 DOM에 붙이지 않고 content model상 스크립트를 실행하지 않으므로 엔티티 디코딩 용도로 안전하다. +export const cleanSearchTitle = (title: string): string => { + const withoutTags = title.replace(/<[^>]*>/g, ''); + const textarea = document.createElement('textarea'); + textarea.innerHTML = withoutTags; + return textarea.value; +}; + export interface AccommodationSearchResponse { count: number; items: AccommodationSearchItem[];