diff --git a/app/api/studio/generate/route.ts b/app/api/studio/generate/route.ts new file mode 100644 index 0000000..7f5aaca --- /dev/null +++ b/app/api/studio/generate/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +type GenerateBody = { + mode: 'simple' | 'custom'; + prompt?: string; + title?: string; + lyrics?: string; + tags?: string; + make_instrumental?: boolean; + model?: string; +}; + +export async function POST(request: Request) { + const apiUrl = process.env.SUNO_API_URL; + const apiKey = process.env.SUNO_API_KEY; + + if (!apiUrl || !apiKey) { + return NextResponse.json( + { error: 'Suno API 미설정 (SUNO_API_URL / SUNO_API_KEY 환경변수 필요)' }, + { status: 503 }, + ); + } + + let body: GenerateBody; + try { + body = (await request.json()) as GenerateBody; + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const endpoint = body.mode === 'custom' ? '/api/custom_generate' : '/api/generate'; + + 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, + }; + + try { + const res = await fetch(`${apiUrl.replace(/\/$/, '')}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(payload), + }); + + const data = await res.json().catch(() => null); + if (!res.ok) { + return NextResponse.json( + { error: '생성 실패', detail: data ?? (await res.text().catch(() => '')) }, + { status: res.status }, + ); + } + return NextResponse.json({ ok: true, data }); + } catch (e) { + return NextResponse.json( + { error: 'Suno API 호출 오류', detail: e instanceof Error ? e.message : String(e) }, + { status: 502 }, + ); + } +} diff --git a/app/api/studio/status/route.ts b/app/api/studio/status/route.ts new file mode 100644 index 0000000..51587a9 --- /dev/null +++ b/app/api/studio/status/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +export async function GET(request: Request) { + const apiUrl = process.env.SUNO_API_URL; + const apiKey = process.env.SUNO_API_KEY; + + if (!apiUrl || !apiKey) { + return NextResponse.json( + { error: 'Suno API 미설정 (SUNO_API_URL / 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 }); + + try { + const res = await fetch( + `${apiUrl.replace(/\/$/, '')}/api/get?ids=${encodeURIComponent(ids)}`, + { headers: { Authorization: `Bearer ${apiKey}` } }, + ); + const data = await res.json().catch(() => null); + if (!res.ok) { + return NextResponse.json( + { error: '조회 실패', detail: data }, + { status: res.status }, + ); + } + return NextResponse.json({ ok: true, data }); + } catch (e) { + return NextResponse.json( + { error: '조회 오류', detail: e instanceof Error ? e.message : String(e) }, + { status: 502 }, + ); + } +} diff --git a/app/components/TopNav.tsx b/app/components/TopNav.tsx index f9a76da..3eeaaf4 100644 --- a/app/components/TopNav.tsx +++ b/app/components/TopNav.tsx @@ -7,8 +7,8 @@ import { useState, useEffect } from 'react'; const LINKS = [ { href: '/', label: '홈' }, { href: '/services/music/samples', label: '샘플' }, - { href: '/services/music#pricing', label: '가격' }, { href: '/services/music', label: '팩 상세' }, + { href: '/studio', label: '스튜디오' }, ]; export default function TopNav() { diff --git a/app/services/music/page.tsx b/app/services/music/page.tsx index b8dbfb8..4060dd3 100644 --- a/app/services/music/page.tsx +++ b/app/services/music/page.tsx @@ -118,89 +118,89 @@ export default function MusicServicePage() { return (
- {/* HERO */} -
-
\")", - }} - /> - -
-
+ {/* 상세 페이지 헤더 (컴팩트) */} +
+
+
- AI MUSIC PACK · v1 + AI MUSIC PACK · v1 · 상품 상세
- -

- 네 사연을 노래로. -
- - 쇼츠까지 한 번에. - +

+ AI 음악 마스터 구조 팩

- -

- AI로 음악을 뽑는 게 아니라, 고품질 결과물을 빠르게 뽑는 법을 팝니다. +

+ Suno 프롬프트 + MV 워크플로우 + 저작권 가이드 + 템플릿 PDF + 샘플 프로젝트. + 4단계 AI 음악 제작 공정을 한 팩으로.

-

- 엔지니어가 설계한 4단계 AI 음악 공정 · Suno Pro 검증. -

- - - -
- ✅ 평생 업데이트 - ✅ 즉시 다운로드 - ✅ Suno Pro 검증 샘플 -
+
- {/* Bottom waveform */} -
- - - - - - - - - - + {/* PRICING — 상세 최상단 */} +
+
+
+
+

Pricing · 1회 결제

+

3개 티어, 목표에 맞게 선택

+
+ + 샘플 먼저 보기 → + +
+ +
+ {(Object.keys(TIERS) as Tier[]).map((key) => { + const t = TIERS[key]; + return ( +
+ {t.highlight && ( +
+ + 🔥 가장 많이 팔림 + +
+ )} +

{t.name}

+

{t.desc}

+
+ {t.price} + 1회 결제 +
+
    + {t.features.map((f) => ( +
  • + + + + {f} +
  • + ))} +
+ +
+ ); + })} +
+

+ 구매 전 환불 정책을 반드시 확인해주세요. + 디지털 콘텐츠 특성상 제공 시작 후 청약철회가 제한됩니다. +

@@ -327,73 +327,6 @@ export default function MusicServicePage() {
- {/* PRICING */} -
-
-

- Pricing -

-

- 3개 티어, 내 목표에 맞게. -

-

한 번 결제로 평생 업데이트.

- -
- {(Object.keys(TIERS) as Tier[]).map((key) => { - const t = TIERS[key]; - return ( -
- {t.highlight && ( -
- - 🔥 80%가 선택 - -
- )} -

{t.name}

-

{t.desc}

-
- {t.price} - 1회 결제 -
-
    - {t.features.map((f) => ( -
  • - - - - {f} -
  • - ))} -
- -
- ); - })} -
-

- 구매 전 환불 정책을 반드시 확인해주세요. - 디지털 콘텐츠 특성상 제공 시작 후 청약철회가 제한됩니다. -

-
-
- {/* B2B */}
diff --git a/app/studio/page.tsx b/app/studio/page.tsx new file mode 100644 index 0000000..abea279 --- /dev/null +++ b/app/studio/page.tsx @@ -0,0 +1,511 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +type Mode = 'simple' | 'custom'; +type Clip = { + id: string; + title?: string; + status?: string; + audio_url?: string; + image_url?: string; + video_url?: string; + metadata?: { tags?: string; prompt?: string; duration?: number }; +}; + +const MODELS = [ + { id: 'chirp-v3-5', label: 'v3.5 (고품질)', desc: '가장 풍부한 사운드' }, + { id: 'chirp-v3-0', label: 'v3.0 (균형)', desc: '속도·품질 밸런스' }, +]; + +const TAG_PRESETS = [ + 'k-pop', 'lo-fi', 'city pop', 'ballad', 'edm', 'trap', + 'rock', 'jazz', 'acoustic', 'cinematic', 'synthwave', 'ambient', +]; + +const LS_KEY = 'jsm_studio_clip_ids'; + +export default function StudioPage() { + const [mode, setMode] = useState('simple'); + const [model, setModel] = useState('chirp-v3-5'); + 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 [clips, setClips] = 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))); + }, []); + + const fetchStatus = useCallback(async (idsCsv: string) => { + if (!idsCsv) return; + try { + const res = await fetch(`/api/studio/status?ids=${encodeURIComponent(idsCsv)}`); + 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()); + }); + } + } catch { + /* silent */ + } + }, []); + + useEffect(() => { + const ids = loadFromLS(); + if (ids.length) { + setClips(ids.map((id) => ({ id, status: 'loading' }))); + fetchStatus(ids.join(',')); + } + }, [loadFromLS, fetchStatus]); + + 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); + } + return () => { + if (pollRef.current) window.clearInterval(pollRef.current); + }; + }, [clips, activeIds, fetchStatus]); + + const onSubmit = async () => { + setError(null); + if (mode === 'simple' && !prompt.trim()) { + setError('프롬프트를 입력해주세요.'); + return; + } + if (mode === 'custom' && !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(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 응답 포맷을 확인하세요.'); + return; + } + setClips((prev) => { + const merged = [...newClips, ...prev]; + saveToLS(merged.map((c) => c.id)); + return merged; + }); + } 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' ? ( +
+ +