'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; 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; }; 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: '최신 · 풍부한 디테일' }, { 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 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(''); const [title, setTitle] = useState(''); const [lyrics, setLyrics] = useState(''); 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 */ } }, []); 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]); // 완료된 트랙 자동 저장 (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); 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; } } setSubmitting(true); try { const res = await fetch('/api/studio/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: forcedMode, model, prompt: prompt.trim(), title: title.trim(), lyrics: lyrics.trim(), tags: tags.trim(), make_instrumental: instrumental, }), }); 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; } const taskId: string | undefined = json.data?.data?.taskId ?? json.data?.taskId; if (!taskId) { setError('응답에서 taskId를 찾지 못했습니다.'); return; } metaRef.current.set(taskId, meta); sessionTaskIdsRef.current.add(taskId); 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); } }, [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) => { const cur = tags.split(',').map((x) => x.trim()).filter(Boolean); if (cur.includes(t)) return; setTags([...cur, t].join(', ')); }; return (
JAENGSEUNG STUDIO

나의 이야기를 음악으로

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

STUDIO · LIVE
{/* 좌측: 제어판 */}
{(['story', 'manual'] as FlowTab[]).map((t) => ( ))}
{flowTab === 'story' ? (
{storyStage === 'input' ? ( <>