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' ? (
+
+
+
+
+ ) : (
+
+
+ setTitle(e.target.value)}
+ placeholder="예: 새벽 세 시의 도시"
+ className="w-full bg-transparent outline-none text-base"
+ style={{ color: 'var(--kx-on-surface)' }}
+ />
+
+
+
+
+ setTags(e.target.value)}
+ placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
+ className="w-full bg-transparent outline-none text-base"
+ style={{ color: 'var(--kx-on-surface)' }}
+ />
+
+ {TAG_PRESETS.map((t) => (
+
+ ))}
+
+
+
+ )}
+
+ {/* 공통 옵션 */}
+
+
+
+
+
+
+
+
+
+ {/* Generate */}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ 생성된 결과는 Suno 서비스 약관을 따릅니다. 상업 이용 전 플랜·저작권을 반드시 확인하세요.
+
+
+
+
+ {/* 우측: 결과 */}
+
+
+
+ RECENT TRACKS
+
최근 생성 결과
+
+ {clips.length > 0 && (
+
+ )}
+
+
+ {clips.length === 0 ? (
+
+ 아직 생성된 트랙이 없습니다.
+
왼쪽에서 프롬프트를 입력하고 Generate를 눌러보세요.
+
+ ) : (
+
+ {clips.map((c) => (
+ -
+
+
+
+ {c.title || '제목 없음'}
+
+ {c.metadata?.tags && (
+
+ {c.metadata.tags}
+
+ )}
+
+
+
+ {c.audio_url ? (
+
+ ) : (
+
+ 오디오 생성 중… (보통 1~3분)
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ {/* 하단: 가이드 */}
+
+
+
+
+
+
+
+ );
+}
+
+function Field({
+ label,
+ hint,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {label}
+
+ {hint && {hint}}
+
+ {children}
+
+ );
+}
+
+function StatusBadge({ status }: { status?: string }) {
+ const map: Record = {
+ complete: { bg: 'rgba(64,206,172,0.18)', fg: '#6cf0c6', label: '완료' },
+ streaming: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '스트리밍' },
+ submitted: { bg: 'rgba(204,151,255,0.18)', fg: '#cc97ff', label: '대기' },
+ queued: { bg: 'rgba(204,151,255,0.18)', fg: '#cc97ff', label: '큐' },
+ error: { bg: 'rgba(215,51,87,0.18)', fg: '#ff8ba7', label: '오류' },
+ };
+ const s = map[status ?? ''] ?? { bg: 'rgba(255,255,255,0.06)', fg: 'rgba(255,255,255,0.6)', label: status ?? '…' };
+ return (
+
+ {s.label}
+
+ );
+}
+
+function Tip({ title, body }: { title: string; body: string }) {
+ return (
+
+ );
+}