diff --git a/app/api/studio/generate/route.ts b/app/api/studio/generate/route.ts index 7f5aaca..c6b7c1e 100644 --- a/app/api/studio/generate/route.ts +++ b/app/api/studio/generate/route.ts @@ -13,12 +13,12 @@ type GenerateBody = { }; export async function POST(request: Request) { - const apiUrl = process.env.SUNO_API_URL; + const apiUrl = process.env.SUNO_API_URL ?? 'https://api.sunoapi.org'; const apiKey = process.env.SUNO_API_KEY; - if (!apiUrl || !apiKey) { + if (!apiKey) { return NextResponse.json( - { error: 'Suno API 미설정 (SUNO_API_URL / SUNO_API_KEY 환경변수 필요)' }, + { error: 'Suno API 미설정 (SUNO_API_KEY 환경변수 필요)' }, { status: 503 }, ); } @@ -30,27 +30,30 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } - const endpoint = body.mode === 'custom' ? '/api/custom_generate' : '/api/generate'; + const origin = new URL(request.url).origin; + const callBackUrl = `${origin}/api/studio/callback`; - const payload = - body.mode === 'custom' - ? { - prompt: body.lyrics ?? '', - tags: body.tags ?? '', - title: body.title ?? '', - make_instrumental: !!body.make_instrumental, - model: body.model ?? 'chirp-v3-5', - wait_audio: false, - } - : { - prompt: body.prompt ?? '', - make_instrumental: !!body.make_instrumental, - model: body.model ?? 'chirp-v3-5', - wait_audio: false, - }; + const isCustom = body.mode === 'custom'; + const payload = isCustom + ? { + prompt: body.lyrics ?? '', + style: body.tags ?? '', + title: body.title ?? 'Untitled', + customMode: true, + instrumental: !!body.make_instrumental, + model: body.model ?? 'V4', + callBackUrl, + } + : { + prompt: body.prompt ?? '', + customMode: false, + instrumental: !!body.make_instrumental, + model: body.model ?? 'V4', + callBackUrl, + }; try { - const res = await fetch(`${apiUrl.replace(/\/$/, '')}${endpoint}`, { + const res = await fetch(`${apiUrl.replace(/\/$/, '')}/api/v1/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -60,10 +63,10 @@ export async function POST(request: Request) { }); const data = await res.json().catch(() => null); - if (!res.ok) { + if (!res.ok || (data && typeof data === 'object' && 'code' in data && data.code !== 200)) { return NextResponse.json( { error: '생성 실패', detail: data ?? (await res.text().catch(() => '')) }, - { status: res.status }, + { status: res.ok ? 502 : res.status }, ); } return NextResponse.json({ ok: true, data }); diff --git a/app/api/studio/status/route.ts b/app/api/studio/status/route.ts index 51587a9..de6b71c 100644 --- a/app/api/studio/status/route.ts +++ b/app/api/studio/status/route.ts @@ -3,23 +3,23 @@ import { NextResponse } from 'next/server'; export const runtime = 'nodejs'; export async function GET(request: Request) { - const apiUrl = process.env.SUNO_API_URL; + const apiUrl = process.env.SUNO_API_URL ?? 'https://api.sunoapi.org'; const apiKey = process.env.SUNO_API_KEY; - if (!apiUrl || !apiKey) { + if (!apiKey) { return NextResponse.json( - { error: 'Suno API 미설정 (SUNO_API_URL / SUNO_API_KEY 환경변수 필요)' }, + { error: 'Suno API 미설정 (SUNO_API_KEY 환경변수 필요)' }, { status: 503 }, ); } const { searchParams } = new URL(request.url); - const ids = searchParams.get('ids'); - if (!ids) return NextResponse.json({ error: 'ids required' }, { status: 400 }); + const taskId = searchParams.get('taskId'); + if (!taskId) return NextResponse.json({ error: 'taskId required' }, { status: 400 }); try { const res = await fetch( - `${apiUrl.replace(/\/$/, '')}/api/get?ids=${encodeURIComponent(ids)}`, + `${apiUrl.replace(/\/$/, '')}/api/v1/generate/record-info?taskId=${encodeURIComponent(taskId)}`, { headers: { Authorization: `Bearer ${apiKey}` } }, ); const data = await res.json().catch(() => null); diff --git a/app/services/music/page.tsx b/app/services/music/page.tsx index 4060dd3..3892c29 100644 --- a/app/services/music/page.tsx +++ b/app/services/music/page.tsx @@ -204,36 +204,31 @@ export default function MusicServicePage() { - {/* BEFORE / AFTER */} -
-
-

- Before vs After -

-

- AI 음악, 왜 다들 어렵다고 할까요? -

-
-
-
😵
-

Before · 대충 뽑은 결과

-
    -
  • • Suno 10번 돌렸는데 다 별로…
  • -
  • • 가사가 이상하게 붙음
  • -
  • • 영상 만들려니 뭐부터 할지 모름
  • -
  • • 유튜브 올려도 조회수 0
  • -
-
-
-
🎯
-

After · 구조를 쓴 결과

-
    -
  • • 프롬프트 1번으로 원하는 무드 적중
  • -
  • • 30분 만에 쇼츠까지 완성
  • -
  • • 저작권·상업 이용 안전 체크
  • -
  • • SEO 템플릿으로 노출 최적화
  • -
-
+ {/* 팩 구성품 */} +
+
+

What's Included

+

팩 구성품

+
+ {[ + { icon: '📄', title: 'Suno 프롬프트 북 (PDF)', desc: '장르·무드·보컬 톤 조합법 20+종. 복붙해서 바로 사용.' }, + { icon: '🎼', title: '구조 템플릿 팩', desc: 'Verse·Chorus·Bridge 파트 설계도 + Suno Custom 모드 입력 예시.' }, + { icon: '🎬', title: 'MV 워크플로우 가이드', desc: 'Midjourney · Runway · Luma로 비트 싱크 영상 만드는 단계별 매뉴얼.' }, + { icon: '⚖️', title: '저작권 & 상업 이용 가이드', desc: 'Suno/Runway 약관 요약 + 수익화 안전 체크리스트.' }, + { icon: '📦', title: '샘플 프로젝트 (프로·마스터)', desc: '완성된 .prj 파일 + 영상. 그대로 수정해 재사용 가능.' }, + { icon: '🔄', title: '12개월 무료 업데이트', desc: 'Suno 신규 모델·기능 반영. 구매자 Notion에서 자동 수령.' }, + ].map((item) => ( +
+
{item.icon}
+
+

{item.title}

+

{item.desc}

+
+
+ ))}
@@ -327,6 +322,39 @@ export default function MusicServicePage() {
+ {/* 추천 대상 */} +
+
+

+ Who It's For +

+

+ 이런 분께 추천드려요 +

+
+
+
🎯
+

이런 분께 딱

+
    +
  • • Suno를 써봤지만 원하는 무드가 안 나오는 분
  • +
  • • 유튜브 쇼츠·릴스 채널 운영 중인 1인 크리에이터
  • +
  • • 브랜드·매장 BGM을 직접 만들고 싶은 소상공인
  • +
  • • AI 음악 워크플로우를 처음부터 제대로 배우고 싶은 분
  • +
+
+
+
🙅
+

이런 분께는 비추

+
    +
  • • 완성된 맞춤곡 파일을 바로 받고 싶은 분 (B2B 문의 권장)
  • +
  • • DAW·마스터링 전문가 수준 튜닝을 원하는 분
  • +
  • • Suno 유료 플랜 가입 의사가 없는 분 (상업 이용 제한)
  • +
+
+
+
+
+ {/* B2B */}
diff --git a/app/studio/page.tsx b/app/studio/page.tsx index abea279..06eae0c 100644 --- a/app/studio/page.tsx +++ b/app/studio/page.tsx @@ -1,21 +1,32 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; type Mode = 'simple' | 'custom'; -type Clip = { + +type SunoClip = { id: string; title?: string; - status?: string; - audio_url?: string; - image_url?: string; - video_url?: string; - metadata?: { tags?: string; prompt?: string; duration?: number }; + 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: 'chirp-v3-5', label: 'v3.5 (고품질)', desc: '가장 풍부한 사운드' }, - { id: 'chirp-v3-0', label: 'v3.0 (균형)', desc: '속도·품질 밸런스' }, + { id: 'V4', label: 'V4 (기본)', desc: '안정적 고품질' }, + { id: 'V4_5', label: 'V4.5', desc: '최신 · 풍부한 디테일' }, + { id: 'V3_5', label: 'V3.5', desc: '빠른 생성' }, ]; const TAG_PRESETS = [ @@ -23,11 +34,14 @@ const TAG_PRESETS = [ 'rock', 'jazz', 'acoustic', 'cinematic', 'synthwave', 'ambient', ]; -const LS_KEY = 'jsm_studio_clip_ids'; +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('chirp-v3-5'); + const [model, setModel] = useState('V4'); const [prompt, setPrompt] = useState(''); const [title, setTitle] = useState(''); const [lyrics, setLyrics] = useState(''); @@ -36,71 +50,68 @@ export default function StudioPage() { const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const [clips, setClips] = useState([]); + const [tasks, setTasks] = useState([]); const pollRef = useRef(null); - const activeIds = useMemo(() => clips.map((c) => c.id).join(','), [clips]); - - const loadFromLS = useCallback(() => { - if (typeof window === 'undefined') return []; - try { - const raw = localStorage.getItem(LS_KEY); - return raw ? (JSON.parse(raw) as string[]) : []; - } catch { - return []; - } - }, []); - const saveToLS = useCallback((ids: string[]) => { if (typeof window === 'undefined') return; - localStorage.setItem(LS_KEY, JSON.stringify(ids.slice(0, 30))); + try { localStorage.setItem(LS_KEY, JSON.stringify(ids.slice(0, 20))); } catch { /* noop */ } }, []); - const fetchStatus = useCallback(async (idsCsv: string) => { - if (!idsCsv) return; + const fetchOne = useCallback(async (taskId: string) => { try { - const res = await fetch(`/api/studio/status?ids=${encodeURIComponent(idsCsv)}`); + const res = await fetch(`/api/studio/status?taskId=${encodeURIComponent(taskId)}`); const json = await res.json(); - if (json.ok && Array.isArray(json.data)) { - setClips((prev) => { - const map = new Map(prev.map((c) => [c.id, c])); - for (const c of json.data as Clip[]) map.set(c.id, { ...map.get(c.id), ...c }); - return Array.from(map.values()); - }); - } + 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 { - /* silent */ + return null; } }, []); - useEffect(() => { - const ids = loadFromLS(); - if (ids.length) { - setClips(ids.map((id) => ({ id, status: 'loading' }))); - fetchStatus(ids.join(',')); - } - }, [loadFromLS, fetchStatus]); + 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(() => { - const pending = clips.some((c) => c.status !== 'complete' && c.status !== 'error'); if (pollRef.current) window.clearInterval(pollRef.current); - if (pending && activeIds) { - pollRef.current = window.setInterval(() => fetchStatus(activeIds), 8000); + 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); - }; - }, [clips, activeIds, fetchStatus]); + 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' && !lyrics.trim() && !instrumental) { - setError('가사를 입력하거나 Instrumental을 켜주세요.'); - return; + 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 { @@ -108,8 +119,7 @@ export default function StudioPage() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - mode, - model, + mode, model, prompt: prompt.trim(), title: title.trim(), lyrics: lyrics.trim(), @@ -119,21 +129,21 @@ export default function StudioPage() { }); const json = await res.json(); if (!res.ok || !json.ok) { - setError(json.error ?? '생성 실패'); + setError(typeof json.error === 'string' ? json.error : '생성 실패'); return; } - const newClips: Clip[] = (Array.isArray(json.data) ? json.data : []).map((c: Clip) => ({ - ...c, - status: c.status ?? 'submitted', - })); - if (!newClips.length) { - setError('응답에 결과가 없습니다. API URL 응답 포맷을 확인하세요.'); + const taskId: string | undefined = json.data?.data?.taskId ?? json.data?.taskId; + if (!taskId) { + setError('응답에서 taskId를 찾지 못했습니다.'); return; } - setClips((prev) => { - const merged = [...newClips, ...prev]; - saveToLS(merged.map((c) => c.id)); - return merged; + 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)); @@ -158,7 +168,6 @@ export default function StudioPage() { }} >
- {/* 헤더 */}
JAENGSEUNG STUDIO @@ -191,7 +200,6 @@ export default function StudioPage() { backdropFilter: 'blur(16px)', }} > - {/* 모드 토글 */}
{(['simple', 'custom'] as Mode[]).map((m) => (