feat(phase3a): 음악 스튜디오 라이트 재스킨 + 스토리→음악 흐름
- 다크/gradient/violet/purple/blur/이모지 전부 제거, --jsm 토큰 기반 라이트 UI로 재구성 (폼 필드 bg-white+jsm-line 보더+jsm-accent 포커스, navy 없이 flat 카드) - 스토리 우선 흐름 신설: 이야기 textarea → "가사 만들기"(POST /api/studio/story) → 제목/가사/스타일 편집 가능 미리보기 → "음악 만들기"(POST /api/studio/generate, custom 모드) - 401/429/503 각각 로그인 CTA·제한 안내·서비스 준비중 메시지로 분기 처리 - 기존 simple/custom 직접 입력 모드는 "직접 입력" 탭으로 보존, taskId 폴링 로직 그대로 유지 - 생성 완료 시 오디오 플레이어 노출 + 로그인 사용자는 POST /api/studio/tracks로 best-effort 자동 저장(세션 내 생성 트랙만 대상, 실패해도 재생에는 영향 없음) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Mode = 'simple' | 'custom';
|
||||
type FlowTab = 'story' | 'manual';
|
||||
type StoryStage = 'input' | 'preview';
|
||||
|
||||
type SunoClip = {
|
||||
id: string;
|
||||
@@ -23,6 +26,20 @@ type TaskState = {
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type TrackMeta = {
|
||||
title?: string;
|
||||
story?: string;
|
||||
lyrics?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
type MusicStory = {
|
||||
title: string;
|
||||
lyrics: string;
|
||||
style: string;
|
||||
mood: string;
|
||||
};
|
||||
|
||||
const MODELS = [
|
||||
{ id: 'V4', label: 'V4 (기본)', desc: '안정적 고품질' },
|
||||
{ id: 'V4_5', label: 'V4.5', desc: '최신 · 풍부한 디테일' },
|
||||
@@ -35,11 +52,16 @@ const TAG_PRESETS = [
|
||||
];
|
||||
|
||||
const LS_KEY = 'jsm_studio_task_ids_v2';
|
||||
const LOGIN_HREF = '/login?next=/music/studio';
|
||||
|
||||
const isDone = (s: string) => s === 'SUCCESS' || s === 'FIRST_SUCCESS';
|
||||
const isFailed = (s: string) => s.includes('FAILED') || s === 'SENSITIVE_WORD_ERROR';
|
||||
|
||||
const FIELD_INPUT =
|
||||
'w-full rounded-xl border border-[var(--jsm-line)] bg-white px-4 py-3 text-base text-[var(--jsm-ink)] outline-none transition focus:border-[var(--jsm-accent)]';
|
||||
|
||||
export default function StudioPage() {
|
||||
const [flowTab, setFlowTab] = useState<FlowTab>('story');
|
||||
const [mode, setMode] = useState<Mode>('simple');
|
||||
const [model, setModel] = useState('V4');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
@@ -48,11 +70,28 @@ export default function StudioPage() {
|
||||
const [tags, setTags] = useState('');
|
||||
const [instrumental, setInstrumental] = useState(false);
|
||||
|
||||
// 스토리 흐름 상태
|
||||
const [storyText, setStoryText] = useState('');
|
||||
const [storyStage, setStoryStage] = useState<StoryStage>('input');
|
||||
const [storyLoading, setStoryLoading] = useState(false);
|
||||
const [storyError, setStoryError] = useState<string | null>(null);
|
||||
const [storyAuthRequired, setStoryAuthRequired] = useState(false);
|
||||
const [mood, setMood] = useState('');
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [tasks, setTasks] = useState<TaskState[]>([]);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
// 생성 요청 시점의 원본(스토리/가사/스타일)을 taskId에 매핑 — 완료 후 자동 저장에 사용
|
||||
const metaRef = useRef<Map<string, TrackMeta>>(new Map());
|
||||
// 자동 저장 완료(또는 시도) 표시 — 중복 저장 방지
|
||||
const savedRef = useRef<Set<string>>(new Set());
|
||||
// 이번 세션에서 새로 생성한 taskId만 자동 저장 대상으로 삼는다
|
||||
// (새로고침 시 localStorage에서 복원된 과거 완료 트랙까지 재저장하는 것 방지)
|
||||
const sessionTaskIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const saveToLS = useCallback((ids: string[]) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(ids.slice(0, 20))); } catch { /* noop */ }
|
||||
@@ -105,10 +144,38 @@ export default function StudioPage() {
|
||||
return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
|
||||
}, [tasks, refreshAll]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
// 완료된 트랙 자동 저장 (best-effort) — 실패해도 재생에는 영향 없음
|
||||
useEffect(() => {
|
||||
tasks.forEach((task) => {
|
||||
if (!isDone(task.status)) return;
|
||||
if (!sessionTaskIdsRef.current.has(task.taskId)) return;
|
||||
if (savedRef.current.has(task.taskId)) return;
|
||||
const clip = task.clips.find((c) => c.audioUrl || c.streamAudioUrl);
|
||||
if (!clip) return;
|
||||
|
||||
savedRef.current.add(task.taskId);
|
||||
const meta = metaRef.current.get(task.taskId);
|
||||
const audioUrl = clip.audioUrl || clip.streamAudioUrl || '';
|
||||
fetch('/api/studio/tracks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: meta?.title || clip.title || null,
|
||||
story: meta?.story || null,
|
||||
lyrics: meta?.lyrics || null,
|
||||
style: meta?.style || clip.tags || null,
|
||||
audio_url: audioUrl,
|
||||
task_id: task.taskId,
|
||||
}),
|
||||
}).catch(() => { /* 비로그인·오류 등 — 무시(best-effort) */ });
|
||||
});
|
||||
}, [tasks]);
|
||||
|
||||
const runGenerate = useCallback(async (forcedMode: Mode, meta: TrackMeta) => {
|
||||
setError(null);
|
||||
if (mode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; }
|
||||
if (mode === 'custom') {
|
||||
setAuthRequired(false);
|
||||
if (forcedMode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; }
|
||||
if (forcedMode === 'custom') {
|
||||
if (!title.trim()) { setError('트랙 제목을 입력해주세요.'); return; }
|
||||
if (!tags.trim()) { setError('스타일 태그를 입력해주세요.'); return; }
|
||||
if (!lyrics.trim() && !instrumental) { setError('가사를 입력하거나 Instrumental을 켜주세요.'); return; }
|
||||
@@ -119,7 +186,7 @@ export default function StudioPage() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mode, model,
|
||||
mode: forcedMode, model,
|
||||
prompt: prompt.trim(),
|
||||
title: title.trim(),
|
||||
lyrics: lyrics.trim(),
|
||||
@@ -127,7 +194,16 @@ export default function StudioPage() {
|
||||
make_instrumental: instrumental,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (res.status === 401) {
|
||||
setAuthRequired(true);
|
||||
setError('로그인이 필요합니다.');
|
||||
return;
|
||||
}
|
||||
if (res.status === 429) {
|
||||
setError(typeof json.error === 'string' ? json.error : '오늘 생성 가능 횟수를 모두 사용했습니다.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok || !json.ok) {
|
||||
setError(typeof json.error === 'string' ? json.error : '생성 실패');
|
||||
return;
|
||||
@@ -137,6 +213,8 @@ export default function StudioPage() {
|
||||
setError('응답에서 taskId를 찾지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
metaRef.current.set(taskId, meta);
|
||||
sessionTaskIdsRef.current.add(taskId);
|
||||
setTasks((prev) => {
|
||||
const next: TaskState[] = [
|
||||
{ taskId, status: 'PENDING', clips: [], updatedAt: Date.now() },
|
||||
@@ -150,6 +228,65 @@ export default function StudioPage() {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [prompt, title, lyrics, tags, instrumental, model, saveToLS]);
|
||||
|
||||
const onManualSubmit = () => {
|
||||
runGenerate(mode, {
|
||||
title: mode === 'custom' ? title.trim() : undefined,
|
||||
lyrics: mode === 'custom' ? lyrics.trim() : undefined,
|
||||
style: mode === 'custom' ? tags.trim() : undefined,
|
||||
story: mode === 'simple' ? prompt.trim() : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const onStoryGenerate = () => {
|
||||
runGenerate('custom', {
|
||||
title: title.trim(),
|
||||
lyrics: lyrics.trim(),
|
||||
style: tags.trim(),
|
||||
story: storyText.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const onMakeLyrics = async () => {
|
||||
setStoryError(null);
|
||||
setStoryAuthRequired(false);
|
||||
if (!storyText.trim()) {
|
||||
setStoryError('이야기를 먼저 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
setStoryLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/studio/story', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ story: storyText.trim() }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (res.status === 401) {
|
||||
setStoryAuthRequired(true);
|
||||
setStoryError('로그인이 필요합니다.');
|
||||
return;
|
||||
}
|
||||
if (res.status === 503 || res.status === 502) {
|
||||
setStoryError(typeof json.error === 'string' ? json.error : 'AI 서비스가 잠시 준비 중입니다. 잠시 후 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok || !json.story) {
|
||||
setStoryError(typeof json.error === 'string' ? json.error : '가사 생성에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
const s = json.story as MusicStory;
|
||||
setTitle(s.title);
|
||||
setLyrics(s.lyrics);
|
||||
setTags(s.style);
|
||||
setMood(s.mood);
|
||||
setStoryStage('preview');
|
||||
} catch (e) {
|
||||
setStoryError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setStoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = (t: string) => {
|
||||
@@ -161,59 +298,227 @@ export default function StudioPage() {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen px-4 md:px-8 lg:px-12 py-10"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(1200px 600px at 20% -10%, rgba(156,72,234,0.18), transparent 60%), radial-gradient(1000px 500px at 110% 10%, rgba(83,221,252,0.12), transparent 55%), var(--kx-surface)',
|
||||
color: 'var(--kx-on-surface)',
|
||||
}}
|
||||
style={{ background: 'var(--jsm-bg)', color: 'var(--jsm-ink)' }}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-end justify-between flex-wrap gap-4 mb-8">
|
||||
<div>
|
||||
<span className="kx-label">JAENGSEUNG STUDIO</span>
|
||||
<h1 className="kx-display text-3xl md:text-5xl font-extrabold mt-2" style={{ letterSpacing: '-0.02em' }}>
|
||||
프롬프트 한 줄로 트랙 만들기
|
||||
나의 이야기를 음악으로
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
Suno 엔진 기반 · Custom 모드로 가사·태그·보컬까지 세밀 제어
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
이야기를 들려주면 AI가 가사·스타일을 제안합니다. 직접 입력 모드로 세밀하게 조정할 수도 있어요.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs px-3 py-1.5 rounded-full border"
|
||||
className="text-xs px-3 py-1.5 rounded-full font-semibold tracking-wide"
|
||||
style={{
|
||||
borderColor: 'rgba(204,151,255,0.35)',
|
||||
background: 'rgba(204,151,255,0.1)',
|
||||
color: 'var(--kx-primary)',
|
||||
border: '1px solid var(--jsm-accent)',
|
||||
background: 'var(--jsm-accent-soft)',
|
||||
color: 'var(--jsm-accent)',
|
||||
}}
|
||||
>
|
||||
⚡ v1 Studio · Live
|
||||
STUDIO · LIVE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)] gap-6">
|
||||
{/* 좌측: 제어판 */}
|
||||
<div
|
||||
className="rounded-2xl p-6 md:p-8"
|
||||
<div className="rounded-2xl p-6 md:p-8 bg-white border border-[var(--jsm-line)]">
|
||||
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
{(['story', 'manual'] as FlowTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setFlowTab(t)}
|
||||
className="flex-1 py-2.5 text-sm font-semibold rounded-full transition-all"
|
||||
style={
|
||||
flowTab === t
|
||||
? { background: 'var(--jsm-accent)', color: '#fff' }
|
||||
: { color: 'var(--jsm-ink-soft)' }
|
||||
}
|
||||
>
|
||||
{t === 'story' ? '스토리로 만들기' : '직접 입력'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{flowTab === 'story' ? (
|
||||
<div className="space-y-5">
|
||||
{storyStage === 'input' ? (
|
||||
<>
|
||||
<Field label="나의 이야기" hint="추억·순간·감정을 편하게 적어주세요">
|
||||
<textarea
|
||||
value={storyText}
|
||||
onChange={(e) => setStoryText(e.target.value)}
|
||||
rows={7}
|
||||
placeholder="예: 대학 시절 자취방에서 혼자 라면을 끓여 먹으며 창밖 비 오는 거리를 보던 밤, 외로웠지만 이상하게 평온했던 기억"
|
||||
className={`${FIELD_INPUT} resize-none`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
onClick={onMakeLyrics}
|
||||
disabled={storyLoading}
|
||||
className="w-full py-4 rounded-xl font-bold text-base transition disabled:opacity-60"
|
||||
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
|
||||
>
|
||||
{storyLoading ? '가사 만드는 중…' : '가사 만들기'}
|
||||
</button>
|
||||
|
||||
{storyError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{storyError}
|
||||
{storyAuthRequired && (
|
||||
<Link
|
||||
href={LOGIN_HREF}
|
||||
className="ml-2 font-semibold underline underline-offset-2"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
로그인하러 가기
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className="text-xs px-3 py-1 rounded-full font-semibold"
|
||||
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
무드 · {mood || '미정'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setStoryStage('input')}
|
||||
className="text-xs underline underline-offset-4"
|
||||
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
이야기 다시 쓰기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Field label="트랙 제목">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 새벽 세 시의 도시"
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="가사" hint="AI가 제안한 가사입니다 — 자유롭게 수정 가능">
|
||||
<textarea
|
||||
value={lyrics}
|
||||
onChange={(e) => setLyrics(e.target.value)}
|
||||
rows={8}
|
||||
className={`${FIELD_INPUT} resize-none font-mono text-sm leading-relaxed`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{TAG_PRESETS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => addTag(t)}
|
||||
className="text-xs px-2.5 py-1 rounded-full transition"
|
||||
style={{
|
||||
background: 'rgba(12,22,45,0.7)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
||||
+ {t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="모델">
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className={`${FIELD_INPUT} text-sm`}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label} — {m.desc}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Instrumental (가사 없음)">
|
||||
<ToggleSwitch checked={instrumental} onChange={setInstrumental} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onMakeLyrics}
|
||||
disabled={storyLoading}
|
||||
className="flex-1 py-3.5 rounded-xl font-semibold text-sm transition disabled:opacity-60"
|
||||
style={{
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink)',
|
||||
}}
|
||||
>
|
||||
{storyLoading ? '다시 만드는 중…' : '가사 다시 만들기'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onStoryGenerate}
|
||||
disabled={submitting}
|
||||
className="flex-1 py-3.5 rounded-xl font-bold text-sm transition disabled:opacity-60"
|
||||
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
|
||||
>
|
||||
{submitting ? '생성 요청 중…' : '음악 만들기'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{storyError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{storyError}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
{authRequired && (
|
||||
<Link
|
||||
href={LOGIN_HREF}
|
||||
className="ml-2 font-semibold underline underline-offset-2"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
로그인하러 가기
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
생성된 결과는 Suno 서비스 약관을 따릅니다. 상업 이용 전 플랜·저작권을 반드시 확인하세요.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
{(['simple', 'custom'] as Mode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className="flex-1 py-2.5 text-sm font-semibold rounded-full transition-all"
|
||||
className="flex-1 py-2 text-xs font-semibold rounded-full transition-all"
|
||||
style={
|
||||
mode === m
|
||||
? {
|
||||
background: 'linear-gradient(135deg, rgba(204,151,255,0.25), rgba(83,221,252,0.15))',
|
||||
color: '#fff',
|
||||
boxShadow: '0 0 24px rgba(204,151,255,0.25) inset',
|
||||
}
|
||||
: { color: 'var(--kx-on-variant)' }
|
||||
? { background: 'var(--jsm-accent)', color: '#fff' }
|
||||
: { color: 'var(--jsm-ink-soft)' }
|
||||
}
|
||||
>
|
||||
{m === 'simple' ? '간단 모드' : 'Custom 모드'}
|
||||
@@ -229,8 +534,7 @@ export default function StudioPage() {
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="예: 비 오는 서울 새벽, 감성 시티팝 with 여성 보컬, 2010년대 무드"
|
||||
className="w-full bg-transparent outline-none resize-none text-base"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
className={`${FIELD_INPUT} resize-none`}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
@@ -241,8 +545,7 @@ export default function StudioPage() {
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 새벽 세 시의 도시"
|
||||
className="w-full bg-transparent outline-none text-base"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="가사" hint="Suno 포맷: [Verse] [Chorus] [Bridge] 등 태그 가능">
|
||||
@@ -251,8 +554,7 @@ export default function StudioPage() {
|
||||
onChange={(e) => setLyrics(e.target.value)}
|
||||
rows={8}
|
||||
placeholder={'[Verse]\n차가운 조명 아래 걷는 나\n새벽 세 시의 도시는 낯설어\n\n[Chorus]\n...'}
|
||||
className="w-full bg-transparent outline-none resize-none font-mono text-sm leading-relaxed"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
className={`${FIELD_INPUT} resize-none font-mono text-sm leading-relaxed`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
|
||||
@@ -260,8 +562,7 @@ export default function StudioPage() {
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
|
||||
className="w-full bg-transparent outline-none text-base"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{TAG_PRESETS.map((t) => (
|
||||
@@ -270,9 +571,9 @@ export default function StudioPage() {
|
||||
onClick={() => addTag(t)}
|
||||
className="text-xs px-2.5 py-1 rounded-full transition"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: 'var(--kx-on-variant)',
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
}}
|
||||
>
|
||||
+ {t}
|
||||
@@ -288,76 +589,53 @@ export default function StudioPage() {
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full bg-transparent outline-none text-sm"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
className={`${FIELD_INPUT} text-sm`}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id} style={{ background: '#0b1428' }}>
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label} — {m.desc}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Instrumental (가사 없음)">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<span
|
||||
className="relative inline-block w-11 h-6 rounded-full transition"
|
||||
style={{ background: instrumental ? 'rgba(204,151,255,0.6)' : 'rgba(255,255,255,0.1)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
|
||||
style={{ left: instrumental ? '22px' : '2px' }}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={instrumental}
|
||||
onChange={(e) => setInstrumental(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-xs" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
{instrumental ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</label>
|
||||
<ToggleSwitch checked={instrumental} onChange={setInstrumental} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
onClick={onManualSubmit}
|
||||
disabled={submitting}
|
||||
className="w-full py-4 rounded-xl font-extrabold text-base transition-all disabled:opacity-60"
|
||||
style={{
|
||||
background: submitting
|
||||
? 'rgba(204,151,255,0.2)'
|
||||
: 'linear-gradient(135deg, #cc97ff 0%, #7c3aed 50%, #53ddfc 100%)',
|
||||
color: '#0b1428',
|
||||
boxShadow: submitting ? 'none' : '0 12px 40px -12px rgba(204,151,255,0.6)',
|
||||
letterSpacing: '0.01em',
|
||||
}}
|
||||
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
|
||||
>
|
||||
{submitting ? '생성 요청 중…' : '▶ Generate Track'}
|
||||
{submitting ? '생성 요청 중…' : '트랙 생성하기'}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-3 text-xs px-3 py-2 rounded-lg" style={{ background: 'rgba(215,51,87,0.12)', color: '#ff8ba7' }}>
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
{authRequired && (
|
||||
<Link
|
||||
href={LOGIN_HREF}
|
||||
className="ml-2 font-semibold underline underline-offset-2"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
로그인하러 가기
|
||||
</Link>
|
||||
)}
|
||||
<p className="mt-3 text-[11px] leading-relaxed" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-[11px] leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
생성된 결과는 Suno 서비스 약관을 따릅니다. 상업 이용 전 플랜·저작권을 반드시 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 결과 */}
|
||||
<div
|
||||
className="rounded-2xl p-6 md:p-7"
|
||||
style={{
|
||||
background: 'rgba(9,17,36,0.7)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
}}
|
||||
>
|
||||
<div className="rounded-2xl p-6 md:p-7 bg-white border border-[var(--jsm-line)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<span className="kx-label">RECENT TRACKS</span>
|
||||
@@ -367,7 +645,7 @@ export default function StudioPage() {
|
||||
<button
|
||||
onClick={() => { setTasks([]); saveToLS([]); }}
|
||||
className="text-[11px] underline underline-offset-4"
|
||||
style={{ color: 'var(--kx-on-variant)' }}
|
||||
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
기록 지우기
|
||||
</button>
|
||||
@@ -376,35 +654,31 @@ export default function StudioPage() {
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div
|
||||
className="rounded-xl p-8 text-center text-sm"
|
||||
style={{ border: '1px dashed rgba(255,255,255,0.1)', color: 'var(--kx-on-variant)' }}
|
||||
className="rounded-xl p-8 text-center text-sm border border-dashed"
|
||||
style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
아직 생성된 트랙이 없습니다.
|
||||
<br />왼쪽에서 프롬프트를 입력하고 Generate를 눌러보세요.
|
||||
<br />왼쪽에서 이야기를 들려주거나 프롬프트를 입력해보세요.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-4 max-h-[640px] overflow-y-auto pr-1">
|
||||
{tasks.map((task) => (
|
||||
<li
|
||||
key={task.taskId}
|
||||
className="rounded-xl p-4"
|
||||
style={{
|
||||
background: 'rgba(20,31,56,0.6)',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}
|
||||
className="rounded-xl p-4 border"
|
||||
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="text-[11px] font-mono opacity-60">task: {task.taskId.slice(0, 10)}…</span>
|
||||
<span className="text-[11px] font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
task: {task.taskId.slice(0, 10)}…
|
||||
</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
|
||||
{task.clips.length === 0 ? (
|
||||
<div
|
||||
className="h-9 rounded-md flex items-center justify-center text-xs"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, rgba(204,151,255,0.08) 0%, rgba(83,221,252,0.08) 100%)',
|
||||
color: 'var(--kx-on-variant)',
|
||||
}}
|
||||
style={{ background: 'var(--jsm-surface)', color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
{isFailed(task.status)
|
||||
? (task.errorMessage ?? '생성 실패')
|
||||
@@ -417,8 +691,8 @@ export default function StudioPage() {
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className="rounded-lg p-3"
|
||||
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.04)' }}
|
||||
className="rounded-lg p-3 bg-white border"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{c.imageUrl && (
|
||||
@@ -430,17 +704,17 @@ export default function StudioPage() {
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm truncate" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<p className="font-semibold text-sm truncate" style={{ color: 'var(--jsm-ink)' }}>
|
||||
{c.title || '제목 없음'}
|
||||
</p>
|
||||
{c.tags && (
|
||||
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
{c.tags}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{c.duration && (
|
||||
<span className="text-[10px] font-mono opacity-60">
|
||||
<span className="text-[10px] font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
{Math.round(c.duration)}s
|
||||
</span>
|
||||
)}
|
||||
@@ -449,8 +723,13 @@ export default function StudioPage() {
|
||||
<audio controls src={src} className="w-full mt-2" style={{ height: 36 }} />
|
||||
) : null}
|
||||
{c.audioUrl && (
|
||||
<div className="mt-1.5 text-[11px]" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<a href={c.audioUrl} download className="underline underline-offset-4 hover:text-white">
|
||||
<div className="mt-1.5 text-[11px]" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
<a
|
||||
href={c.audioUrl}
|
||||
download
|
||||
className="underline underline-offset-4"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
MP3 다운로드
|
||||
</a>
|
||||
</div>
|
||||
@@ -467,9 +746,9 @@ export default function StudioPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid md:grid-cols-3 gap-4 text-xs" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<Tip title="① 간단 모드" body="한 줄 프롬프트로 즉시 생성. 결과물 다양성 높음." />
|
||||
<Tip title="② Custom 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
|
||||
<div className="mt-10 grid md:grid-cols-3 gap-4 text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
<Tip title="① 스토리 모드" body="이야기를 적으면 AI가 제목·가사·스타일을 자동으로 제안합니다." />
|
||||
<Tip title="② 직접 입력 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
|
||||
<Tip title="③ 상업 이용" body="Suno Pro 이상 플랜에서 생성한 결과만 수익화 가능. 플랜 확인 필수." />
|
||||
</div>
|
||||
</div>
|
||||
@@ -487,41 +766,60 @@ function Field({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] font-semibold tracking-widest uppercase" style={{ color: 'var(--kx-primary)' }}>
|
||||
<span className="text-[11px] font-semibold tracking-widest uppercase" style={{ color: 'var(--jsm-accent)' }}>
|
||||
{label}
|
||||
</span>
|
||||
{hint && <span className="text-[10px]" style={{ color: 'var(--kx-on-variant)' }}>{hint}</span>}
|
||||
{hint && <span className="text-[10px]" style={{ color: 'var(--jsm-ink-soft)' }}>{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<span
|
||||
className="relative inline-block w-11 h-6 rounded-full transition"
|
||||
style={{ background: checked ? 'var(--jsm-accent)' : 'var(--jsm-line)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
|
||||
style={{ left: checked ? '22px' : '2px' }}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
{checked ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, { bg: string; fg: string; label: string }> = {
|
||||
SUCCESS: { bg: 'rgba(64,206,172,0.18)', fg: '#6cf0c6', label: '완료' },
|
||||
FIRST_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '첫 트랙 준비' },
|
||||
TEXT_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '가사 완료' },
|
||||
PENDING: { bg: 'rgba(204,151,255,0.18)', fg: '#cc97ff', label: '대기' },
|
||||
const map: Record<string, { bg: string; fg: string; border: string; label: string }> = {
|
||||
SUCCESS: { bg: '#ecfdf5', fg: '#047857', border: '#a7f3d0', label: '완료' },
|
||||
FIRST_SUCCESS: { bg: 'var(--jsm-accent-soft)', fg: 'var(--jsm-accent)', border: 'var(--jsm-accent)', label: '첫 트랙 준비' },
|
||||
TEXT_SUCCESS: { bg: 'var(--jsm-accent-soft)', fg: 'var(--jsm-accent)', border: 'var(--jsm-accent)', label: '가사 완료' },
|
||||
PENDING: { bg: 'var(--jsm-surface-alt)', fg: 'var(--jsm-ink-soft)', border: 'var(--jsm-line)', label: '대기' },
|
||||
};
|
||||
let entry = map[status];
|
||||
if (!entry) {
|
||||
entry = isFailed(status)
|
||||
? { bg: 'rgba(215,51,87,0.18)', fg: '#ff8ba7', label: '실패' }
|
||||
: { bg: 'rgba(255,255,255,0.06)', fg: 'rgba(255,255,255,0.6)', label: status };
|
||||
? { bg: '#fef2f2', fg: '#b91c1c', border: '#fecaca', label: '실패' }
|
||||
: { bg: 'var(--jsm-surface-alt)', fg: 'var(--jsm-ink-soft)', border: 'var(--jsm-line)', label: status };
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap"
|
||||
style={{ background: entry.bg, color: entry.fg }}
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap border"
|
||||
style={{ background: entry.bg, color: entry.fg, borderColor: entry.border }}
|
||||
>
|
||||
{entry.label}
|
||||
</span>
|
||||
@@ -530,11 +828,8 @@ function StatusBadge({ status }: { status: string }) {
|
||||
|
||||
function Tip({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4"
|
||||
style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}
|
||||
>
|
||||
<p className="font-semibold mb-1" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<div className="rounded-xl p-4 bg-white border" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<p className="font-semibold mb-1" style={{ color: 'var(--jsm-ink)' }}>
|
||||
{title}
|
||||
</p>
|
||||
<p className="leading-relaxed">{body}</p>
|
||||
|
||||
Reference in New Issue
Block a user