프로그래스바 수정, 이미지 분류 추가 및 UI 수정

main
김성경 2026-07-03 11:49:16 +09:00
parent 8a39a34630
commit 2f720dfa92
12 changed files with 98 additions and 43 deletions

View File

@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; 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 { useSearchHistory } from './SearchHistory/useSearchHistory';
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown'; import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from './BusinessNameInputModal'; import BusinessNameInputModal from './BusinessNameInputModal';
@ -80,8 +80,9 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
}, []); }, []);
const handleSelectAutocomplete = (item: AccommodationSearchItem) => { const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
setInputValue(item.title.replace(/<[^>]*>/g, '')); const cleanTitle = cleanSearchTitle(item.title);
setSelectedItem(item); setInputValue(cleanTitle);
setSelectedItem({ ...item, title: cleanTitle });
setShowAutocomplete(false); setShowAutocomplete(false);
setAutocompleteResults([]); setAutocompleteResults([]);
setHighlightedIndex(-1); setHighlightedIndex(-1);

View File

@ -265,7 +265,7 @@
"back": "Go Back", "back": "Go Back",
"loadMore": "Load more", "loadMore": "Load more",
"uploadFailed": "Image upload failed.", "uploadFailed": "Image upload failed.",
"uploading": "Uploading... (30 sec 2 min)", "uploading": "Uploading... (30 sec 1 min)",
"nextStep": "Next Step" "nextStep": "Next Step"
}, },
"soundStudio": { "soundStudio": {

View File

@ -264,7 +264,7 @@
"back": "뒤로가기", "back": "뒤로가기",
"loadMore": "더보기", "loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.", "uploadFailed": "이미지 업로드에 실패했습니다.",
"uploading": "업로드 중 (30초~2분 소요)", "uploading": "업로드 중 (30~60초 소요)",
"nextStep": "다음 단계" "nextStep": "다음 단계"
}, },
"soundStudio": { "soundStudio": {

View File

@ -22,7 +22,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1; const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
return Math.min(prev + increment, 100); return Math.min(prev + increment, 100);
}); });
}, 100); }, 150);
return () => { return () => {
if (intervalRef.current) clearInterval(intervalRef.current); if (intervalRef.current) clearInterval(intervalRef.current);
}; };

View File

