diff --git a/app/music/studio/page.tsx b/app/music/studio/page.tsx new file mode 100644 index 0000000..06eae0c --- /dev/null +++ b/app/music/studio/page.tsx @@ -0,0 +1,543 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +type Mode = 'simple' | 'custom'; + +type SunoClip = { + id: string; + title?: string; + audioUrl?: string; + streamAudioUrl?: string; + imageUrl?: string; + tags?: string; + duration?: number; + prompt?: string; +}; + +type TaskState = { + taskId: string; + status: string; + errorMessage?: string; + clips: SunoClip[]; + updatedAt: number; +}; + +const MODELS = [ + { id: 'V4', label: 'V4 (기본)', desc: '안정적 고품질' }, + { id: 'V4_5', label: 'V4.5', desc: '최신 · 풍부한 디테일' }, + { id: 'V3_5', label: 'V3.5', desc: '빠른 생성' }, +]; + +const TAG_PRESETS = [ + 'k-pop', 'lo-fi', 'city pop', 'ballad', 'edm', 'trap', + 'rock', 'jazz', 'acoustic', 'cinematic', 'synthwave', 'ambient', +]; + +const LS_KEY = 'jsm_studio_task_ids_v2'; + +const isDone = (s: string) => s === 'SUCCESS' || s === 'FIRST_SUCCESS'; +const isFailed = (s: string) => s.includes('FAILED') || s === 'SENSITIVE_WORD_ERROR'; + +export default function StudioPage() { + const [mode, setMode] = useState('simple'); + const [model, setModel] = useState('V4'); + const [prompt, setPrompt] = useState(''); + const [title, setTitle] = useState(''); + const [lyrics, setLyrics] = useState(''); + const [tags, setTags] = useState(''); + const [instrumental, setInstrumental] = useState(false); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [tasks, setTasks] = useState([]); + const pollRef = useRef(null); + + const saveToLS = useCallback((ids: string[]) => { + if (typeof window === 'undefined') return; + try { localStorage.setItem(LS_KEY, JSON.stringify(ids.slice(0, 20))); } catch { /* noop */ } + }, []); + + const fetchOne = useCallback(async (taskId: string) => { + try { + const res = await fetch(`/api/studio/status?taskId=${encodeURIComponent(taskId)}`); + const json = await res.json(); + if (!json.ok) return null; + const d = json.data?.data ?? json.data; + const status: string = d?.status ?? 'PENDING'; + const errMsg: string | undefined = d?.errorMessage; + const sunoData: SunoClip[] = d?.response?.sunoData ?? []; + return { taskId, status, errorMessage: errMsg, clips: sunoData, updatedAt: Date.now() } as TaskState; + } catch { + return null; + } + }, []); + + const refreshAll = useCallback(async (ids: string[]) => { + const results = await Promise.all(ids.map((id) => fetchOne(id))); + setTasks((prev) => { + const map = new Map(prev.map((t) => [t.taskId, t])); + for (const r of results) if (r) map.set(r.taskId, r); + return Array.from(map.values()).sort((a, b) => b.updatedAt - a.updatedAt); + }); + }, [fetchOne]); + + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const raw = localStorage.getItem(LS_KEY); + const ids = raw ? (JSON.parse(raw) as string[]) : []; + if (ids.length) { + setTasks(ids.map((id) => ({ taskId: id, status: 'PENDING', clips: [], updatedAt: Date.now() }))); + refreshAll(ids); + } + } catch { /* noop */ } + }, [refreshAll]); + + useEffect(() => { + if (pollRef.current) window.clearInterval(pollRef.current); + const pending = tasks.filter((t) => !isDone(t.status) && !isFailed(t.status)); + if (pending.length) { + pollRef.current = window.setInterval(() => { + refreshAll(pending.map((t) => t.taskId)); + }, 8000); + } + return () => { if (pollRef.current) window.clearInterval(pollRef.current); }; + }, [tasks, refreshAll]); + + const onSubmit = async () => { + setError(null); + if (mode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; } + if (mode === 'custom') { + if (!title.trim()) { setError('트랙 제목을 입력해주세요.'); return; } + if (!tags.trim()) { setError('스타일 태그를 입력해주세요.'); return; } + if (!lyrics.trim() && !instrumental) { setError('가사를 입력하거나 Instrumental을 켜주세요.'); return; } + } + setSubmitting(true); + try { + const res = await fetch('/api/studio/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode, model, + prompt: prompt.trim(), + title: title.trim(), + lyrics: lyrics.trim(), + tags: tags.trim(), + make_instrumental: instrumental, + }), + }); + const json = await res.json(); + if (!res.ok || !json.ok) { + setError(typeof json.error === 'string' ? json.error : '생성 실패'); + return; + } + const taskId: string | undefined = json.data?.data?.taskId ?? json.data?.taskId; + if (!taskId) { + setError('응답에서 taskId를 찾지 못했습니다.'); + return; + } + setTasks((prev) => { + const next: TaskState[] = [ + { taskId, status: 'PENDING', clips: [], updatedAt: Date.now() }, + ...prev.filter((t) => t.taskId !== taskId), + ]; + saveToLS(next.map((t) => t.taskId)); + return next; + }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(false); + } + }; + + const addTag = (t: string) => { + const cur = tags.split(',').map((x) => x.trim()).filter(Boolean); + if (cur.includes(t)) return; + setTags([...cur, t].join(', ')); + }; + + return ( +
+
+
+
+ JAENGSEUNG STUDIO +

+ 프롬프트 한 줄로 트랙 만들기 +

+

+ Suno 엔진 기반 · Custom 모드로 가사·태그·보컬까지 세밀 제어 +

+
+
+ ⚡ v1 Studio · Live +
+
+ +
+ {/* 좌측: 제어판 */} +
+
+ {(['simple', 'custom'] as Mode[]).map((m) => ( + + ))} +
+ + {mode === 'simple' ? ( +
+ +