diff --git a/app/music/studio/page.tsx b/app/music/studio/page.tsx index 06eae0c..a1d4c71 100644 --- a/app/music/studio/page.tsx +++ b/app/music/studio/page.tsx @@ -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('story'); const [mode, setMode] = useState('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('input'); + const [storyLoading, setStoryLoading] = useState(false); + const [storyError, setStoryError] = useState(null); + const [storyAuthRequired, setStoryAuthRequired] = useState(false); + const [mood, setMood] = useState(''); + const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [authRequired, setAuthRequired] = useState(false); const [tasks, setTasks] = useState([]); const pollRef = useRef(null); + // 생성 요청 시점의 원본(스토리/가사/스타일)을 taskId에 매핑 — 완료 후 자동 저장에 사용 + const metaRef = useRef>(new Map()); + // 자동 저장 완료(또는 시도) 표시 — 중복 저장 방지 + const savedRef = useRef>(new Set()); + // 이번 세션에서 새로 생성한 taskId만 자동 저장 대상으로 삼는다 + // (새로고침 시 localStorage에서 복원된 과거 완료 트랙까지 재저장하는 것 방지) + const sessionTaskIdsRef = useRef>(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,203 +298,344 @@ export default function StudioPage() { return (
JAENGSEUNG STUDIO

- 프롬프트 한 줄로 트랙 만들기 + 나의 이야기를 음악으로

-

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

+ 이야기를 들려주면 AI가 가사·스타일을 제안합니다. 직접 입력 모드로 세밀하게 조정할 수도 있어요.

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