@ -44,7 +44,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}; };
const getImageSrc = (item: ImageItem): string => { 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 () => { const handleNextWithUpload = async () => {
@ -59,7 +59,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
if (prev >= 99) return prev; if (prev >= 99) return prev;
return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11); return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11);
}); });
}, 300); }, 200);
try { try {
const urlImages: ImageUrlItem[] = imageList const urlImages: ImageUrlItem[] = imageList
@ -74,6 +74,12 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const response = await uploadImages(urlImages, fileImages); const response = await uploadImages(urlImages, fileImages);
clearInterval(interval); clearInterval(interval);
setUploadProgress(100); 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); onNext(response.task_id);
} }
} catch (error) { } catch (error) {
@ -181,6 +187,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
<img <img
src={getImageSrc(item)} src={getImageSrc(item)}
alt={`${t('assetManagement.imageAlt')} ${i + 1}`} alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
referrerPolicy="no-referrer"
/> />
{item.type === 'file' && ( {item.type === 'file' && (
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div> <div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>

View File

@ -500,19 +500,22 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>; return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
} }
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim()); const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
const outro = lines.slice(-1); const sections: { tag: string | null; lines: string[] }[] = [];
const body = lines.slice(0, -1); lines.forEach((line: string) => {
const half = Math.ceil(body.length / 2); const tagMatch = line.trim().match(/^\[(.+)\]$/);
const sections = [ if (tagMatch) {
{ tag: '[Verse]', lines: body.slice(0, half) }, sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
{ tag: '[Chorus]', lines: body.slice(half) }, } else if (sections.length === 0) {
{ tag: '[Outro]', lines: outro }, sections.push({ tag: null, lines: [line] });
].filter(s => s.lines.length > 0); } else {
sections[sections.length - 1].lines.push(line);
}
});
return ( return (
<div className="comp2-lyrics-paragraphs"> <div className="comp2-lyrics-paragraphs">
{sections.map((section, i) => ( {sections.filter(s => s.lines.length > 0).map((section, i) => (
<div key={i} className="comp2-lyrics-para-section"> <div key={i} className="comp2-lyrics-para-section">
<span className="comp2-lyrics-tag">{section.tag}</span> {section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p> <p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
</div> </div>
))} ))}

View File

@ -14,7 +14,7 @@ import MyInfoContent from './MyInfoContent';
import ContentCalendarContent from './ContentCalendarContent'; import ContentCalendarContent from './ContentCalendarContent';
import LoadingSection from '../Analysis/LoadingSection'; import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection'; 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 { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial'; import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
@ -52,7 +52,7 @@ interface BusinessInfo {
interface GenerationFlowProps { interface GenerationFlowProps {
onHome: () => void; onHome: () => void;
initialActiveItem?: string; initialActiveItem?: string;
initialImageList?: string[]; initialImageList?: ImageListItem[];
businessInfo?: BusinessInfo; businessInfo?: BusinessInfo;
initialAnalysisData?: CrawlingResponse | null; initialAnalysisData?: CrawlingResponse | null;
} }
@ -169,9 +169,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// URL 이미지를 ImageItem 형태로 변환하여 초기화 // URL 이미지를 ImageItem 형태로 변환하여 초기화
const getInitialImageList = (): ImageItem[] => { const getInitialImageList = (): ImageItem[] => {
if (analysisData?.image_list && analysisData.image_list.length > 0) { 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<ImageItem[]>(getInitialImageList()); const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
@ -183,7 +183,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id); console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
if (analysisData?.image_list && analysisData.image_list.length > 0) { if (analysisData?.image_list && analysisData.image_list.length > 0) {
if (prevAnalysisMIdRef.current !== analysisData.m_id) { 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; prevAnalysisMIdRef.current = analysisData?.m_id;

View File

@ -52,6 +52,21 @@ const getEffectiveGenre = (selectedGenre: string): string => {
return genreMap[selectedGenre] || 'pop'; return genreMap[selectedGenre] || 'pop';
}; };
// 실제 영상 생성에 사용된 genre 값(kpop, hip-hop 등)을 화면 표시용 라벨로 변환
const getGenreDisplayLabel = (genreValue: string, t: (key: string) => string): string => {
const displayMap: Record<string, string> = {
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 => { const sanitizeError = (message: string, fallback: string): string => {
if ( if (
message.includes('Traceback') || message.includes('Traceback') ||
@ -110,7 +125,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const saveSongCompletionData = () => { const saveSongCompletionData = () => {
const completionData = { const completionData = {
businessName: businessInfo?.customer_name || '알 수 없음', businessName: businessInfo?.customer_name || '알 수 없음',
genre: selectedGenre, // '자동 선택'처럼 UI에서 고른 값이 아니라, 실제 영상 생성에 사용된 genre(usedGenre)를 저장
genre: usedGenre ? getGenreDisplayLabel(usedGenre, t) : selectedGenre,
lyrics: lyrics, lyrics: lyrics,
timestamp: Date.now(), timestamp: Date.now(),
}; };
@ -367,6 +383,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
try { try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean'; const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const isInstrumental = selectedType === '배경음악'; const isInstrumental = selectedType === '배경음악';
// 수동 선택한 장르는 가사 생성 시점부터 넘겨서, 그 장르 템포에 맞는 줄 수로 가사를 쓰게 한다.
// '자동 선택'이면 넘기지 않고, 가사 생성 결과로 받은 recommended genre를 그대로 쓴다.
const manualGenre = selectedGenre === '자동 선택' ? undefined : genreMap[selectedGenre];
// 1. 가사 생성 요청 // 1. 가사 생성 요청
console.log('[SoundStudio] Sending m_id:', mId); console.log('[SoundStudio] Sending m_id:', mId);
@ -380,6 +399,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
industry, industry,
orientation, orientation,
...(isInstrumental && { instrumental: true }), ...(isInstrumental && { instrumental: true }),
...(manualGenre && { genre: manualGenre }),
}); });
if (!lyricResponse.success || !lyricResponse.task_id) { if (!lyricResponse.success || !lyricResponse.task_id) {
@ -387,6 +407,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
} }
let songLyrics: string | undefined; let songLyrics: string | undefined;
// BGM 모드는 백엔드가 가사/장르 추천 없이 즉시 completed 처리하므로 GPT 추천 장르가 없다.
// 이 경우에만 기존 랜덤 선택 로직으로 폴백한다.
let effectiveGenre = manualGenre || getEffectiveGenre(selectedGenre);
if (!isInstrumental) { if (!isInstrumental) {
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회 // 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
@ -411,12 +434,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
setLyrics(lyricDetailResponse.lyric_result); setLyrics(lyricDetailResponse.lyric_result);
songLyrics = lyricDetailResponse.lyric_result; songLyrics = lyricDetailResponse.lyric_result;
// 가사와 함께 확정된 장르(수동 선택값 또는 GPT 추천값)를 그대로 음악 생성에 사용
effectiveGenre = lyricDetailResponse.genre || effectiveGenre;
} }
setStatus('generating_song'); setStatus('generating_song');
setStatusMessage(t('soundStudio.generatingSong')); setStatusMessage(t('soundStudio.generatingSong'));
const effectiveGenre = getEffectiveGenre(selectedGenre);
setUsedGenre(effectiveGenre); setUsedGenre(effectiveGenre);
const songResponse = await generateSong(imageTaskId, { const songResponse = await generateSong(imageTaskId, {
@ -698,19 +721,22 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<div className="lyrics-display"> <div className="lyrics-display">
{lyrics ? (() => { {lyrics ? (() => {
const lines = lyrics.split('\n').filter(l => l.trim()); const lines = lyrics.split('\n').filter(l => l.trim());
const outro = lines.slice(-1); const sections: { tag: string | null; lines: string[] }[] = [];
const body = lines.slice(0, -1); lines.forEach(line => {
const half = Math.ceil(body.length / 2); const tagMatch = line.trim().match(/^\[(.+)\]$/);
const sections = [ if (tagMatch) {
{ tag: '[Verse]', lines: body.slice(0, half) }, sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
{ tag: '[Chorus]', lines: body.slice(half) }, } else if (sections.length === 0) {
{ tag: '[Outro]', lines: outro }, sections.push({ tag: null, lines: [line] });
].filter(s => s.lines.length > 0); } else {
sections[sections.length - 1].lines.push(line);
}
});
return ( return (
<div className="lyrics-paragraphs"> <div className="lyrics-paragraphs">
{sections.map((section, i) => ( {sections.filter(s => s.lines.length > 0).map((section, i) => (
<div key={i} className="lyrics-section"> <div key={i} className="lyrics-section">
<span className="lyrics-tag">{section.tag}</span> {section.tag && <span className="lyrics-tag">{section.tag}</span>}
<p className="lyrics-paragraph">{section.lines.join('\n')}</p> <p className="lyrics-paragraph">{section.lines.join('\n')}</p>
</div> </div>
))} ))}

View File

@ -1419,7 +1419,7 @@
flex: 1; flex: 1;
padding: 2rem; padding: 2rem;
max-width: 900px; max-width: 900px;
background-color: #002224; background-color: var(--color-bg-darker);
} }
.myinfo-title { .myinfo-title {

View File

@ -547,7 +547,7 @@
.lyrics-paragraphs { .lyrics-paragraphs {
width: 100%; width: 100%;
overflow-y: auto; overflow-y: auto;
padding: 1rem; padding: 0 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: 1.25rem;
@ -574,14 +574,14 @@
} }
.lyrics-tag { .lyrics-tag {
font-size: var(--text-xs); font-size: 14px;
font-weight: 700; font-weight: 700;
color: #379599; color: #379599;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.lyrics-paragraph { .lyrics-paragraph {
font-size: var(--text-sm); font-size: 14px;
font-weight: 400; font-weight: 400;
color: #CEE5E6; color: #CEE5E6;
line-height: 1.9; line-height: 1.9;
@ -594,7 +594,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: var(--text-sm); font-size: 14px;
font-weight: 400; font-weight: 400;
color: #6AB0B3; color: #6AB0B3;
text-align: center; text-align: center;

View File

@ -32,8 +32,13 @@ export interface MarketingAnalysis {
target_keywords: string[]; target_keywords: string[];
} }
export interface ImageListItem {
preview: string; // 미리보기용 CDN 리사이즈 URL
original: string; // 업로드용 원본 URL
}
export interface CrawlingResponse { export interface CrawlingResponse {
image_list: string[]; image_list: ImageListItem[] | null;
image_count: number; image_count: number;
m_id: number; m_id: number;
industry: string; industry: string;
@ -49,7 +54,8 @@ export interface CrawlingResponse {
// URL 이미지 (서버에서 가져온 이미지) // URL 이미지 (서버에서 가져온 이미지)
export interface UrlImage { export interface UrlImage {
type: 'url'; type: 'url';
url: string; url: string; // 업로드용 원본 URL
preview_url: string; // 미리보기용 CDN 리사이즈 URL
} }
// 업로드된 파일 이미지 // 업로드된 파일 이미지
@ -72,6 +78,7 @@ export interface LyricGenerateRequest {
industry?: string; industry?: string;
orientation?: 'vertical' | 'horizontal'; orientation?: 'vertical' | 'horizontal';
instrumental?: boolean; instrumental?: boolean;
genre?: string; // 사용자가 수동으로 고른 장르. '자동 선택'이면 생략 (GPT가 추천)
} }
// 가사 생성 응답 // 가사 생성 응답
@ -101,6 +108,7 @@ export interface LyricDetailResponse {
status: string; status: string;
lyric_prompt: string; lyric_prompt: string;
lyric_result: string; lyric_result: string;
genre?: string | null; // 확정된 장르 (수동 선택값 또는 GPT 추천값)
created_at: string; created_at: string;
} }

View File

@ -844,6 +844,16 @@ export interface AccommodationSearchItem {
title: string; title: string;
} }
// 네이버 검색 API는 title에 <b> 태그와 &amp; 같은 HTML 엔티티를 함께 내려주므로
// 태그 제거 후 엔티티까지 디코딩해야 "&amp;" 같은 원문이 그대로 노출되지 않는다.
// 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 { export interface AccommodationSearchResponse {
count: number; count: number;
items: AccommodationSearchItem[]; items: AccommodationSearchItem[];