프로그래스바 수정, 이미지 분류 추가 및 UI 수정
parent
8a39a34630
commit
2f720dfa92
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@
|
|||
"back": "뒤로가기",
|
||||
"loadMore": "더보기",
|
||||
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
||||
"uploading": "업로드 중 (30초~2분 소요)",
|
||||
"uploading": "업로드 중 (30~60초 소요)",
|
||||
"nextStep": "다음 단계"
|
||||
},
|
||||
"soundStudio": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1419,7 +1419,7 @@
|
|||
flex: 1;
|
||||
padding: 2rem;
|
||||
max-width: 900px;
|
||||
background-color: #002224;
|
||||
background-color: var(--color-bg-darker);
|
||||
}
|
||||
|
||||
.myinfo-title {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -844,6 +844,16 @@ export interface AccommodationSearchItem {
|
|||
title: string;
|
||||
}
|
||||
|
||||
// 네이버 검색 API는 title에 <b> 태그와 & 같은 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[];
|
||||
|
|
|
|||
Loading…
Reference in New Issue