194 lines
9.0 KiB
JavaScript
194 lines
9.0 KiB
JavaScript
import React, { useState } from 'react';
|
|
import { uploadAndCover, uploadAndExtend, addVocals, addInstrumental } from '../../../api';
|
|
|
|
const REMIX_ACTIONS = [
|
|
{ id: 'cover', label: 'AI Cover', icon: '🎨', desc: '외부 음원을 Suno AI 스타일로 리메이크' },
|
|
{ id: 'extend', label: 'Extend', icon: '⏩', desc: '외부 음원을 이어서 확장' },
|
|
{ id: 'add-vocals', label: 'Add Vocals', icon: '🎤', desc: '인스트루멘탈에 AI 보컬 입히기' },
|
|
{ id: 'add-instrumental', label: 'Add Instrumental', icon: '🎹', desc: '보컬에 AI 반주 입히기' },
|
|
];
|
|
|
|
const RemixTab = ({ onTaskStarted, model, isGenerating }) => {
|
|
const [uploadUrl, setUploadUrl] = useState('');
|
|
const [activeAction, setActiveAction] = useState(null);
|
|
|
|
// 각 액션별 파라미터
|
|
const [title, setTitle] = useState('');
|
|
const [style, setStyle] = useState('');
|
|
const [prompt, setPrompt] = useState('');
|
|
const [tags, setTags] = useState('');
|
|
const [negativeTags, setNegativeTags] = useState('');
|
|
const [vocalGender, setVocalGender] = useState(null);
|
|
const [continueAt, setContinueAt] = useState(0);
|
|
const [instrumental, setInstrumental] = useState(false);
|
|
|
|
const handleSubmit = async () => {
|
|
if (!uploadUrl || !activeAction || isGenerating) return;
|
|
|
|
let apiCall;
|
|
let payload = {};
|
|
|
|
switch (activeAction) {
|
|
case 'cover':
|
|
apiCall = uploadAndCover;
|
|
payload = {
|
|
upload_url: uploadUrl, model, custom_mode: true,
|
|
instrumental, prompt, style, title,
|
|
vocal_gender: vocalGender || undefined,
|
|
negative_tags: negativeTags || undefined,
|
|
};
|
|
break;
|
|
case 'extend':
|
|
apiCall = uploadAndExtend;
|
|
payload = {
|
|
upload_url: uploadUrl, model,
|
|
default_param_flag: !prompt,
|
|
continue_at: continueAt || undefined,
|
|
prompt, style, title, instrumental,
|
|
vocal_gender: vocalGender || undefined,
|
|
negative_tags: negativeTags || undefined,
|
|
};
|
|
break;
|
|
case 'add-vocals':
|
|
apiCall = addVocals;
|
|
payload = {
|
|
upload_url: uploadUrl, prompt, title, style,
|
|
negative_tags: negativeTags,
|
|
vocal_gender: vocalGender || undefined,
|
|
model: 'V4_5PLUS',
|
|
};
|
|
break;
|
|
case 'add-instrumental':
|
|
apiCall = addInstrumental;
|
|
payload = {
|
|
upload_url: uploadUrl, title, tags,
|
|
negative_tags: negativeTags,
|
|
vocal_gender: vocalGender || undefined,
|
|
model: 'V4_5PLUS',
|
|
};
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await apiCall(payload);
|
|
if (res?.task_id) {
|
|
onTaskStarted(res.task_id, `Remix: ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`);
|
|
}
|
|
} catch (e) {
|
|
// 에러는 부모 컴포넌트에서 처리
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="ms-remix-tab">
|
|
<div className="ms-remix-tab__header">
|
|
<h2 className="ms-remix-tab__title">Remix Studio</h2>
|
|
<p className="ms-remix-tab__desc">외부 음원을 AI로 리메이크, 확장, 보컬/반주 추가</p>
|
|
</div>
|
|
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Audio URL</label>
|
|
<input
|
|
type="url"
|
|
className="ms-negative-tags__input"
|
|
placeholder="리믹스할 오디오 파일 URL (예: /media/music/track.mp3)"
|
|
value={uploadUrl}
|
|
onChange={(e) => setUploadUrl(e.target.value)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="ms-remix-actions">
|
|
{REMIX_ACTIONS.map((action) => (
|
|
<button
|
|
key={action.id}
|
|
type="button"
|
|
className={`ms-remix-card ${activeAction === action.id ? 'is-active' : ''}`}
|
|
onClick={() => setActiveAction(activeAction === action.id ? null : action.id)}
|
|
>
|
|
<span className="ms-remix-card__icon">{action.icon}</span>
|
|
<span className="ms-remix-card__label">{action.label}</span>
|
|
<span className="ms-remix-card__desc">{action.desc}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeAction && (
|
|
<div className="ms-remix-params">
|
|
{/* 공통 파라미터 */}
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Title</label>
|
|
<input type="text" className="ms-negative-tags__input" value={title}
|
|
onChange={(e) => setTitle(e.target.value)} placeholder="곡 제목" style={{ width: '100%' }} />
|
|
</div>
|
|
|
|
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Prompt / Lyrics</label>
|
|
<textarea className="ms-prompt" value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)} rows={3}
|
|
placeholder="가사 또는 스타일 설명" />
|
|
</div>
|
|
)}
|
|
|
|
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Style</label>
|
|
<input type="text" className="ms-negative-tags__input" value={style}
|
|
onChange={(e) => setStyle(e.target.value)} placeholder="예: Pop, Energetic, Piano" style={{ width: '100%' }} />
|
|
</div>
|
|
)}
|
|
|
|
{activeAction === 'add-instrumental' && (
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Tags (스타일/특성)</label>
|
|
<input type="text" className="ms-negative-tags__input" value={tags}
|
|
onChange={(e) => setTags(e.target.value)} placeholder="예: acoustic, warm, dreamy" style={{ width: '100%' }} />
|
|
</div>
|
|
)}
|
|
|
|
{activeAction === 'extend' && (
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Continue At (초)</label>
|
|
<input type="number" className="ms-negative-tags__input" value={continueAt}
|
|
onChange={(e) => setContinueAt(Number(e.target.value))} min={0} style={{ width: '120px' }} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Exclude Styles</label>
|
|
<input type="text" className="ms-negative-tags__input" value={negativeTags}
|
|
onChange={(e) => setNegativeTags(e.target.value)} placeholder="제외할 스타일" style={{ width: '100%' }} />
|
|
</div>
|
|
|
|
<div className="ms-param-group">
|
|
<label className="ms-param-label">Vocal Gender</label>
|
|
<div className="ms-gender-toggle">
|
|
{[{ value: null, label: 'Auto' }, { value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }].map((opt) => (
|
|
<button key={opt.label} type="button"
|
|
className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''}`}
|
|
onClick={() => setVocalGender(opt.value)}>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="ms-btn ms-btn--accent ms-remix-submit"
|
|
disabled={!uploadUrl || isGenerating}
|
|
onClick={handleSubmit}
|
|
>
|
|
{isGenerating ? 'Processing...' : `Start ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RemixTab;
|