프로그래스바 수정, 이미지 분류 추가 및 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 { 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<SearchInputFormProps> = ({
}, []);
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);

View File

@ -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": {

View File

@ -264,7 +264,7 @@
"back": "뒤로가기",
"loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.",
"uploading": "업로드 중 (30초~2분 소요)",
"uploading": "업로드 중 (30~60초 소요)",
"nextStep": "다음 단계"
},
"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;
return Math.min(prev + increment, 100);
});
}, 100);
}, 150);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};

View File

@ -44,7 +44,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
};
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<AssetManagementContentProps> = ({
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<AssetManagementContentProps> = ({
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<AssetManagementContentProps> = ({
<img
src={getImageSrc(item)}
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
referrerPolicy="no-referrer"
/>
{item.type === 'file' && (
<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>;
}
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 (
<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">
<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>
</div>
))}

View File

@ -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<GenerationFlowProps> = ({
// 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<ImageItem[]>(getInitialImageList());
@ -183,7 +183,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
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;

View File

@ -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<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 => {
if (
message.includes('Traceback') ||
@ -110,7 +125,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
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<SoundStudioContentProps> = ({
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<SoundStudioContentProps> = ({
industry,
orientation,
...(isInstrumental && { instrumental: true }),
...(manualGenre && { genre: manualGenre }),
});
if (!lyricResponse.success || !lyricResponse.task_id) {
@ -387,6 +407,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
}
let songLyrics: string | undefined;
// BGM 모드는 백엔드가 가사/장르 추천 없이 즉시 completed 처리하므로 GPT 추천 장르가 없다.
// 이 경우에만 기존 랜덤 선택 로직으로 폴백한다.
let effectiveGenre = manualGenre || getEffectiveGenre(selectedGenre);
if (!isInstrumental) {
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
@ -411,12 +434,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
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<SoundStudioContentProps> = ({
<div className="lyrics-display">
{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 (
<div className="lyrics-paragraphs">
{sections.map((section, i) => (
{sections.filter(s => s.lines.length > 0).map((section, i) => (
<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>
</div>
))}

View File

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

View File

@ -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;

View File

@ -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;
}

View File

@ -844,6 +844,16 @@ export interface AccommodationSearchItem {
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 {
count: number;
items: AccommodationSearchItem[];