feat: Suno sunoapi.org v1 스펙 적용 + 팩 상세 섹션 재구성
- Suno API: /api/v1/generate (taskId) + record-info 폴링으로 전환 - SUNO_API_URL 기본값 https://api.sunoapi.org, SUNO_API_KEY만 필수 - 모델: V4 / V4_5 / V3_5, customMode·callBackUrl 지원 - 결과 카드: sunoData 배열(오디오·이미지·태그·duration) 렌더 - 팩 상세: 팩 구성품 + 추천 대상 섹션 추가, Before/After 제거
This commit is contained in:
@@ -13,12 +13,12 @@ type GenerateBody = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
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;
|
const apiKey = process.env.SUNO_API_KEY;
|
||||||
|
|
||||||
if (!apiUrl || !apiKey) {
|
if (!apiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Suno API 미설정 (SUNO_API_URL / SUNO_API_KEY 환경변수 필요)' },
|
{ error: 'Suno API 미설정 (SUNO_API_KEY 환경변수 필요)' },
|
||||||
{ status: 503 },
|
{ status: 503 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -30,27 +30,30 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
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 =
|
const isCustom = body.mode === 'custom';
|
||||||
body.mode === 'custom'
|
const payload = isCustom
|
||||||
? {
|
? {
|
||||||
prompt: body.lyrics ?? '',
|
prompt: body.lyrics ?? '',
|
||||||
tags: body.tags ?? '',
|
style: body.tags ?? '',
|
||||||
title: body.title ?? '',
|
title: body.title ?? 'Untitled',
|
||||||
make_instrumental: !!body.make_instrumental,
|
customMode: true,
|
||||||
model: body.model ?? 'chirp-v3-5',
|
instrumental: !!body.make_instrumental,
|
||||||
wait_audio: false,
|
model: body.model ?? 'V4',
|
||||||
|
callBackUrl,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
prompt: body.prompt ?? '',
|
prompt: body.prompt ?? '',
|
||||||
make_instrumental: !!body.make_instrumental,
|
customMode: false,
|
||||||
model: body.model ?? 'chirp-v3-5',
|
instrumental: !!body.make_instrumental,
|
||||||
wait_audio: false,
|
model: body.model ?? 'V4',
|
||||||
|
callBackUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${apiUrl.replace(/\/$/, '')}${endpoint}`, {
|
const res = await fetch(`${apiUrl.replace(/\/$/, '')}/api/v1/generate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -60,10 +63,10 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json().catch(() => null);
|
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(
|
return NextResponse.json(
|
||||||
{ error: '생성 실패', detail: data ?? (await res.text().catch(() => '')) },
|
{ error: '생성 실패', detail: data ?? (await res.text().catch(() => '')) },
|
||||||
{ status: res.status },
|
{ status: res.ok ? 502 : res.status },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return NextResponse.json({ ok: true, data });
|
return NextResponse.json({ ok: true, data });
|
||||||
|
|||||||
@@ -3,23 +3,23 @@ import { NextResponse } from 'next/server';
|
|||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
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;
|
const apiKey = process.env.SUNO_API_KEY;
|
||||||
|
|
||||||
if (!apiUrl || !apiKey) {
|
if (!apiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Suno API 미설정 (SUNO_API_URL / SUNO_API_KEY 환경변수 필요)' },
|
{ error: 'Suno API 미설정 (SUNO_API_KEY 환경변수 필요)' },
|
||||||
{ status: 503 },
|
{ status: 503 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const ids = searchParams.get('ids');
|
const taskId = searchParams.get('taskId');
|
||||||
if (!ids) return NextResponse.json({ error: 'ids required' }, { status: 400 });
|
if (!taskId) return NextResponse.json({ error: 'taskId required' }, { status: 400 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
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}` } },
|
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
||||||
);
|
);
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
|
|||||||
@@ -204,36 +204,31 @@ export default function MusicServicePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* BEFORE / AFTER */}
|
{/* 팩 구성품 */}
|
||||||
<section className="px-6 py-20 lg:px-14 bg-slate-950 border-t border-white/5">
|
<section className="px-6 py-16 lg:px-14 bg-slate-950 border-t border-white/5">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<p className="font-mono text-xs text-violet-300/70 tracking-widest uppercase mb-2 text-center">
|
<p className="font-mono text-xs text-violet-300/70 tracking-widest uppercase mb-2">What's Included</p>
|
||||||
Before vs After
|
<h2 className="text-2xl md:text-3xl font-extrabold mb-8">팩 구성품</h2>
|
||||||
</p>
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
<h2 className="text-3xl md:text-4xl font-extrabold text-center mb-12">
|
{[
|
||||||
AI 음악, 왜 다들 어렵다고 할까요?
|
{ icon: '📄', title: 'Suno 프롬프트 북 (PDF)', desc: '장르·무드·보컬 톤 조합법 20+종. 복붙해서 바로 사용.' },
|
||||||
</h2>
|
{ icon: '🎼', title: '구조 템플릿 팩', desc: 'Verse·Chorus·Bridge 파트 설계도 + Suno Custom 모드 입력 예시.' },
|
||||||
<div className="grid md:grid-cols-2 gap-5">
|
{ icon: '🎬', title: 'MV 워크플로우 가이드', desc: 'Midjourney · Runway · Luma로 비트 싱크 영상 만드는 단계별 매뉴얼.' },
|
||||||
<div className="border border-rose-500/20 bg-rose-500/5 rounded-2xl p-8">
|
{ icon: '⚖️', title: '저작권 & 상업 이용 가이드', desc: 'Suno/Runway 약관 요약 + 수익화 안전 체크리스트.' },
|
||||||
<div className="text-4xl mb-3">😵</div>
|
{ icon: '📦', title: '샘플 프로젝트 (프로·마스터)', desc: '완성된 .prj 파일 + 영상. 그대로 수정해 재사용 가능.' },
|
||||||
<h3 className="font-extrabold text-rose-300 mb-3 text-lg">Before · 대충 뽑은 결과</h3>
|
{ icon: '🔄', title: '12개월 무료 업데이트', desc: 'Suno 신규 모델·기능 반영. 구매자 Notion에서 자동 수령.' },
|
||||||
<ul className="space-y-2 text-sm text-slate-300 leading-relaxed">
|
].map((item) => (
|
||||||
<li>• Suno 10번 돌렸는데 다 별로…</li>
|
<div
|
||||||
<li>• 가사가 이상하게 붙음</li>
|
key={item.title}
|
||||||
<li>• 영상 만들려니 뭐부터 할지 모름</li>
|
className="flex gap-4 p-5 rounded-xl border border-white/10 bg-white/[0.02]"
|
||||||
<li>• 유튜브 올려도 조회수 0</li>
|
>
|
||||||
</ul>
|
<div className="text-2xl flex-shrink-0">{item.icon}</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white mb-1">{item.title}</h3>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">{item.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-emerald-500/30 bg-emerald-500/5 rounded-2xl p-8 shadow-[0_0_40px_rgba(16,185,129,0.15)]">
|
|
||||||
<div className="text-4xl mb-3">🎯</div>
|
|
||||||
<h3 className="font-extrabold text-emerald-300 mb-3 text-lg">After · 구조를 쓴 결과</h3>
|
|
||||||
<ul className="space-y-2 text-sm text-slate-200 leading-relaxed">
|
|
||||||
<li>• 프롬프트 1번으로 원하는 무드 적중</li>
|
|
||||||
<li>• 30분 만에 쇼츠까지 완성</li>
|
|
||||||
<li>• 저작권·상업 이용 안전 체크</li>
|
|
||||||
<li>• SEO 템플릿으로 노출 최적화</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -327,6 +322,39 @@ export default function MusicServicePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* 추천 대상 */}
|
||||||
|
<section className="px-6 py-20 lg:px-14 bg-slate-950 border-t border-white/5">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<p className="font-mono text-xs text-violet-300/70 tracking-widest uppercase mb-2 text-center">
|
||||||
|
Who It's For
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-extrabold text-center mb-12">
|
||||||
|
이런 분께 추천드려요
|
||||||
|
</h2>
|
||||||
|
<div className="grid md:grid-cols-2 gap-5">
|
||||||
|
<div className="border border-emerald-500/30 bg-emerald-500/5 rounded-2xl p-8 shadow-[0_0_40px_rgba(16,185,129,0.12)]">
|
||||||
|
<div className="text-4xl mb-3">🎯</div>
|
||||||
|
<h3 className="font-extrabold text-emerald-300 mb-3 text-lg">이런 분께 딱</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-slate-200 leading-relaxed">
|
||||||
|
<li>• Suno를 써봤지만 원하는 무드가 안 나오는 분</li>
|
||||||
|
<li>• 유튜브 쇼츠·릴스 채널 운영 중인 1인 크리에이터</li>
|
||||||
|
<li>• 브랜드·매장 BGM을 직접 만들고 싶은 소상공인</li>
|
||||||
|
<li>• AI 음악 워크플로우를 처음부터 제대로 배우고 싶은 분</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="border border-white/10 bg-white/[0.02] rounded-2xl p-8">
|
||||||
|
<div className="text-4xl mb-3">🙅</div>
|
||||||
|
<h3 className="font-extrabold text-slate-300 mb-3 text-lg">이런 분께는 비추</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-slate-400 leading-relaxed">
|
||||||
|
<li>• 완성된 맞춤곡 파일을 바로 받고 싶은 분 (B2B 문의 권장)</li>
|
||||||
|
<li>• DAW·마스터링 전문가 수준 튜닝을 원하는 분</li>
|
||||||
|
<li>• Suno 유료 플랜 가입 의사가 없는 분 (상업 이용 제한)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* B2B */}
|
{/* B2B */}
|
||||||
<section className="px-6 py-16 lg:px-14 bg-gradient-to-br from-slate-900 to-slate-950 border-y border-white/5">
|
<section className="px-6 py-16 lg:px-14 bg-gradient-to-br from-slate-900 to-slate-950 border-y border-white/5">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
type Mode = 'simple' | 'custom';
|
type Mode = 'simple' | 'custom';
|
||||||
type Clip = {
|
|
||||||
|
type SunoClip = {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
status?: string;
|
audioUrl?: string;
|
||||||
audio_url?: string;
|
streamAudioUrl?: string;
|
||||||
image_url?: string;
|
imageUrl?: string;
|
||||||
video_url?: string;
|
tags?: string;
|
||||||
metadata?: { tags?: string; prompt?: string; duration?: number };
|
duration?: number;
|
||||||
|
prompt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskState = {
|
||||||
|
taskId: string;
|
||||||
|
status: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
clips: SunoClip[];
|
||||||
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MODELS = [
|
const MODELS = [
|
||||||
{ id: 'chirp-v3-5', label: 'v3.5 (고품질)', desc: '가장 풍부한 사운드' },
|
{ id: 'V4', label: 'V4 (기본)', desc: '안정적 고품질' },
|
||||||
{ id: 'chirp-v3-0', label: 'v3.0 (균형)', desc: '속도·품질 밸런스' },
|
{ id: 'V4_5', label: 'V4.5', desc: '최신 · 풍부한 디테일' },
|
||||||
|
{ id: 'V3_5', label: 'V3.5', desc: '빠른 생성' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TAG_PRESETS = [
|
const TAG_PRESETS = [
|
||||||
@@ -23,11 +34,14 @@ const TAG_PRESETS = [
|
|||||||
'rock', 'jazz', 'acoustic', 'cinematic', 'synthwave', 'ambient',
|
'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() {
|
export default function StudioPage() {
|
||||||
const [mode, setMode] = useState<Mode>('simple');
|
const [mode, setMode] = useState<Mode>('simple');
|
||||||
const [model, setModel] = useState('chirp-v3-5');
|
const [model, setModel] = useState('V4');
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [lyrics, setLyrics] = useState('');
|
const [lyrics, setLyrics] = useState('');
|
||||||
@@ -36,71 +50,68 @@ export default function StudioPage() {
|
|||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [clips, setClips] = useState<Clip[]>([]);
|
const [tasks, setTasks] = useState<TaskState[]>([]);
|
||||||
const pollRef = useRef<number | null>(null);
|
const pollRef = useRef<number | null>(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[]) => {
|
const saveToLS = useCallback((ids: string[]) => {
|
||||||
if (typeof window === 'undefined') return;
|
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) => {
|
const fetchOne = useCallback(async (taskId: string) => {
|
||||||
if (!idsCsv) return;
|
|
||||||
try {
|
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();
|
const json = await res.json();
|
||||||
if (json.ok && Array.isArray(json.data)) {
|
if (!json.ok) return null;
|
||||||
setClips((prev) => {
|
const d = json.data?.data ?? json.data;
|
||||||
const map = new Map<string, Clip>(prev.map((c) => [c.id, c]));
|
const status: string = d?.status ?? 'PENDING';
|
||||||
for (const c of json.data as Clip[]) map.set(c.id, { ...map.get(c.id), ...c });
|
const errMsg: string | undefined = d?.errorMessage;
|
||||||
return Array.from(map.values());
|
const sunoData: SunoClip[] = d?.response?.sunoData ?? [];
|
||||||
});
|
return { taskId, status, errorMessage: errMsg, clips: sunoData, updatedAt: Date.now() } as TaskState;
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* silent */
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const refreshAll = useCallback(async (ids: string[]) => {
|
||||||
const ids = loadFromLS();
|
const results = await Promise.all(ids.map((id) => fetchOne(id)));
|
||||||
if (ids.length) {
|
setTasks((prev) => {
|
||||||
setClips(ids.map((id) => ({ id, status: 'loading' })));
|
const map = new Map(prev.map((t) => [t.taskId, t]));
|
||||||
fetchStatus(ids.join(','));
|
for (const r of results) if (r) map.set(r.taskId, r);
|
||||||
}
|
return Array.from(map.values()).sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
}, [loadFromLS, fetchStatus]);
|
});
|
||||||
|
}, [fetchOne]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pending = clips.some((c) => c.status !== 'complete' && c.status !== 'error');
|
if (typeof window === 'undefined') return;
|
||||||
if (pollRef.current) window.clearInterval(pollRef.current);
|
try {
|
||||||
if (pending && activeIds) {
|
const raw = localStorage.getItem(LS_KEY);
|
||||||
pollRef.current = window.setInterval(() => fetchStatus(activeIds), 8000);
|
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);
|
||||||
}
|
}
|
||||||
return () => {
|
} catch { /* noop */ }
|
||||||
|
}, [refreshAll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (pollRef.current) window.clearInterval(pollRef.current);
|
if (pollRef.current) window.clearInterval(pollRef.current);
|
||||||
};
|
const pending = tasks.filter((t) => !isDone(t.status) && !isFailed(t.status));
|
||||||
}, [clips, activeIds, fetchStatus]);
|
if (pending.length) {
|
||||||
|
pollRef.current = window.setInterval(() => {
|
||||||
|
refreshAll(pending.map((t) => t.taskId));
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
|
||||||
|
}, [tasks, refreshAll]);
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (mode === 'simple' && !prompt.trim()) {
|
if (mode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; }
|
||||||
setError('프롬프트를 입력해주세요.');
|
if (mode === 'custom') {
|
||||||
return;
|
if (!title.trim()) { setError('트랙 제목을 입력해주세요.'); return; }
|
||||||
}
|
if (!tags.trim()) { setError('스타일 태그를 입력해주세요.'); return; }
|
||||||
if (mode === 'custom' && !lyrics.trim() && !instrumental) {
|
if (!lyrics.trim() && !instrumental) { setError('가사를 입력하거나 Instrumental을 켜주세요.'); return; }
|
||||||
setError('가사를 입력하거나 Instrumental을 켜주세요.');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -108,8 +119,7 @@ export default function StudioPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mode,
|
mode, model,
|
||||||
model,
|
|
||||||
prompt: prompt.trim(),
|
prompt: prompt.trim(),
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
lyrics: lyrics.trim(),
|
lyrics: lyrics.trim(),
|
||||||
@@ -119,21 +129,21 @@ export default function StudioPage() {
|
|||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!res.ok || !json.ok) {
|
if (!res.ok || !json.ok) {
|
||||||
setError(json.error ?? '생성 실패');
|
setError(typeof json.error === 'string' ? json.error : '생성 실패');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newClips: Clip[] = (Array.isArray(json.data) ? json.data : []).map((c: Clip) => ({
|
const taskId: string | undefined = json.data?.data?.taskId ?? json.data?.taskId;
|
||||||
...c,
|
if (!taskId) {
|
||||||
status: c.status ?? 'submitted',
|
setError('응답에서 taskId를 찾지 못했습니다.');
|
||||||
}));
|
|
||||||
if (!newClips.length) {
|
|
||||||
setError('응답에 결과가 없습니다. API URL 응답 포맷을 확인하세요.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setClips((prev) => {
|
setTasks((prev) => {
|
||||||
const merged = [...newClips, ...prev];
|
const next: TaskState[] = [
|
||||||
saveToLS(merged.map((c) => c.id));
|
{ taskId, status: 'PENDING', clips: [], updatedAt: Date.now() },
|
||||||
return merged;
|
...prev.filter((t) => t.taskId !== taskId),
|
||||||
|
];
|
||||||
|
saveToLS(next.map((t) => t.taskId));
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
@@ -158,7 +168,6 @@ export default function StudioPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-end justify-between flex-wrap gap-4 mb-8">
|
<div className="flex items-end justify-between flex-wrap gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<span className="kx-label">JAENGSEUNG STUDIO</span>
|
<span className="kx-label">JAENGSEUNG STUDIO</span>
|
||||||
@@ -191,7 +200,6 @@ export default function StudioPage() {
|
|||||||
backdropFilter: 'blur(16px)',
|
backdropFilter: 'blur(16px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 모드 토글 */}
|
|
||||||
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
||||||
{(['simple', 'custom'] as Mode[]).map((m) => (
|
{(['simple', 'custom'] as Mode[]).map((m) => (
|
||||||
<button
|
<button
|
||||||
@@ -201,8 +209,7 @@ export default function StudioPage() {
|
|||||||
style={
|
style={
|
||||||
mode === m
|
mode === m
|
||||||
? {
|
? {
|
||||||
background:
|
background: 'linear-gradient(135deg, rgba(204,151,255,0.25), rgba(83,221,252,0.15))',
|
||||||
'linear-gradient(135deg, rgba(204,151,255,0.25), rgba(83,221,252,0.15))',
|
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 0 24px rgba(204,151,255,0.25) inset',
|
boxShadow: '0 0 24px rgba(204,151,255,0.25) inset',
|
||||||
}
|
}
|
||||||
@@ -276,7 +283,6 @@ export default function StudioPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 공통 옵션 */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 mt-6">
|
<div className="grid grid-cols-2 gap-4 mt-6">
|
||||||
<Field label="모델">
|
<Field label="모델">
|
||||||
<select
|
<select
|
||||||
@@ -316,7 +322,6 @@ export default function StudioPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Generate */}
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<button
|
<button
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
@@ -358,12 +363,9 @@ export default function StudioPage() {
|
|||||||
<span className="kx-label">RECENT TRACKS</span>
|
<span className="kx-label">RECENT TRACKS</span>
|
||||||
<h2 className="kx-display text-xl font-bold mt-1">최근 생성 결과</h2>
|
<h2 className="kx-display text-xl font-bold mt-1">최근 생성 결과</h2>
|
||||||
</div>
|
</div>
|
||||||
{clips.length > 0 && (
|
{tasks.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { setTasks([]); saveToLS([]); }}
|
||||||
setClips([]);
|
|
||||||
saveToLS([]);
|
|
||||||
}}
|
|
||||||
className="text-[11px] underline underline-offset-4"
|
className="text-[11px] underline underline-offset-4"
|
||||||
style={{ color: 'var(--kx-on-variant)' }}
|
style={{ color: 'var(--kx-on-variant)' }}
|
||||||
>
|
>
|
||||||
@@ -372,7 +374,7 @@ export default function StudioPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{clips.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className="rounded-xl p-8 text-center text-sm"
|
className="rounded-xl p-8 text-center text-sm"
|
||||||
style={{ border: '1px dashed rgba(255,255,255,0.1)', color: 'var(--kx-on-variant)' }}
|
style={{ border: '1px dashed rgba(255,255,255,0.1)', color: 'var(--kx-on-variant)' }}
|
||||||
@@ -381,56 +383,83 @@ export default function StudioPage() {
|
|||||||
<br />왼쪽에서 프롬프트를 입력하고 Generate를 눌러보세요.
|
<br />왼쪽에서 프롬프트를 입력하고 Generate를 눌러보세요.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3 max-h-[620px] overflow-y-auto pr-1">
|
<ul className="space-y-4 max-h-[640px] overflow-y-auto pr-1">
|
||||||
{clips.map((c) => (
|
{tasks.map((task) => (
|
||||||
<li
|
<li
|
||||||
key={c.id}
|
key={task.taskId}
|
||||||
className="rounded-xl p-4 transition"
|
className="rounded-xl p-4"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(20,31,56,0.6)',
|
background: 'rgba(20,31,56,0.6)',
|
||||||
border: '1px solid rgba(255,255,255,0.05)',
|
border: '1px solid rgba(255,255,255,0.05)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<div className="min-w-0">
|
<span className="text-[11px] font-mono opacity-60">task: {task.taskId.slice(0, 10)}…</span>
|
||||||
<p className="font-semibold truncate" style={{ color: 'var(--kx-on-surface)' }}>
|
<StatusBadge status={task.status} />
|
||||||
{c.title || '제목 없음'}
|
|
||||||
</p>
|
|
||||||
{c.metadata?.tags && (
|
|
||||||
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--kx-on-variant)' }}>
|
|
||||||
{c.metadata.tags}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={c.status} />
|
|
||||||
</div>
|
{task.clips.length === 0 ? (
|
||||||
{c.audio_url ? (
|
|
||||||
<audio controls src={c.audio_url} className="w-full mt-2" style={{ height: 36 }} />
|
|
||||||
) : (
|
|
||||||
<div
|
<div
|
||||||
className="h-9 rounded-md flex items-center justify-center text-xs"
|
className="h-9 rounded-md flex items-center justify-center text-xs"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background: 'linear-gradient(90deg, rgba(204,151,255,0.08) 0%, rgba(83,221,252,0.08) 100%)',
|
||||||
'linear-gradient(90deg, rgba(204,151,255,0.08) 0%, rgba(83,221,252,0.08) 100%)',
|
|
||||||
color: 'var(--kx-on-variant)',
|
color: 'var(--kx-on-variant)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
오디오 생성 중… (보통 1~3분)
|
{isFailed(task.status)
|
||||||
|
? (task.errorMessage ?? '생성 실패')
|
||||||
|
: '오디오 생성 중… (보통 1~3분)'}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{task.clips.map((c) => {
|
||||||
|
const src = c.audioUrl || c.streamAudioUrl;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="rounded-lg p-3"
|
||||||
|
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.04)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{c.imageUrl && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={c.imageUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-12 h-12 rounded-md object-cover flex-shrink-0"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3 mt-2 text-[11px]" style={{ color: 'var(--kx-on-variant)' }}>
|
<div className="min-w-0 flex-1">
|
||||||
{c.audio_url && (
|
<p className="font-semibold text-sm truncate" style={{ color: 'var(--kx-on-surface)' }}>
|
||||||
<a href={c.audio_url} download className="underline underline-offset-4 hover:text-white">
|
{c.title || '제목 없음'}
|
||||||
|
</p>
|
||||||
|
{c.tags && (
|
||||||
|
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--kx-on-variant)' }}>
|
||||||
|
{c.tags}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{c.duration && (
|
||||||
|
<span className="text-[10px] font-mono opacity-60">
|
||||||
|
{Math.round(c.duration)}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{src ? (
|
||||||
|
<audio controls src={src} className="w-full mt-2" style={{ height: 36 }} />
|
||||||
|
) : null}
|
||||||
|
{c.audioUrl && (
|
||||||
|
<div className="mt-1.5 text-[11px]" style={{ color: 'var(--kx-on-variant)' }}>
|
||||||
|
<a href={c.audioUrl} download className="underline underline-offset-4 hover:text-white">
|
||||||
MP3 다운로드
|
MP3 다운로드
|
||||||
</a>
|
</a>
|
||||||
)}
|
|
||||||
{c.video_url && (
|
|
||||||
<a href={c.video_url} target="_blank" rel="noreferrer" className="underline underline-offset-4 hover:text-white">
|
|
||||||
영상 보기
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<span className="opacity-50">id: {c.id.slice(0, 8)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -438,7 +467,6 @@ export default function StudioPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단: 가이드 */}
|
|
||||||
<div className="mt-10 grid md:grid-cols-3 gap-4 text-xs" style={{ color: 'var(--kx-on-variant)' }}>
|
<div className="mt-10 grid md:grid-cols-3 gap-4 text-xs" style={{ color: 'var(--kx-on-variant)' }}>
|
||||||
<Tip title="① 간단 모드" body="한 줄 프롬프트로 즉시 생성. 결과물 다양성 높음." />
|
<Tip title="① 간단 모드" body="한 줄 프롬프트로 즉시 생성. 결과물 다양성 높음." />
|
||||||
<Tip title="② Custom 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
|
<Tip title="② Custom 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
|
||||||
@@ -477,21 +505,25 @@ function Field({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status?: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const map: Record<string, { bg: string; fg: string; label: string }> = {
|
const map: Record<string, { bg: string; fg: string; label: string }> = {
|
||||||
complete: { bg: 'rgba(64,206,172,0.18)', fg: '#6cf0c6', label: '완료' },
|
SUCCESS: { bg: 'rgba(64,206,172,0.18)', fg: '#6cf0c6', label: '완료' },
|
||||||
streaming: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '스트리밍' },
|
FIRST_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '첫 트랙 준비' },
|
||||||
submitted: { bg: 'rgba(204,151,255,0.18)', fg: '#cc97ff', label: '대기' },
|
TEXT_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '가사 완료' },
|
||||||
queued: { bg: 'rgba(204,151,255,0.18)', fg: '#cc97ff', label: '큐' },
|
PENDING: { 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 ?? '…' };
|
let entry = map[status];
|
||||||
|
if (!entry) {
|
||||||
|
entry = isFailed(status)
|
||||||
|
? { bg: 'rgba(215,51,87,0.18)', fg: '#ff8ba7', label: '실패' }
|
||||||
|
: { bg: 'rgba(255,255,255,0.06)', fg: 'rgba(255,255,255,0.6)', label: status };
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap"
|
className="text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap"
|
||||||
style={{ background: s.bg, color: s.fg }}
|
style={{ background: entry.bg, color: entry.fg }}
|
||||||
>
|
>
|
||||||
{s.label}
|
{entry.label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user