feat(music-lab): Phase 3 UI — RemixTab + 뮤직비디오 생성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 09:14:18 +09:00
parent 0849c70644
commit 1f00866694
4 changed files with 304 additions and 2 deletions

View File

@@ -0,0 +1,193 @@
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;