Compare commits

...

19 Commits

Author SHA1 Message Date
9af71ddd9a docs(phase3b): CLAUDE.md — 음악 영상화 발주 플로우 반영 2026-07-09 23:21:45 +09:00
65a8680adc feat(phase3b): admin 영상화 납품 — 연결 트랙 표시 + 영상 URL 입력
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-09 23:15:55 +09:00
6b6f1dbe0e feat(phase3b): 마이페이지 음악 영상화 신청·상태 + 모달 트랙 연결
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-09 23:05:17 +09:00
c5c9487874 fix(phase3b): orders 영상화 트랙 update·조회 error 로깅(파일 내 규약 일치) 2026-07-09 22:59:27 +09:00
c9b8d2df5c feat(phase3b): orders에 영상화(music_video) 발주 + video-product 조회 2026-07-09 22:53:02 +09:00
853f96b405 feat(phase3b): music_video 제품 시드 + music_tracks video 컬럼 마이그레이션
Phase 3b Task 1: DB 마이그레이션
- products에 'music_video' 제품 생성 (30000원)
- music_tracks에 video_status/video_url/video_order_id 컬럼 추가

의존성: 2026-07-03-phase3a-music.sql 적용 후 실행

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-09 12:56:37 +09:00
1949f5c692 @
docs(phase3b): 음악 영상화 발주 구현 플랜 (5 Task)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
@
2026-07-09 12:54:14 +09:00
bbbc61f402 docs(phase3b): 음악 영상화 유료 발주 설계 + 향후 로드맵 기록 (WS1~5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-09 12:43:01 +09:00
6a67a9d812 @
chore(music): /music 카피 무료 스토리→음악 포지셔닝으로 정리 — 구 유료 팩 카피·packs 카드 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
@
2026-07-04 01:26:09 +09:00
677012a9c8 fix(phase3a): music generate — recordUsage 집계 실패가 성공 생성을 502로 가리지 않게 (일일제한 우회 방지)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:40:30 +09:00
468ee84687 feat(phase3a): TopNav 음악 + 마이페이지 AI기록 음악 통합 + CLAUDE.md
- TopNav LINKS에 /music(음악) 추가 — 외주/소프트웨어/제작사례/사주/타로/음악 6링크
- mypage AI 기록 탭에 음악 트랙 병합: MusicTrackRow 타입 + /api/studio/tracks 로드
  + MusicAiCard(제목·스토리 요약·<audio controls>) + 빈 상태 CTA에 /music 추가
  (기존 사주·타로 렌더·로직은 미변경)
- CLAUDE.md: /music 공개 전환 반영(숨김 서비스 표에서 제거), api/studio/{story,tracks,callback}
  · lib/music/story-prompt.ts 파일 구조 반영, /mypage 5탭 서술(AI 기록: 사주·타로·음악)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:29:51 +09:00
39025fc57b feat(phase3a): 음악 스튜디오 라이트 재스킨 + 스토리→음악 흐름
- 다크/gradient/violet/purple/blur/이모지 전부 제거, --jsm 토큰 기반 라이트 UI로 재구성
  (폼 필드 bg-white+jsm-line 보더+jsm-accent 포커스, navy 없이 flat 카드)
- 스토리 우선 흐름 신설: 이야기 textarea → "가사 만들기"(POST /api/studio/story) →
  제목/가사/스타일 편집 가능 미리보기 → "음악 만들기"(POST /api/studio/generate, custom 모드)
- 401/429/503 각각 로그인 CTA·제한 안내·서비스 준비중 메시지로 분기 처리
- 기존 simple/custom 직접 입력 모드는 "직접 입력" 탭으로 보존, taskId 폴링 로직 그대로 유지
- 생성 완료 시 오디오 플레이어 노출 + 로그인 사용자는 POST /api/studio/tracks로 best-effort
  자동 저장(세션 내 생성 트랙만 대상, 실패해도 재생에는 영향 없음)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:21:05 +09:00
7cd63a3868 feat(phase3a): 음악 랜딩·샘플 라이트 재스킨
app/music/page.tsx·app/music/samples/page.tsx 순수 시각 변경(className/style만).
다크 히어로(bg-black/gradient)와 샘플 카드의 violet 그라데이션·블러 글래스
CTA 밴드를 --jsm-navy/accent/accent-soft/surface-alt/line/ink 토큰으로 치환해
사주·showcase 재스킨과 동일한 navy 밴드 무테두리 flat + 흰 CTA 관용구로 정렬.
영상 이모지는 인라인 SVG 아이콘으로 교체. 데이터·구조는 변경 없음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:09:27 +09:00
895b33d83d feat(phase3a): 음악 트랙 저장·조회 API (user_id + RLS)
- POST: 로그인 필수, createAdminClient로 music_tracks insert
- GET: 세션 클라이언트로 본인 것만(RLS music_select_own) 조회, 최신순
- JSON 파싱 try/catch(400), 필드 검증(str 헬퍼)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:02:22 +09:00
2aa424f3ce feat(phase3a): 스토리→가사(Gemini) + generate 인증·일일제한 + callback 정리
- lib/music/story-prompt.ts: MusicStory 스키마 + Gemini 응답 파싱/검증(타로 prompt.ts 방어 패턴 포팅)
- app/api/studio/story/route.ts: 로그인 인증 후 Gemini 모델 폴백(2.5-pro→2.5-flash→2.0-flash)으로 가사 JSON 생성. 일일 사용량은 미집계(생성 확정 전 초안 단계)
- app/api/studio/callback/route.ts: Suno webhook 수신용 최소 200 응답 엔드포인트
- app/api/studio/generate/route.ts: 인증(401) + 일일 제한(429, MUSIC_DAILY_LIMIT) 추가, Suno 생성 성공 시에만 recordUsage('music') 기록

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:56:58 +09:00
0742059db2 feat(phase3a): 음악 서비스 공개화 — 가드·HideableService·DEFAULT_SERVICES 정리
- app/music/layout.tsx: isServiceVisible/notFound 제거, metadata 유지
- lib/service-visibility.ts: HideableService type에서 'music' 제거 (gyeol|lotto만 유지)
- app/api/admin/services/route.ts: DEFAULT_SERVICES에서 music 행 제거

/music* 라우트가 이제 공개(static) 상태로 노출됨.
service_settings music DELETE는 Task 1 마이그레이션이 담당.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:51:54 +09:00
7100842179 feat(phase3a): ai-usage에 music 추가 + music_tracks·CHECK 마이그레이션
- lib/ai-usage.ts: MUSIC_DAILY_LIMIT=1 추가, AiService 타입에 'music' 포함
- lib/__tests__/ai-usage.test.ts: MUSIC_DAILY_LIMIT 상수 검증 테스트 추가
- supabase/migrations/2026-07-03-phase3a-music.sql: music_tracks 테이블, CHECK 확장, service_settings 정리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-03 12:48:42 +09:00
da33254076 @
docs(phase3a): 음악 공개화 구현 플랜 (7 Task)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
@
2026-07-03 12:47:16 +09:00
a1a281d059 docs(phase3a): 음악 서비스 공개화 설계 — 스토리→음악·무료·회원저장 (WS1~5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-03 12:33:55 +09:00
27 changed files with 2236 additions and 315 deletions

View File

@@ -21,10 +21,11 @@
| `/showcase` | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 | | `/showcase` | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 |
| `/work/saju` | 사주 분석 — 공개 AI 사주 (로그인 시 무료 해석 1회/일) | | `/work/saju` | 사주 분석 — 공개 AI 사주 (로그인 시 무료 해석 1회/일) |
| `/tarot` | 타로 — 3카드 셔플·해석 (비로그인 카드 리딩, 로그인 AI 인사이트) | | `/tarot` | 타로 — 3카드 셔플·해석 (비로그인 카드 리딩, 로그인 AI 인사이트) |
| `/music` | 공개 음악 — 스토리→음악 AI 스튜디오 (studio·samples, 로그인 시 생성·저장) |
| `/track/[token]` | 비회원 의뢰 진행 추적 | | `/track/[token]` | 비회원 의뢰 진행 추적 |
| `/quote/[token]` | 공개 견적 — 고객 수락/거절 | | `/quote/[token]` | 공개 견적 — 고객 수락/거절 |
| `/login` | 로그인 (`?next=` 리다이렉트 지원) | | `/login` | 로그인 (`?next=` 리다이렉트 지원) |
| `/mypage` | 4탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 | | `/mypage` | 5탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 / AI 기록(사주·타로·음악 병합) |
| `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 | | `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 |
## 숨김 서비스 (admin_token 세션 전용) ## 숨김 서비스 (admin_token 세션 전용)
@@ -33,7 +34,6 @@ admin/services 패널에서 ON/OFF 전환 가능.
| 경로 | 서비스 | | 경로 | 서비스 |
|------|--------| |------|--------|
| `/music/*` | 음악 팩 (단, `/music/packs``/products`로 308 리다이렉트) |
| `/gyeol` | CONTOUR PMF 설문 | | `/gyeol` | CONTOUR PMF 설문 |
## 기술 스택 ## 기술 스택
@@ -93,9 +93,13 @@ app/
saju/analyze/route.ts — 사주 AI 분석 (Gemini) saju/analyze/route.ts — 사주 AI 분석 (Gemini)
tarot/interpret/route.ts — 타로 AI 인사이트 (로그인·일 3회 제한) tarot/interpret/route.ts — 타로 AI 인사이트 (로그인·일 3회 제한)
tarot/readings/route.ts — 타로 리딩 저장·조회 (tarot_readings) tarot/readings/route.ts — 타로 리딩 저장·조회 (tarot_readings)
studio/story/route.ts — POST: 스토리→가사 생성 (Gemini, 로그인 필요)
studio/tracks/route.ts — GET/POST: 음악 트랙 저장·조회 (music_tracks, 본인 것만·video 상태 포함)
studio/callback/route.ts — POST: Suno webhook 수신용 최소 엔드포인트
studio/video-product/route.ts — GET: 음악 영상화(music_video) 제품 조회 (로그인, 모달 가격 표시용)
work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일) work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일)
tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석) tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석)
music/ — 숨김: 음악 팩 (packs는 /products로 308) music/ — 공개: 스토리→음악 AI 스튜디오 (studio·samples, packs는 /products로 308)
gyeol/ — 숨김: CONTOUR PMF 설문 gyeol/ — 숨김: CONTOUR PMF 설문
lib/ lib/
@@ -116,6 +120,8 @@ lib/
shuffle.ts — 셔플·3카드 드로우 로직 shuffle.ts — 셔플·3카드 드로우 로직
reference.ts — 카드 의미 레퍼런스 reference.ts — 카드 의미 레퍼런스
prompt.ts — AI 해석 프롬프트 prompt.ts — AI 해석 프롬프트
music/
story-prompt.ts — 스토리→가사 AI 프롬프트 (시스템 프롬프트·JSON 파싱·검증)
``` ```
--- ---
@@ -162,6 +168,28 @@ lib/
- `lib/product-access.ts`: orders 기반 접근 + music tier 하위 호환 - `lib/product-access.ts`: orders 기반 접근 + music tier 하위 호환
### 음악 영상화 발주 (music_video, 유료 계좌이체) — Phase 3b
```
회원이 저장한 음악 트랙 (마이페이지 → AI 기록 탭 음악 카드)
→ "영상화 신청" → GET /api/studio/video-product (고정가 ₩30,000)
→ BankTransferModal (musicTrackId 전달) → POST /api/orders {productId:'music_video', musicTrackId}
→ orders 생성 (metadata.music_track_id) + music_tracks.video_status='requested'
→ 입금 안내 메일 (기존 계좌이체 흐름 재사용)
관리자 납품 (/admin/orders)
→ music_video 주문에 연결 트랙(title·audio) 표시
→ 영상 URL 입력 + "납품 완료" → PATCH {id, status:'paid', videoUrl}
→ music_tracks.video_url 세팅 + video_status='delivered'
→ (선택) "제작 중" → PATCH {id, videoStatus:'in_progress'}
고객 확인 (마이페이지 음악 카드)
→ video_status 뱃지(requested/in_progress) → delivered 시 <video> 재생 + 다운로드
```
- `music_tracks` video 컬럼: `video_status`(none|requested|in_progress|delivered) · `video_url` · `video_order_id`
- 기존 orders/계좌이체/admin/메일 인프라 재사용 — 일반 상품 발주 경로 무변경
--- ---
## 개발 규칙 ## 개발 규칙

View File

@@ -2,6 +2,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
interface MusicTrack {
id: string;
title: string | null;
audio_url: string | null;
video_status: string;
video_url: string | null;
}
interface Order { interface Order {
id: string; id: string;
user_id: string | null; user_id: string | null;
@@ -12,8 +20,16 @@ interface Order {
created_at: string; created_at: string;
product_name: string | null; product_name: string | null;
customer_email: string | null; customer_email: string | null;
musicTrack?: MusicTrack | null;
} }
const VIDEO_STATUS_LABELS: Record<string, { label: string; color: string }> = {
none: { label: '미신청', color: 'bg-slate-700/60 text-slate-500' },
requested: { label: '신청됨', color: 'bg-yellow-900/40 text-yellow-400' },
in_progress: { label: '제작 중', color: 'bg-blue-900/40 text-blue-400' },
delivered: { label: '납품 완료', color: 'bg-green-900/40 text-green-400' },
};
const STATUS_LABELS: Record<string, { label: string; color: string }> = { const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '입금 대기', color: 'bg-yellow-900/40 text-yellow-400' }, pending: { label: '입금 대기', color: 'bg-yellow-900/40 text-yellow-400' },
paid: { label: '완료', color: 'bg-green-900/40 text-green-400' }, paid: { label: '완료', color: 'bg-green-900/40 text-green-400' },
@@ -33,6 +49,7 @@ export default function AdminOrdersPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('all'); const [filterStatus, setFilterStatus] = useState<string>('all');
const [updating, setUpdating] = useState<string | null>(null); const [updating, setUpdating] = useState<string | null>(null);
const [videoUrls, setVideoUrls] = useState<Record<string, string>>({});
async function loadOrders() { async function loadOrders() {
try { try {
@@ -82,6 +99,73 @@ export default function AdminOrdersPage() {
} }
} }
async function deliverVideo(order: Order) {
const videoUrl = (videoUrls[order.id] ?? '').trim();
if (!videoUrl) {
alert('영상 URL을 입력해주세요.');
return;
}
const ok = confirm('영상 납품을 완료 처리하시겠습니까? 고객에게 다운로드 활성화 메일이 발송됩니다.');
if (!ok) return;
setUpdating(order.id);
try {
const res = await fetch('/api/admin/orders', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: order.id, status: 'paid', videoUrl }),
});
if (res.ok) {
setOrders((prev) =>
prev.map((o) =>
o.id === order.id
? {
...o,
status: 'paid',
musicTrack: o.musicTrack
? { ...o.musicTrack, video_status: 'delivered', video_url: videoUrl }
: o.musicTrack,
}
: o
)
);
} else {
alert('납품 처리에 실패했습니다.');
}
} catch (e) {
console.error(e);
alert('네트워크 오류가 발생했습니다.');
} finally {
setUpdating(null);
}
}
async function markVideoInProgress(orderId: string) {
setUpdating(orderId);
try {
const res = await fetch('/api/admin/orders', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: orderId, videoStatus: 'in_progress' }),
});
if (res.ok) {
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? { ...o, musicTrack: o.musicTrack ? { ...o.musicTrack, video_status: 'in_progress' } : o.musicTrack }
: o
)
);
} else {
alert('상태 변경에 실패했습니다.');
}
} catch (e) {
console.error(e);
alert('네트워크 오류가 발생했습니다.');
} finally {
setUpdating(null);
}
}
const filtered = orders.filter((o) => filterStatus === 'all' || o.status === filterStatus); const filtered = orders.filter((o) => filterStatus === 'all' || o.status === filterStatus);
const pendingCount = orders.filter((o) => o.status === 'pending').length; const pendingCount = orders.filter((o) => o.status === 'pending').length;
@@ -212,6 +296,55 @@ export default function AdminOrdersPage() {
)} )}
</div> </div>
</div> </div>
{order.product_id === 'music_video' && (
<div className="mt-3 pt-3 border-t border-slate-800 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-slate-500 flex-shrink-0"> :</span>
<span className="text-sm text-white truncate">
{order.musicTrack?.title ?? '(트랙 정보 없음)'}
</span>
{order.musicTrack?.audio_url && (
<audio controls src={order.musicTrack.audio_url} className="h-8" />
)}
{order.musicTrack && (
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${
VIDEO_STATUS_LABELS[order.musicTrack.video_status]?.color ?? 'bg-slate-700/60 text-slate-500'
}`}
>
{VIDEO_STATUS_LABELS[order.musicTrack.video_status]?.label ?? order.musicTrack.video_status}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
<input
type="text"
placeholder="영상 URL"
value={videoUrls[order.id] ?? order.musicTrack?.video_url ?? ''}
onChange={(e) =>
setVideoUrls((prev) => ({ ...prev, [order.id]: e.target.value }))
}
className="bg-slate-800 border border-slate-700 text-white text-xs rounded-lg px-2.5 py-1.5 w-56 placeholder:text-slate-500 focus:outline-none focus:border-slate-500"
/>
<button
onClick={() => markVideoInProgress(order.id)}
disabled={updating === order.id}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-blue-600/20 text-blue-400 border border-blue-500/30 hover:bg-blue-600/30 transition disabled:opacity-50"
>
</button>
<button
onClick={() => deliverVideo(order)}
disabled={updating === order.id}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-green-600/20 text-green-400 border border-green-500/30 hover:bg-green-600/30 transition disabled:opacity-50"
>
{updating === order.id ? '처리중...' : '납품 완료'}
</button>
</div>
</div>
)}
</div> </div>
); );
})} })}

View File

@@ -36,45 +36,91 @@ export async function GET() {
const productIds = [...new Set(orders.map((o) => o.product_id).filter(Boolean))] as string[]; const productIds = [...new Set(orders.map((o) => o.product_id).filter(Boolean))] as string[];
const userIds = [...new Set(orders.map((o) => o.user_id).filter(Boolean))] as string[]; const userIds = [...new Set(orders.map((o) => o.user_id).filter(Boolean))] as string[];
const [productsRes, profilesRes] = await Promise.all([ // music_video 주문의 연결 트랙 id 모으기
const trackIds = [
...new Set(
orders
.filter((o) => o.product_id === 'music_video')
.map((o) =>
typeof (o.metadata as Record<string, unknown> | null)?.music_track_id === 'string'
? ((o.metadata as Record<string, unknown>).music_track_id as string)
: null,
)
.filter((v): v is string => v !== null),
),
];
const [productsRes, profilesRes, tracksRes] = await Promise.all([
productIds.length > 0 productIds.length > 0
? supabase.from('products').select('id, name').in('id', productIds) ? supabase.from('products').select('id, name').in('id', productIds)
: Promise.resolve({ data: [] as { id: string; name: string }[] | null, error: null }), : Promise.resolve({ data: [] as { id: string; name: string }[] | null, error: null }),
userIds.length > 0 userIds.length > 0
? supabase.from('profiles').select('id, email').in('id', userIds) ? supabase.from('profiles').select('id, email').in('id', userIds)
: Promise.resolve({ data: [] as { id: string; email: string }[] | null, error: null }), : Promise.resolve({ data: [] as { id: string; email: string }[] | null, error: null }),
trackIds.length > 0
? supabase.from('music_tracks').select('id, title, audio_url, video_status, video_url').in('id', trackIds)
: Promise.resolve({
data: [] as { id: string; title: string | null; audio_url: string | null; video_status: string; video_url: string | null }[] | null,
error: null,
}),
]); ]);
const productMap = Object.fromEntries((productsRes.data ?? []).map((p) => [p.id, p.name])); const productMap = Object.fromEntries((productsRes.data ?? []).map((p) => [p.id, p.name]));
const profileMap = Object.fromEntries((profilesRes.data ?? []).map((p) => [p.id, p.email])); const profileMap = Object.fromEntries((profilesRes.data ?? []).map((p) => [p.id, p.email]));
const trackMap = Object.fromEntries((tracksRes.data ?? []).map((t) => [t.id, t]));
const enriched = orders.map((o) => ({ const enriched = orders.map((o) => {
const trackId =
o.product_id === 'music_video' && typeof (o.metadata as Record<string, unknown> | null)?.music_track_id === 'string'
? ((o.metadata as Record<string, unknown>).music_track_id as string)
: null;
return {
...o, ...o,
product_name: o.product_id ? (productMap[o.product_id] ?? null) : null, product_name: o.product_id ? (productMap[o.product_id] ?? null) : null,
customer_email: o.user_id ? (profileMap[o.user_id] ?? null) : null, customer_email: o.user_id ? (profileMap[o.user_id] ?? null) : null,
})); musicTrack: trackId ? (trackMap[trackId] ?? null) : null,
};
});
return NextResponse.json({ orders: enriched }); return NextResponse.json({ orders: enriched });
} }
// PATCH: 상태 변경 ('paid' 전환 시 고객에게 다운로드 활성화 메일) // PATCH: 상태 변경('paid' 전환 시 고객에게 다운로드 활성화 메일) + music_video 영상화 납품(videoUrl/videoStatus)
export async function PATCH(request: Request) { export async function PATCH(request: Request) {
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id, status } = await request.json(); const { id, status, videoUrl, videoStatus } = await request.json();
if (typeof id !== 'string' || !['pending', 'paid', 'cancelled'].includes(status)) {
if (typeof id !== 'string') {
return NextResponse.json({ error: 'invalid request' }, { status: 400 });
}
if (status !== undefined && !['pending', 'paid', 'cancelled'].includes(status)) {
return NextResponse.json({ error: 'invalid status' }, { status: 400 });
}
if (videoStatus !== undefined && !['none', 'requested', 'in_progress', 'delivered'].includes(videoStatus)) {
return NextResponse.json({ error: 'invalid videoStatus' }, { status: 400 });
}
if (videoUrl !== undefined && typeof videoUrl !== 'string') {
return NextResponse.json({ error: 'invalid videoUrl' }, { status: 400 });
}
if (status === undefined && videoUrl === undefined && videoStatus === undefined) {
return NextResponse.json({ error: 'invalid request' }, { status: 400 }); return NextResponse.json({ error: 'invalid request' }, { status: 400 });
} }
const supabase = createAdminClient(); const supabase = createAdminClient();
const { data: order, error } = await supabase
let order: { id: string; product_id: string | null; user_id: string | null; metadata: Record<string, unknown> | null };
if (status !== undefined) {
const { data, error } = await supabase
.from('orders') .from('orders')
.update({ status, updated_at: new Date().toISOString() }) .update({ status, updated_at: new Date().toISOString() })
.eq('id', id) .eq('id', id)
.select('id, product_id, user_id') .select('id, product_id, user_id, metadata')
.single(); .single();
if (error || !order) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 }); if (error || !data) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 });
order = data;
// paid 전환 시에만 메일 발송 — 실패해도 상태 변경은 이미 완료 // paid 전환 시에만 메일 발송 — 실패해도 상태 변경은 이미 완료
if (status === 'paid' && order.product_id && order.user_id) { if (status === 'paid' && order.product_id && order.user_id) {
@@ -92,6 +138,36 @@ export async function PATCH(request: Request) {
console.error('paid email failed', e); console.error('paid email failed', e);
} }
} }
} else {
// status 변경 없이 영상화 트랙만 갱신하는 경우 — 주문 조회로 product_id/metadata 확보
const { data, error } = await supabase
.from('orders')
.select('id, product_id, user_id, metadata')
.eq('id', id)
.single();
if (error || !data) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 });
order = data;
}
// music_video 주문의 연결 트랙 영상화 상태 갱신
if (order.product_id === 'music_video') {
const trackId = typeof order.metadata?.music_track_id === 'string' ? (order.metadata.music_track_id as string) : null;
if (trackId) {
if (status === 'paid' || videoUrl !== undefined) {
const trackUpdate: Record<string, unknown> = { video_status: 'delivered' };
if (videoUrl !== undefined) trackUpdate.video_url = videoUrl;
const { error: trackError } = await supabase.from('music_tracks').update(trackUpdate).eq('id', trackId);
if (trackError) console.error('music_tracks video delivery update failed', trackError);
} else if (videoStatus === 'in_progress') {
const { error: trackError } = await supabase
.from('music_tracks')
.update({ video_status: 'in_progress' })
.eq('id', trackId);
if (trackError) console.error('music_tracks video in_progress update failed', trackError);
}
}
}
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }

View File

@@ -51,7 +51,6 @@ export async function PATCH(request: Request) {
} }
const DEFAULT_SERVICES = [ const DEFAULT_SERVICES = [
{ id: 'music', name: 'AI 음악 팩', description: '음악 가이드 패키지·샘플·스튜디오', is_active: false, order_index: 102 },
{ id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 }, { id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 },
{ id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 }, { id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 },
]; ];

View File

@@ -70,14 +70,25 @@ export async function POST(request: Request) {
return NextResponse.json({ error: '판매 중인 상품이 아닙니다' }, { status: 404 }); return NextResponse.json({ error: '판매 중인 상품이 아닙니다' }, { status: 404 });
} }
// music_video 발주: 본인 트랙 검증 + 트랙별 중복 가드
let musicTrackId: string | null = null;
if (productId === 'music_video') {
musicTrackId = sanitizeStr((body as Record<string, unknown>).musicTrackId, 64) || null;
if (!musicTrackId) return NextResponse.json({ error: '영상화할 트랙이 필요합니다' }, { status: 400 });
const { data: track, error: trackLookupError } = await admin
.from('music_tracks').select('id').eq('id', musicTrackId).eq('user_id', user.id).maybeSingle();
if (trackLookupError) {
console.error('[Orders] music_tracks ownership lookup error:', trackLookupError);
}
if (!track) return NextResponse.json({ error: '본인 음악 트랙만 영상화할 수 있습니다' }, { status: 404 });
}
// 4) 중복 pending 방지 // 4) 중복 pending 방지
const { data: existing } = await admin const dupQuery = admin.from('orders').select('id')
.from('orders') .eq('user_id', user.id).eq('product_id', productId).eq('status', 'pending');
.select('id') const { data: existing } = productId === 'music_video' && musicTrackId
.eq('user_id', user.id) ? await dupQuery.eq('metadata->>music_track_id', musicTrackId).maybeSingle()
.eq('product_id', productId) : await dupQuery.maybeSingle();
.eq('status', 'pending')
.maybeSingle();
if (existing) { if (existing) {
return NextResponse.json({ orderId: existing.id, reused: true }); return NextResponse.json({ orderId: existing.id, reused: true });
@@ -94,6 +105,7 @@ export async function POST(request: Request) {
metadata: { metadata: {
method: 'bank_transfer', method: 'bank_transfer',
depositor_name: depositorName, depositor_name: depositorName,
...(musicTrackId ? { music_track_id: musicTrackId } : {}),
}, },
}) })
.select('id') .select('id')
@@ -106,6 +118,15 @@ export async function POST(request: Request) {
const orderId = order.id as string; const orderId = order.id as string;
if (productId === 'music_video' && musicTrackId) {
const { error: trackUpdateError } = await admin.from('music_tracks')
.update({ video_status: 'requested', video_order_id: orderId })
.eq('id', musicTrackId).eq('user_id', user.id);
if (trackUpdateError) {
console.error('[Orders] music_tracks video_status update error:', trackUpdateError);
}
}
// 6) 메일 발송 (실패해도 주문 유효) // 6) 메일 발송 (실패해도 주문 유효)
try { try {
await sendOrderReceivedEmails({ await sendOrderReceivedEmails({

View File

@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
// Suno webhook 수신용 최소 엔드포인트.
// 트랙 저장은 폴링 + 클라이언트 트리거(/api/studio/tracks)가 담당하므로 여기서는 200만 반환한다.
export async function POST() {
return NextResponse.json({ ok: true });
}

View File

@@ -1,4 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { getTodayUsage, recordUsage, MUSIC_DAILY_LIMIT } from '@/lib/ai-usage';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -13,6 +16,23 @@ type GenerateBody = {
}; };
export async function POST(request: Request) { export async function POST(request: Request) {
// 1) 인증 — 로그인 사용자만 (Suno API 무단 호출 방지)
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
}
// 2) 일일 제한
const admin = createAdminClient();
const used = await getTodayUsage(admin, user.id, 'music');
if (used >= MUSIC_DAILY_LIMIT) {
return NextResponse.json(
{ error: `오늘 음악 생성을 모두 사용했습니다. (${MUSIC_DAILY_LIMIT}회/일)` },
{ status: 429 }
);
}
const apiUrl = process.env.SUNO_API_URL ?? 'https://api.sunoapi.org'; 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;
@@ -69,6 +89,11 @@ export async function POST(request: Request) {
{ status: res.ok ? 502 : res.status }, { status: res.ok ? 502 : res.status },
); );
} }
try {
await recordUsage(admin, user.id, 'music');
} catch {
/* 집계 실패는 무시 — 생성은 이미 성공 */
}
return NextResponse.json({ ok: true, data }); return NextResponse.json({ ok: true, data });
} catch (e) { } catch (e) {
return NextResponse.json( return NextResponse.json(

View File

@@ -0,0 +1,115 @@
import { NextResponse } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { createClient } from '@/lib/supabase/server';
import {
STORY_SYSTEM_PROMPT,
buildStoryUserMessage,
parseStoryJson,
validateStory,
} from '@/lib/music/story-prompt';
import { config as loadDotenv } from 'dotenv';
import { resolve } from 'path';
export const runtime = 'nodejs';
// Vercel 최대 타임아웃 (Pro plan 300s, Hobby 60s)
export const maxDuration = 60;
// Next.js가 env 로드를 놓치는 경우 대비해 직접 로드 (Windows 환경 대응)
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
// 모델 우선순위 — 사주 analyze·타로 interpret와 동일 폴백 목록(이 API 키로 접근 가능한 모델만)
const MODELS = [
{ id: 'gemini-2.5-pro', maxTokens: 8192 },
{ id: 'gemini-2.5-flash', maxTokens: 8192 },
{ id: 'gemini-2.0-flash', maxTokens: 8192 },
] as const;
// wall-clock 예산 — maxDuration(60s)보다 여유 있게 끊어 graceful 502를 반환
const TIME_BUDGET_MS = 45_000;
// 최악 호출 수 상한 — 모델 폴백 × 검증 실패 reroll을 합쳐도 이 값을 넘지 않음
const MAX_ATTEMPTS = 3;
export async function POST(request: Request) {
// 1) 인증 — 로그인 사용자만 (Gemini API 무단 호출 방지)
// 일일 사용량 집계·제한은 generate 단계에서만 수행 — story는 가사 초안 생성일 뿐이라 미집계.
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
}
// 2) 입력 검증
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: '잘못된 요청 형식입니다.' }, { status: 400 });
}
const story = typeof body.story === 'string' ? body.story.trim() : '';
if (!story) {
return NextResponse.json({ error: '이야기를 입력해주세요.' }, { status: 400 });
}
// 3) API 키
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
console.warn('[스튜디오] GEMINI_API_KEY 미설정 — 503 반환 (예시 가사 반환 금지, 데이터 오염 방지)');
return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
}
const genAI = new GoogleGenerativeAI(apiKey);
const userMessage = buildStoryUserMessage(story);
// 4) 호출 — 모델 폴백 + 검증 실패 시 같은 모델로 1회 reroll
// wall-clock 45s 예산과 총 호출 3회 상한으로 최악 케이스를 조기 종료(→ 502)
const startedAt = Date.now();
let feedback = '';
let attempts = 0;
modelLoop:
for (const { id: modelId, maxTokens } of MODELS) {
// retry 0: 최초 시도, retry 1: 검증 실패 시에만 같은 모델로 1회 reroll
for (let retry = 0; retry < 2; retry += 1) {
if (attempts >= MAX_ATTEMPTS || Date.now() - startedAt > TIME_BUDGET_MS) {
break modelLoop;
}
attempts += 1;
try {
const model = genAI.getGenerativeModel({
model: modelId,
systemInstruction: STORY_SYSTEM_PROMPT,
generationConfig: {
temperature: 0.9,
topP: 0.95,
maxOutputTokens: maxTokens,
},
});
const prompt = feedback
? `${userMessage}\n\n[이전 시도 오류: ${feedback}] 스키마를 정확히 지켜 다시 출력하세요.`
: userMessage;
const result = await model.generateContent(prompt);
const text = result.response.text();
const parsed = parseStoryJson(text);
const invalid = parsed ? validateStory(parsed) : 'JSON 파싱 실패';
if (parsed && !invalid) {
return NextResponse.json({ story: parsed });
}
// 검증 실패 — 사유를 피드백으로 주입해 같은 모델로 1회 reroll(retry 루프 계속)
feedback = invalid ?? 'JSON 파싱 실패';
} catch (modelError) {
// 호출 자체의 예외(레이트리밋 등)는 reroll하지 않고 바로 다음 모델로 폴백
feedback = modelError instanceof Error ? modelError.message : 'model error';
break;
}
}
}
return NextResponse.json(
{ error: '가사 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' },
{ status: 502 }
);
}

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 });
}
const str = (k: string) => (typeof body[k] === 'string' ? (body[k] as string) : null);
const admin = createAdminClient();
const { data, error } = await admin.from('music_tracks').insert({
user_id: user.id,
title: str('title'),
story: str('story'),
lyrics: str('lyrics'),
style: str('style'),
audio_url: str('audio_url'),
task_id: str('task_id'),
}).select('id, created_at').single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data);
}
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
// 세션 클라이언트로 본인 것만(RLS music_select_own)
const { data, error } = await supabase
.from('music_tracks')
.select('id, title, story, lyrics, style, audio_url, task_id, created_at, video_status, video_url')
.order('created_at', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ tracks: data ?? [] });
}

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { getProductById } from '@/lib/supabase/product-files';
export const runtime = 'nodejs';
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
const admin = createAdminClient();
const product = await getProductById(admin, 'music_video');
if (!product || !product.is_active) return NextResponse.json({ error: '영상화 상품이 준비 중입니다.' }, { status: 404 });
return NextResponse.json({ product: { id: product.id, name: product.name, price: product.price, is_active: product.is_active } });
}

View File

@@ -19,6 +19,7 @@ interface Props {
product: { id: string; name: string; price: number }; product: { id: string; name: string; price: number };
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
musicTrackId?: string;
} }
type AuthState = 'checking' | 'guest' | 'user'; type AuthState = 'checking' | 'guest' | 'user';
@@ -29,7 +30,7 @@ interface SuccessInfo {
reused: boolean; reused: boolean;
} }
export default function BankTransferModal({ product, isOpen, onClose }: Props) { export default function BankTransferModal({ product, isOpen, onClose, musicTrackId }: Props) {
const [authState, setAuthState] = useState<AuthState>('checking'); const [authState, setAuthState] = useState<AuthState>('checking');
const [depositorName, setDepositorName] = useState(''); const [depositorName, setDepositorName] = useState('');
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
@@ -98,7 +99,11 @@ export default function BankTransferModal({ product, isOpen, onClose }: Props) {
const res = await fetch('/api/orders', { const res = await fetch('/api/orders', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: product.id, depositorName: name }), body: JSON.stringify({
productId: product.id,
depositorName: name,
...(musicTrackId ? { musicTrackId } : {}),
}),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
@@ -122,7 +127,7 @@ export default function BankTransferModal({ product, isOpen, onClose }: Props) {
setSubmitting(false); setSubmitting(false);
} }
}, },
[depositorName, agreed, submitting, product.id], [depositorName, agreed, submitting, product.id, musicTrackId],
); );
if (!isOpen) return null; if (!isOpen) return null;

View File

@@ -12,6 +12,7 @@ const LINKS = [
{ href: '/showcase', label: '제작 사례' }, { href: '/showcase', label: '제작 사례' },
{ href: '/work/saju', label: '사주' }, { href: '/work/saju', label: '사주' },
{ href: '/tarot', label: '타로' }, { href: '/tarot', label: '타로' },
{ href: '/music', label: '음악' },
]; ];
export default function TopNav() { export default function TopNav() {

View File

@@ -1,13 +1,10 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { isServiceVisible } from '@/lib/service-visibility';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'AI 음악 제품', title: '나의 이야기를 음악으로',
description: 'Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿 한 팩에. 1시간 만에 음악·뮤비 완성.', description: '당신의 이야기를 AI가 가사와 음악으로. 스토리를 들려주면 나만의 노래가 완성됩니다. 로그인 무료.',
}; };
export default async function MusicLayout({ children }: { children: React.ReactNode }) { export default function MusicLayout({ children }: { children: React.ReactNode }) {
if (!(await isServiceVisible('music'))) notFound();
return <>{children}</>; return <>{children}</>;
} }

View File

@@ -2,67 +2,60 @@ import Link from 'next/link';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Music — AI 음악 제품', title: '음악 — 나의 이야기를 음악으로',
}; };
const CARDS = [ const CARDS = [
{ {
href: '/music/packs', href: '/music/studio',
label: '팩 상세', label: 'AI 스튜디오',
desc: '입문 ₩39,000부터 — Suno 프롬프트북 + 뮤비 워크플로우 + SEO 템플릿', desc: '스토리를 입력하면 가사·음악을 자동 생성 — 로그인 무료',
key: 'packs', key: 'studio',
}, },
{ {
href: '/music/samples', href: '/music/samples',
label: '샘플 갤러리', label: '샘플 갤러리',
desc: '실제 결과물 — 장르별 데모 + 가사 + 영상 미리보기', desc: '실제 결과물 — 장르별 데모 가사',
key: 'samples', key: 'samples',
}, },
{
href: '/music/studio',
label: 'AI 스튜디오',
desc: 'Suno API 연동 — 직접 트랙 생성 (베타)',
key: 'studio',
},
]; ];
export default function MusicHub() { export default function MusicHub() {
return ( return (
<div className="min-h-screen bg-black text-white"> <div className="min-h-screen bg-[var(--jsm-bg)]">
<section className="relative w-full min-h-[60vh] flex items-center justify-center px-6 border-b border-white/10"> <section className="relative w-full min-h-[60vh] flex items-center justify-center px-6 bg-[var(--jsm-navy)]">
<div className="absolute inset-0 bg-gradient-to-b from-[#060e20] to-black pointer-events-none" />
<div className="relative z-10 max-w-3xl mx-auto text-center"> <div className="relative z-10 max-w-3xl mx-auto text-center">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4"> <p className="font-mono text-[11px] tracking-widest uppercase text-[var(--jsm-accent-soft)] mb-4">
Music Music
</p> </p>
<h1 <h1
className="kx-display text-4xl md:text-6xl font-bold mb-5" className="kx-display text-4xl md:text-6xl font-bold mb-5 text-white"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }} style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
> >
AI
</h1> </h1>
<p className="text-base md:text-lg text-white/70 max-w-2xl mx-auto leading-relaxed"> <p className="text-base md:text-lg text-white/70 max-w-2xl mx-auto leading-relaxed">
Suno + + SEO 릿. 4 1 . AI가 . .
</p> </p>
</div> </div>
</section> </section>
<section className="py-20 px-6"> <section className="py-20 px-6">
<div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-5"> <div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-5">
{CARDS.map((c) => ( {CARDS.map((c) => (
<Link <Link
key={c.key} key={c.key}
href={c.href} href={c.href}
className="group rounded-2xl border border-white/15 bg-white/[0.02] p-7 hover:border-white/40 hover:bg-white/[0.05] transition flex flex-col" className="rounded-2xl border border-[var(--jsm-line)] bg-[var(--jsm-surface)] p-7 transition-colors hover:border-[var(--jsm-accent)] hover:bg-[var(--jsm-surface-alt)] flex flex-col"
style={{ textDecoration: 'none' }} style={{ textDecoration: 'none' }}
> >
<h2 className="kx-display text-xl md:text-2xl font-bold text-white mb-3"> <h2 className="kx-display text-xl md:text-2xl font-bold text-[var(--jsm-ink)] mb-3">
{c.label} {c.label}
</h2> </h2>
<p className="text-sm md:text-base text-white/60 leading-relaxed flex-1"> <p className="text-sm md:text-base text-[var(--jsm-ink-soft)] leading-relaxed flex-1">
{c.desc} {c.desc}
</p> </p>
<span aria-hidden="true" className="mt-4 text-white/40 text-xs"></span> <span aria-hidden="true" className="mt-4 text-[var(--jsm-ink-faint)] text-xs"></span>
</Link> </Link>
))} ))}
</div> </div>

View File

@@ -27,17 +27,17 @@ const SAMPLES: Sample[] = [
export default function MusicSamplesPage() { export default function MusicSamplesPage() {
return ( return (
<div className="px-6 py-20 lg:px-14" style={{ background: 'var(--kx-surface)' }}> <div className="px-6 py-20 lg:px-14" style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="text-center mb-12"> <div className="text-center mb-12">
<span className="kx-label">SAMPLE GALLERY</span> <span className="kx-label">SAMPLE GALLERY</span>
<h1 className="kx-display text-3xl md:text-5xl font-bold mt-3 mb-4" style={{ color: 'var(--kx-on-surface)' }}> <h1 className="kx-display text-3xl md:text-5xl font-bold mt-3 mb-4" style={{ color: 'var(--jsm-ink)' }}>
AI · AI ·
</h1> </h1>
<p className="max-w-2xl mx-auto text-sm md:text-base" style={{ color: 'var(--kx-on-variant)' }}> <p className="max-w-2xl mx-auto text-sm md:text-base" style={{ color: 'var(--jsm-ink-soft)' }}>
. . . .
<br className="hidden md:block" /> <br className="hidden md:block" />
<span className="text-xs" style={{ color: 'var(--kx-on-variant)' }}> <span className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
. .
</span> </span>
</p> </p>
@@ -47,30 +47,36 @@ export default function MusicSamplesPage() {
{SAMPLES.map((s) => ( {SAMPLES.map((s) => (
<div <div
key={s.id} key={s.id}
className={`group relative aspect-[9/16] rounded-2xl overflow-hidden border ${ className={`relative aspect-[9/16] rounded-2xl overflow-hidden border ${
s.featured ? 'border-violet-400/50 shadow-2xl shadow-violet-900/40' : 'border-white/10' s.featured ? 'border-[var(--jsm-accent)] shadow-lg' : 'border-[var(--jsm-line)]'
}`} }`}
style={{ background: 'linear-gradient(135deg, #1a0840 0%, #061228 100%)' }} style={{ background: 'var(--jsm-surface-alt)' }}
> >
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/15 to-cyan-500/10 group-hover:from-violet-500/25 group-hover:to-cyan-500/20 transition-all" />
{s.featured && ( {s.featured && (
<span <span
className="absolute top-3 left-3 z-10 text-[10px] px-2 py-1 rounded-full font-semibold tracking-widest" className="absolute top-3 left-3 z-10 text-[10px] px-2 py-1 rounded-full font-semibold tracking-widest"
style={{ background: 'rgba(204,151,255,0.2)', color: 'var(--kx-primary)', border: '1px solid rgba(204,151,255,0.5)' }} style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)', border: '1px solid var(--jsm-accent)' }}
> >
TOP TOP
</span> </span>
)} )}
<div className="absolute inset-0 flex flex-col items-center justify-center text-center p-5"> <div className="absolute inset-0 flex flex-col items-center justify-center text-center p-5">
<div className="text-5xl mb-3 opacity-80 group-hover:scale-110 transition-transform">🎬</div> <svg
<p className="text-[10px] md:text-xs font-mono tracking-widest text-violet-300/80 mb-1">{s.genre.toUpperCase()}</p> className="w-10 h-10 mb-3 opacity-80"
<p className="text-sm md:text-base font-semibold" style={{ color: 'var(--kx-on-surface)' }}> fill="none"
stroke="var(--jsm-accent)"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 10.5l4.72-2.72a.75.75 0 011.28.53v9.38a.75.75 0 01-1.28.53l-4.72-2.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
</svg>
<p className="text-[10px] md:text-xs font-mono tracking-widest mb-1" style={{ color: 'var(--jsm-accent)' }}>{s.genre.toUpperCase()}</p>
<p className="text-sm md:text-base font-semibold" style={{ color: 'var(--jsm-ink)' }}>
{s.title} {s.title}
</p> </p>
<p className="text-xs mt-1" style={{ color: 'var(--kx-on-variant)' }}>{s.duration}</p> <p className="text-xs mt-1" style={{ color: 'var(--jsm-ink-soft)' }}>{s.duration}</p>
<p className="text-[10px] mt-3 opacity-60" style={{ color: 'var(--kx-on-variant)' }}> <p className="text-[10px] mt-3 opacity-60" style={{ color: 'var(--jsm-ink-soft)' }}>
{s.embedId ? '영상 재생' : '영상 준비 중'} {s.embedId ? '영상 재생' : '영상 준비 중'}
</p> </p>
</div> </div>
@@ -79,19 +85,20 @@ export default function MusicSamplesPage() {
</div> </div>
<div <div
className="mt-16 text-center p-10 kx-glass" className="mt-16 text-center px-8 py-16 rounded-3xl"
style={{ border: '1px solid rgba(204,151,255,0.12)', borderRadius: '0.75rem 0.75rem 0.125rem 0.125rem' }} style={{ background: 'var(--jsm-navy)' }}
> >
<span className="kx-label">NEXT</span> <span className="text-[var(--jsm-accent-soft)] text-xs font-bold uppercase tracking-widest">NEXT</span>
<h2 className="kx-display text-2xl md:text-3xl font-bold mt-2 mb-3" style={{ color: 'var(--kx-on-surface)' }}> <h2 className="text-2xl md:text-3xl font-bold mt-3 mb-3 text-white">
</h2> </h2>
<p className="text-sm mb-6" style={{ color: 'var(--kx-on-variant)' }}> <p className="text-sm mb-6 text-white/70">
39,000. 39,000.
</p> </p>
<Link <Link
href="/music/packs#pricing" href="/music/packs#pricing"
className="kx-btn-primary px-8 py-3.5 rounded-full text-sm inline-flex" className="inline-flex items-center justify-center gap-2 rounded-full bg-white px-8 py-3.5 text-sm font-semibold transition-transform duration-200 hover:translate-y-[-1px]"
style={{ color: 'var(--jsm-navy)' }}
> >
</Link> </Link>

View File

@@ -1,8 +1,11 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
type Mode = 'simple' | 'custom'; type Mode = 'simple' | 'custom';
type FlowTab = 'story' | 'manual';
type StoryStage = 'input' | 'preview';
type SunoClip = { type SunoClip = {
id: string; id: string;
@@ -23,6 +26,20 @@ type TaskState = {
updatedAt: number; updatedAt: number;
}; };
type TrackMeta = {
title?: string;
story?: string;
lyrics?: string;
style?: string;
};
type MusicStory = {
title: string;
lyrics: string;
style: string;
mood: string;
};
const MODELS = [ const MODELS = [
{ id: 'V4', label: 'V4 (기본)', desc: '안정적 고품질' }, { id: 'V4', label: 'V4 (기본)', desc: '안정적 고품질' },
{ id: 'V4_5', label: 'V4.5', desc: '최신 · 풍부한 디테일' }, { id: 'V4_5', label: 'V4.5', desc: '최신 · 풍부한 디테일' },
@@ -35,11 +52,16 @@ const TAG_PRESETS = [
]; ];
const LS_KEY = 'jsm_studio_task_ids_v2'; 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 isDone = (s: string) => s === 'SUCCESS' || s === 'FIRST_SUCCESS';
const isFailed = (s: string) => s.includes('FAILED') || s === 'SENSITIVE_WORD_ERROR'; 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() { export default function StudioPage() {
const [flowTab, setFlowTab] = useState<FlowTab>('story');
const [mode, setMode] = useState<Mode>('simple'); const [mode, setMode] = useState<Mode>('simple');
const [model, setModel] = useState('V4'); const [model, setModel] = useState('V4');
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
@@ -48,11 +70,28 @@ export default function StudioPage() {
const [tags, setTags] = useState(''); const [tags, setTags] = useState('');
const [instrumental, setInstrumental] = useState(false); const [instrumental, setInstrumental] = useState(false);
// 스토리 흐름 상태
const [storyText, setStoryText] = useState('');
const [storyStage, setStoryStage] = useState<StoryStage>('input');
const [storyLoading, setStoryLoading] = useState(false);
const [storyError, setStoryError] = useState<string | null>(null);
const [storyAuthRequired, setStoryAuthRequired] = useState(false);
const [mood, setMood] = useState('');
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 [authRequired, setAuthRequired] = useState(false);
const [tasks, setTasks] = useState<TaskState[]>([]); const [tasks, setTasks] = useState<TaskState[]>([]);
const pollRef = useRef<number | null>(null); const pollRef = useRef<number | null>(null);
// 생성 요청 시점의 원본(스토리/가사/스타일)을 taskId에 매핑 — 완료 후 자동 저장에 사용
const metaRef = useRef<Map<string, TrackMeta>>(new Map());
// 자동 저장 완료(또는 시도) 표시 — 중복 저장 방지
const savedRef = useRef<Set<string>>(new Set());
// 이번 세션에서 새로 생성한 taskId만 자동 저장 대상으로 삼는다
// (새로고침 시 localStorage에서 복원된 과거 완료 트랙까지 재저장하는 것 방지)
const sessionTaskIdsRef = useRef<Set<string>>(new Set());
const saveToLS = useCallback((ids: string[]) => { const saveToLS = useCallback((ids: string[]) => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { localStorage.setItem(LS_KEY, JSON.stringify(ids.slice(0, 20))); } catch { /* noop */ } try { localStorage.setItem(LS_KEY, JSON.stringify(ids.slice(0, 20))); } catch { /* noop */ }
@@ -105,10 +144,38 @@ export default function StudioPage() {
return () => { if (pollRef.current) window.clearInterval(pollRef.current); }; return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
}, [tasks, refreshAll]); }, [tasks, refreshAll]);
const onSubmit = async () => { // 완료된 트랙 자동 저장 (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); setError(null);
if (mode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; } setAuthRequired(false);
if (mode === 'custom') { if (forcedMode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; }
if (forcedMode === 'custom') {
if (!title.trim()) { setError('트랙 제목을 입력해주세요.'); return; } if (!title.trim()) { setError('트랙 제목을 입력해주세요.'); return; }
if (!tags.trim()) { setError('스타일 태그를 입력해주세요.'); return; } if (!tags.trim()) { setError('스타일 태그를 입력해주세요.'); return; }
if (!lyrics.trim() && !instrumental) { setError('가사를 입력하거나 Instrumental을 켜주세요.'); return; } if (!lyrics.trim() && !instrumental) { setError('가사를 입력하거나 Instrumental을 켜주세요.'); return; }
@@ -119,7 +186,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, model, mode: forcedMode, model,
prompt: prompt.trim(), prompt: prompt.trim(),
title: title.trim(), title: title.trim(),
lyrics: lyrics.trim(), lyrics: lyrics.trim(),
@@ -127,7 +194,16 @@ export default function StudioPage() {
make_instrumental: instrumental, make_instrumental: instrumental,
}), }),
}); });
const json = await res.json(); 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) { if (!res.ok || !json.ok) {
setError(typeof json.error === 'string' ? json.error : '생성 실패'); setError(typeof json.error === 'string' ? json.error : '생성 실패');
return; return;
@@ -137,6 +213,8 @@ export default function StudioPage() {
setError('응답에서 taskId를 찾지 못했습니다.'); setError('응답에서 taskId를 찾지 못했습니다.');
return; return;
} }
metaRef.current.set(taskId, meta);
sessionTaskIdsRef.current.add(taskId);
setTasks((prev) => { setTasks((prev) => {
const next: TaskState[] = [ const next: TaskState[] = [
{ taskId, status: 'PENDING', clips: [], updatedAt: Date.now() }, { taskId, status: 'PENDING', clips: [], updatedAt: Date.now() },
@@ -150,6 +228,65 @@ export default function StudioPage() {
} finally { } finally {
setSubmitting(false); 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 addTag = (t: string) => {
@@ -161,59 +298,227 @@ export default function StudioPage() {
return ( return (
<div <div
className="min-h-screen px-4 md:px-8 lg:px-12 py-10" className="min-h-screen px-4 md:px-8 lg:px-12 py-10"
style={{ style={{ background: 'var(--jsm-bg)', color: 'var(--jsm-ink)' }}
background:
'radial-gradient(1200px 600px at 20% -10%, rgba(156,72,234,0.18), transparent 60%), radial-gradient(1000px 500px at 110% 10%, rgba(83,221,252,0.12), transparent 55%), var(--kx-surface)',
color: 'var(--kx-on-surface)',
}}
> >
<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>
<h1 className="kx-display text-3xl md:text-5xl font-extrabold mt-2" style={{ letterSpacing: '-0.02em' }}> <h1 className="kx-display text-3xl md:text-5xl font-extrabold mt-2" style={{ letterSpacing: '-0.02em' }}>
</h1> </h1>
<p className="mt-2 text-sm" style={{ color: 'var(--kx-on-variant)' }}> <p className="mt-2 text-sm" style={{ color: 'var(--jsm-ink-soft)' }}>
Suno · Custom ·· AI가 · . .
</p> </p>
</div> </div>
<div <div
className="text-xs px-3 py-1.5 rounded-full border" className="text-xs px-3 py-1.5 rounded-full font-semibold tracking-wide"
style={{ style={{
borderColor: 'rgba(204,151,255,0.35)', border: '1px solid var(--jsm-accent)',
background: 'rgba(204,151,255,0.1)', background: 'var(--jsm-accent-soft)',
color: 'var(--kx-primary)', color: 'var(--jsm-accent)',
}} }}
> >
v1 Studio · Live STUDIO · LIVE
</div> </div>
</div> </div>
<div className="grid lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)] gap-6"> <div className="grid lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)] gap-6">
{/* 좌측: 제어판 */} {/* 좌측: 제어판 */}
<div <div className="rounded-2xl p-6 md:p-8 bg-white border border-[var(--jsm-line)]">
className="rounded-2xl p-6 md:p-8" <div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'var(--jsm-surface-alt)' }}>
{(['story', 'manual'] as FlowTab[]).map((t) => (
<button
key={t}
onClick={() => setFlowTab(t)}
className="flex-1 py-2.5 text-sm font-semibold rounded-full transition-all"
style={
flowTab === t
? { background: 'var(--jsm-accent)', color: '#fff' }
: { color: 'var(--jsm-ink-soft)' }
}
>
{t === 'story' ? '스토리로 만들기' : '직접 입력'}
</button>
))}
</div>
{flowTab === 'story' ? (
<div className="space-y-5">
{storyStage === 'input' ? (
<>
<Field label="나의 이야기" hint="추억·순간·감정을 편하게 적어주세요">
<textarea
value={storyText}
onChange={(e) => setStoryText(e.target.value)}
rows={7}
placeholder="예: 대학 시절 자취방에서 혼자 라면을 끓여 먹으며 창밖 비 오는 거리를 보던 밤, 외로웠지만 이상하게 평온했던 기억"
className={`${FIELD_INPUT} resize-none`}
/>
</Field>
<button
onClick={onMakeLyrics}
disabled={storyLoading}
className="w-full py-4 rounded-xl font-bold text-base transition disabled:opacity-60"
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
>
{storyLoading ? '가사 만드는 중…' : '가사 만들기'}
</button>
{storyError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{storyError}
{storyAuthRequired && (
<Link
href={LOGIN_HREF}
className="ml-2 font-semibold underline underline-offset-2"
style={{ color: 'var(--jsm-accent)' }}
>
</Link>
)}
</div>
)}
</>
) : (
<>
<div className="flex items-center justify-between">
<span
className="text-xs px-3 py-1 rounded-full font-semibold"
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }}
>
· {mood || '미정'}
</span>
<button
onClick={() => setStoryStage('input')}
className="text-xs underline underline-offset-4"
style={{ color: 'var(--jsm-ink-soft)' }}
>
</button>
</div>
<Field label="트랙 제목">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 새벽 세 시의 도시"
className={FIELD_INPUT}
/>
</Field>
<Field label="가사" hint="AI가 제안한 가사입니다 — 자유롭게 수정 가능">
<textarea
value={lyrics}
onChange={(e) => setLyrics(e.target.value)}
rows={8}
className={`${FIELD_INPUT} resize-none font-mono text-sm leading-relaxed`}
/>
</Field>
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
<input
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
className={FIELD_INPUT}
/>
<div className="flex flex-wrap gap-1.5 mt-3">
{TAG_PRESETS.map((t) => (
<button
key={t}
onClick={() => addTag(t)}
className="text-xs px-2.5 py-1 rounded-full transition"
style={{ style={{
background: 'rgba(12,22,45,0.7)', background: 'var(--jsm-surface-alt)',
border: '1px solid rgba(255,255,255,0.06)', border: '1px solid var(--jsm-line)',
backdropFilter: 'blur(16px)', color: 'var(--jsm-ink-soft)',
}} }}
> >
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'rgba(255,255,255,0.04)' }}> + {t}
</button>
))}
</div>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="모델">
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className={`${FIELD_INPUT} text-sm`}
>
{MODELS.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {m.desc}
</option>
))}
</select>
</Field>
<Field label="Instrumental (가사 없음)">
<ToggleSwitch checked={instrumental} onChange={setInstrumental} />
</Field>
</div>
<div className="flex gap-3">
<button
onClick={onMakeLyrics}
disabled={storyLoading}
className="flex-1 py-3.5 rounded-xl font-semibold text-sm transition disabled:opacity-60"
style={{
background: 'var(--jsm-surface-alt)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
}}
>
{storyLoading ? '다시 만드는 중…' : '가사 다시 만들기'}
</button>
<button
onClick={onStoryGenerate}
disabled={submitting}
className="flex-1 py-3.5 rounded-xl font-bold text-sm transition disabled:opacity-60"
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
>
{submitting ? '생성 요청 중…' : '음악 만들기'}
</button>
</div>
{storyError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{storyError}
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
{authRequired && (
<Link
href={LOGIN_HREF}
className="ml-2 font-semibold underline underline-offset-2"
style={{ color: 'var(--jsm-accent)' }}
>
</Link>
)}
</div>
)}
<p className="text-[11px] leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
Suno . · .
</p>
</>
)}
</div>
) : (
<>
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'var(--jsm-surface-alt)' }}>
{(['simple', 'custom'] as Mode[]).map((m) => ( {(['simple', 'custom'] as Mode[]).map((m) => (
<button <button
key={m} key={m}
onClick={() => setMode(m)} onClick={() => setMode(m)}
className="flex-1 py-2.5 text-sm font-semibold rounded-full transition-all" className="flex-1 py-2 text-xs font-semibold rounded-full transition-all"
style={ style={
mode === m mode === m
? { ? { background: 'var(--jsm-accent)', color: '#fff' }
background: 'linear-gradient(135deg, rgba(204,151,255,0.25), rgba(83,221,252,0.15))', : { color: 'var(--jsm-ink-soft)' }
color: '#fff',
boxShadow: '0 0 24px rgba(204,151,255,0.25) inset',
}
: { color: 'var(--kx-on-variant)' }
} }
> >
{m === 'simple' ? '간단 모드' : 'Custom 모드'} {m === 'simple' ? '간단 모드' : 'Custom 모드'}
@@ -229,8 +534,7 @@ export default function StudioPage() {
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
rows={5} rows={5}
placeholder="예: 비 오는 서울 새벽, 감성 시티팝 with 여성 보컬, 2010년대 무드" placeholder="예: 비 오는 서울 새벽, 감성 시티팝 with 여성 보컬, 2010년대 무드"
className="w-full bg-transparent outline-none resize-none text-base" className={`${FIELD_INPUT} resize-none`}
style={{ color: 'var(--kx-on-surface)' }}
/> />
</Field> </Field>
</div> </div>
@@ -241,8 +545,7 @@ export default function StudioPage() {
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="예: 새벽 세 시의 도시" placeholder="예: 새벽 세 시의 도시"
className="w-full bg-transparent outline-none text-base" className={FIELD_INPUT}
style={{ color: 'var(--kx-on-surface)' }}
/> />
</Field> </Field>
<Field label="가사" hint="Suno 포맷: [Verse] [Chorus] [Bridge] 등 태그 가능"> <Field label="가사" hint="Suno 포맷: [Verse] [Chorus] [Bridge] 등 태그 가능">
@@ -251,8 +554,7 @@ export default function StudioPage() {
onChange={(e) => setLyrics(e.target.value)} onChange={(e) => setLyrics(e.target.value)}
rows={8} rows={8}
placeholder={'[Verse]\n차가운 조명 아래 걷는 나\n새벽 세 시의 도시는 낯설어\n\n[Chorus]\n...'} placeholder={'[Verse]\n차가운 조명 아래 걷는 나\n새벽 세 시의 도시는 낯설어\n\n[Chorus]\n...'}
className="w-full bg-transparent outline-none resize-none font-mono text-sm leading-relaxed" className={`${FIELD_INPUT} resize-none font-mono text-sm leading-relaxed`}
style={{ color: 'var(--kx-on-surface)' }}
/> />
</Field> </Field>
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤"> <Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
@@ -260,8 +562,7 @@ export default function StudioPage() {
value={tags} value={tags}
onChange={(e) => setTags(e.target.value)} onChange={(e) => setTags(e.target.value)}
placeholder="city pop, female vocal, 120bpm, synth, nostalgic" placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
className="w-full bg-transparent outline-none text-base" className={FIELD_INPUT}
style={{ color: 'var(--kx-on-surface)' }}
/> />
<div className="flex flex-wrap gap-1.5 mt-3"> <div className="flex flex-wrap gap-1.5 mt-3">
{TAG_PRESETS.map((t) => ( {TAG_PRESETS.map((t) => (
@@ -270,9 +571,9 @@ export default function StudioPage() {
onClick={() => addTag(t)} onClick={() => addTag(t)}
className="text-xs px-2.5 py-1 rounded-full transition" className="text-xs px-2.5 py-1 rounded-full transition"
style={{ style={{
background: 'rgba(255,255,255,0.04)', background: 'var(--jsm-surface-alt)',
border: '1px solid rgba(255,255,255,0.08)', border: '1px solid var(--jsm-line)',
color: 'var(--kx-on-variant)', color: 'var(--jsm-ink-soft)',
}} }}
> >
+ {t} + {t}
@@ -288,76 +589,53 @@ export default function StudioPage() {
<select <select
value={model} value={model}
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
className="w-full bg-transparent outline-none text-sm" className={`${FIELD_INPUT} text-sm`}
style={{ color: 'var(--kx-on-surface)' }}
> >
{MODELS.map((m) => ( {MODELS.map((m) => (
<option key={m.id} value={m.id} style={{ background: '#0b1428' }}> <option key={m.id} value={m.id}>
{m.label} {m.desc} {m.label} {m.desc}
</option> </option>
))} ))}
</select> </select>
</Field> </Field>
<Field label="Instrumental (가사 없음)"> <Field label="Instrumental (가사 없음)">
<label className="flex items-center gap-3 cursor-pointer"> <ToggleSwitch checked={instrumental} onChange={setInstrumental} />
<span
className="relative inline-block w-11 h-6 rounded-full transition"
style={{ background: instrumental ? 'rgba(204,151,255,0.6)' : 'rgba(255,255,255,0.1)' }}
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
style={{ left: instrumental ? '22px' : '2px' }}
/>
</span>
<input
type="checkbox"
checked={instrumental}
onChange={(e) => setInstrumental(e.target.checked)}
className="sr-only"
/>
<span className="text-xs" style={{ color: 'var(--kx-on-variant)' }}>
{instrumental ? 'ON' : 'OFF'}
</span>
</label>
</Field> </Field>
</div> </div>
<div className="mt-8"> <div className="mt-8">
<button <button
onClick={onSubmit} onClick={onManualSubmit}
disabled={submitting} disabled={submitting}
className="w-full py-4 rounded-xl font-extrabold text-base transition-all disabled:opacity-60" className="w-full py-4 rounded-xl font-extrabold text-base transition-all disabled:opacity-60"
style={{ style={{ background: 'var(--jsm-accent)', color: '#fff' }}
background: submitting
? 'rgba(204,151,255,0.2)'
: 'linear-gradient(135deg, #cc97ff 0%, #7c3aed 50%, #53ddfc 100%)',
color: '#0b1428',
boxShadow: submitting ? 'none' : '0 12px 40px -12px rgba(204,151,255,0.6)',
letterSpacing: '0.01em',
}}
> >
{submitting ? '생성 요청 중…' : '▶ Generate Track'} {submitting ? '생성 요청 중…' : '트랙 생성하기'}
</button> </button>
{error && ( {error && (
<p className="mt-3 text-xs px-3 py-2 rounded-lg" style={{ background: 'rgba(215,51,87,0.12)', color: '#ff8ba7' }}> <div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{error} {error}
</p> {authRequired && (
<Link
href={LOGIN_HREF}
className="ml-2 font-semibold underline underline-offset-2"
style={{ color: 'var(--jsm-accent)' }}
>
</Link>
)} )}
<p className="mt-3 text-[11px] leading-relaxed" style={{ color: 'var(--kx-on-variant)' }}> </div>
)}
<p className="mt-3 text-[11px] leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
Suno . · . Suno . · .
</p> </p>
</div> </div>
</>
)}
</div> </div>
{/* 우측: 결과 */} {/* 우측: 결과 */}
<div <div className="rounded-2xl p-6 md:p-7 bg-white border border-[var(--jsm-line)]">
className="rounded-2xl p-6 md:p-7"
style={{
background: 'rgba(9,17,36,0.7)',
border: '1px solid rgba(255,255,255,0.06)',
backdropFilter: 'blur(16px)',
}}
>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<span className="kx-label">RECENT TRACKS</span> <span className="kx-label">RECENT TRACKS</span>
@@ -367,7 +645,7 @@ export default function StudioPage() {
<button <button
onClick={() => { setTasks([]); saveToLS([]); }} onClick={() => { setTasks([]); saveToLS([]); }}
className="text-[11px] underline underline-offset-4" className="text-[11px] underline underline-offset-4"
style={{ color: 'var(--kx-on-variant)' }} style={{ color: 'var(--jsm-ink-soft)' }}
> >
</button> </button>
@@ -376,35 +654,31 @@ export default function StudioPage() {
{tasks.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 border border-dashed"
style={{ border: '1px dashed rgba(255,255,255,0.1)', color: 'var(--kx-on-variant)' }} style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink-soft)' }}
> >
. .
<br /> Generate . <br /> .
</div> </div>
) : ( ) : (
<ul className="space-y-4 max-h-[640px] overflow-y-auto pr-1"> <ul className="space-y-4 max-h-[640px] overflow-y-auto pr-1">
{tasks.map((task) => ( {tasks.map((task) => (
<li <li
key={task.taskId} key={task.taskId}
className="rounded-xl p-4" className="rounded-xl p-4 border"
style={{ style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
background: 'rgba(20,31,56,0.6)',
border: '1px solid rgba(255,255,255,0.05)',
}}
> >
<div className="flex items-center justify-between gap-3 mb-3"> <div className="flex items-center justify-between gap-3 mb-3">
<span className="text-[11px] font-mono opacity-60">task: {task.taskId.slice(0, 10)}</span> <span className="text-[11px] font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
task: {task.taskId.slice(0, 10)}
</span>
<StatusBadge status={task.status} /> <StatusBadge status={task.status} />
</div> </div>
{task.clips.length === 0 ? ( {task.clips.length === 0 ? (
<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: 'var(--jsm-surface)', color: 'var(--jsm-ink-soft)' }}
background: 'linear-gradient(90deg, rgba(204,151,255,0.08) 0%, rgba(83,221,252,0.08) 100%)',
color: 'var(--kx-on-variant)',
}}
> >
{isFailed(task.status) {isFailed(task.status)
? (task.errorMessage ?? '생성 실패') ? (task.errorMessage ?? '생성 실패')
@@ -417,8 +691,8 @@ export default function StudioPage() {
return ( return (
<div <div
key={c.id} key={c.id}
className="rounded-lg p-3" className="rounded-lg p-3 bg-white border"
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.04)' }} style={{ borderColor: 'var(--jsm-line)' }}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{c.imageUrl && ( {c.imageUrl && (
@@ -430,17 +704,17 @@ export default function StudioPage() {
/> />
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-semibold text-sm truncate" style={{ color: 'var(--kx-on-surface)' }}> <p className="font-semibold text-sm truncate" style={{ color: 'var(--jsm-ink)' }}>
{c.title || '제목 없음'} {c.title || '제목 없음'}
</p> </p>
{c.tags && ( {c.tags && (
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--kx-on-variant)' }}> <p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--jsm-ink-soft)' }}>
{c.tags} {c.tags}
</p> </p>
)} )}
</div> </div>
{c.duration && ( {c.duration && (
<span className="text-[10px] font-mono opacity-60"> <span className="text-[10px] font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
{Math.round(c.duration)}s {Math.round(c.duration)}s
</span> </span>
)} )}
@@ -449,8 +723,13 @@ export default function StudioPage() {
<audio controls src={src} className="w-full mt-2" style={{ height: 36 }} /> <audio controls src={src} className="w-full mt-2" style={{ height: 36 }} />
) : null} ) : null}
{c.audioUrl && ( {c.audioUrl && (
<div className="mt-1.5 text-[11px]" style={{ color: 'var(--kx-on-variant)' }}> <div className="mt-1.5 text-[11px]" style={{ color: 'var(--jsm-ink-soft)' }}>
<a href={c.audioUrl} download className="underline underline-offset-4 hover:text-white"> <a
href={c.audioUrl}
download
className="underline underline-offset-4"
style={{ color: 'var(--jsm-accent)' }}
>
MP3 MP3
</a> </a>
</div> </div>
@@ -467,9 +746,9 @@ 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(--jsm-ink-soft)' }}>
<Tip title="① 간단 모드" body="한 줄 프롬프트로 즉시 생성. 결과물 다양성 높음." /> <Tip title="① 스토리 모드" body="이야기를 적으면 AI가 제목·가사·스타일을 자동으로 제안합니다." />
<Tip title="② Custom 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." /> <Tip title="② 직접 입력 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
<Tip title="③ 상업 이용" body="Suno Pro 이상 플랜에서 생성한 결과만 수익화 가능. 플랜 확인 필수." /> <Tip title="③ 상업 이용" body="Suno Pro 이상 플랜에서 생성한 결과만 수익화 가능. 플랜 확인 필수." />
</div> </div>
</div> </div>
@@ -487,41 +766,60 @@ function Field({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div <div>
className="rounded-xl p-4"
style={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.06)',
}}
>
<div className="flex items-baseline justify-between mb-2"> <div className="flex items-baseline justify-between mb-2">
<span className="text-[11px] font-semibold tracking-widest uppercase" style={{ color: 'var(--kx-primary)' }}> <span className="text-[11px] font-semibold tracking-widest uppercase" style={{ color: 'var(--jsm-accent)' }}>
{label} {label}
</span> </span>
{hint && <span className="text-[10px]" style={{ color: 'var(--kx-on-variant)' }}>{hint}</span>} {hint && <span className="text-[10px]" style={{ color: 'var(--jsm-ink-soft)' }}>{hint}</span>}
</div> </div>
{children} {children}
</div> </div>
); );
} }
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center gap-3 cursor-pointer">
<span
className="relative inline-block w-11 h-6 rounded-full transition"
style={{ background: checked ? 'var(--jsm-accent)' : 'var(--jsm-line)' }}
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
style={{ left: checked ? '22px' : '2px' }}
/>
</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="sr-only"
/>
<span className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
{checked ? 'ON' : 'OFF'}
</span>
</label>
);
}
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; border: string; label: string }> = {
SUCCESS: { bg: 'rgba(64,206,172,0.18)', fg: '#6cf0c6', label: '완료' }, SUCCESS: { bg: '#ecfdf5', fg: '#047857', border: '#a7f3d0', label: '완료' },
FIRST_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '첫 트랙 준비' }, FIRST_SUCCESS: { bg: 'var(--jsm-accent-soft)', fg: 'var(--jsm-accent)', border: 'var(--jsm-accent)', label: '첫 트랙 준비' },
TEXT_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '가사 완료' }, TEXT_SUCCESS: { bg: 'var(--jsm-accent-soft)', fg: 'var(--jsm-accent)', border: 'var(--jsm-accent)', label: '가사 완료' },
PENDING: { bg: 'rgba(204,151,255,0.18)', fg: '#cc97ff', label: '대기' }, PENDING: { bg: 'var(--jsm-surface-alt)', fg: 'var(--jsm-ink-soft)', border: 'var(--jsm-line)', label: '대기' },
}; };
let entry = map[status]; let entry = map[status];
if (!entry) { if (!entry) {
entry = isFailed(status) entry = isFailed(status)
? { bg: 'rgba(215,51,87,0.18)', fg: '#ff8ba7', label: '실패' } ? { bg: '#fef2f2', fg: '#b91c1c', border: '#fecaca', label: '실패' }
: { bg: 'rgba(255,255,255,0.06)', fg: 'rgba(255,255,255,0.6)', label: status }; : { bg: 'var(--jsm-surface-alt)', fg: 'var(--jsm-ink-soft)', border: 'var(--jsm-line)', 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 border"
style={{ background: entry.bg, color: entry.fg }} style={{ background: entry.bg, color: entry.fg, borderColor: entry.border }}
> >
{entry.label} {entry.label}
</span> </span>
@@ -530,11 +828,8 @@ function StatusBadge({ status }: { status: string }) {
function Tip({ title, body }: { title: string; body: string }) { function Tip({ title, body }: { title: string; body: string }) {
return ( return (
<div <div className="rounded-xl p-4 bg-white border" style={{ borderColor: 'var(--jsm-line)' }}>
className="rounded-xl p-4" <p className="font-semibold mb-1" style={{ color: 'var(--jsm-ink)' }}>
style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}
>
<p className="font-semibold mb-1" style={{ color: 'var(--kx-on-surface)' }}>
{title} {title}
</p> </p>
<p className="leading-relaxed">{body}</p> <p className="leading-relaxed">{body}</p>

View File

@@ -6,6 +6,7 @@ import Link from 'next/link';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import type { User } from '@supabase/supabase-js'; import type { User } from '@supabase/supabase-js';
import TelegramGuideModal from '@/app/components/TelegramGuideModal'; import TelegramGuideModal from '@/app/components/TelegramGuideModal';
import BankTransferModal from '@/app/components/BankTransferModal';
import { KAKAO_OPENCHAT_URL } from '@/lib/contact'; import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
import { findCard } from '@/lib/tarot/cards'; import { findCard } from '@/lib/tarot/cards';
import { import {
@@ -68,10 +69,22 @@ type SajuRecordRow = {
is_paid: boolean; is_paid: boolean;
}; };
// AI 기록 탭 — 사주·타로 병합 렌더용 판별 유니언 // AI 기록 탭 — 음악 트랙 (app/api/studio/tracks 응답)
type MusicTrackRow = {
id: string;
title: string | null;
story: string | null;
audio_url: string | null;
created_at: string;
video_status?: 'none' | 'requested' | 'in_progress' | 'delivered';
video_url?: string | null;
};
// AI 기록 탭 — 사주·타로·음악 병합 렌더용 판별 유니언
type AiRecordItem = type AiRecordItem =
| { kind: 'saju'; data: SajuRecordRow } | { kind: 'saju'; data: SajuRecordRow }
| { kind: 'tarot'; data: TarotReadingRow }; | { kind: 'tarot'; data: TarotReadingRow }
| { kind: 'music'; data: MusicTrackRow };
interface Payment { interface Payment {
id: string; id: string;
@@ -152,10 +165,14 @@ function MyPageContent() {
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>(''); const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
const [showTelegramGuide, setShowTelegramGuide] = useState(false); const [showTelegramGuide, setShowTelegramGuide] = useState(false);
// AI 기록 탭 — 타로 리딩 / 사주 기록 // AI 기록 탭 — 타로 리딩 / 사주 기록 / 음악 트랙
const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]); const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]);
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]); const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
const [musicTracks, setMusicTracks] = useState<MusicTrackRow[]>([]);
const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set()); const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set());
// 음악 영상화 신청 — 계좌이체 모달에 전달할 상품·대상 트랙
const [videoProduct, setVideoProduct] = useState<{ id: string; name: string; price: number } | null>(null);
const [videoModalTrackId, setVideoModalTrackId] = useState<string | null>(null);
const loadProjects = useCallback(async () => { const loadProjects = useCallback(async () => {
try { try {
@@ -166,7 +183,7 @@ function MyPageContent() {
} catch { /* 미로그인/네트워크 — 무시 */ } } catch { /* 미로그인/네트워크 — 무시 */ }
}, []); }, []);
// 사주·타로 결과 통합 로드 — 다 실패해도 서로 영향 없이 무시(best-effort) // 사주·타로·음악 결과 통합 로드 — 다 실패해도 서로 영향 없이 무시(best-effort)
const loadAiRecords = useCallback(async () => { const loadAiRecords = useCallback(async () => {
try { try {
const tr = await fetch('/api/tarot/readings'); const tr = await fetch('/api/tarot/readings');
@@ -184,9 +201,30 @@ function MyPageContent() {
setSajuRecords(data ?? []); setSajuRecords(data ?? []);
} }
} catch { /* 무시 */ } } catch { /* 무시 */ }
try {
const mt = await fetch('/api/studio/tracks');
if (mt.ok) setMusicTracks((await mt.json()).tracks ?? []);
} catch { /* 무시 */ }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// 음악 트랙 영상화 신청 — 상품 정보 로드 후 계좌이체 모달 오픈
const handleRequestVideo = useCallback(async (trackId: string) => {
try {
const res = await fetch('/api/studio/video-product');
if (!res.ok) {
console.warn('영상화 상품을 불러오지 못했습니다', res.status);
return;
}
const data = await res.json();
if (!data?.product) return;
setVideoProduct(data.product);
setVideoModalTrackId(trackId);
} catch (e) {
console.warn('영상화 상품 조회 중 오류', e);
}
}, []);
useEffect(() => { useEffect(() => {
async function init() { async function init() {
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
@@ -369,10 +407,11 @@ function MyPageContent() {
// 입금 확인 대기 중인 주문 (orders 테이블 pending) // 입금 확인 대기 중인 주문 (orders 테이블 pending)
const pendingOrders = productOrders.filter((o) => o.status === 'pending'); const pendingOrders = productOrders.filter((o) => o.status === 'pending');
// AI 기록 탭 — 사주·타로 결과를 created_at 기준 내림차순 병합 // AI 기록 탭 — 사주·타로·음악 결과를 created_at 기준 내림차순 병합
const aiRecords: AiRecordItem[] = [ const aiRecords: AiRecordItem[] = [
...sajuRecords.map((r): AiRecordItem => ({ kind: 'saju', data: r })), ...sajuRecords.map((r): AiRecordItem => ({ kind: 'saju', data: r })),
...tarotReadings.map((r): AiRecordItem => ({ kind: 'tarot', data: r })), ...tarotReadings.map((r): AiRecordItem => ({ kind: 'tarot', data: r })),
...musicTracks.map((r): AiRecordItem => ({ kind: 'music', data: r })),
].sort((a, b) => new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime()); ].sort((a, b) => new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime());
const tabs: { key: Tab; label: string; count?: number }[] = [ const tabs: { key: Tab; label: string; count?: number }[] = [
@@ -380,7 +419,7 @@ function MyPageContent() {
{ key: 'requests', label: '발주·진행', count: orders.length || undefined }, { key: 'requests', label: '발주·진행', count: orders.length || undefined },
{ key: 'products', label: '내 제품', count: productGroups.length || undefined }, { key: 'products', label: '내 제품', count: productGroups.length || undefined },
{ key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined }, { key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined },
{ key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.length) || undefined }, { key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.length + musicTracks.length) || undefined },
]; ];
function selectTab(key: Tab) { function selectTab(key: Tab) {
@@ -910,7 +949,7 @@ function MyPageContent() {
AI AI
</div> </div>
<div className="text-sm mb-6 break-keep max-w-sm mx-auto" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}> <div className="text-sm mb-6 break-keep max-w-sm mx-auto" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
· . · · .
</div> </div>
<div className="flex flex-wrap items-center justify-center gap-3"> <div className="flex flex-wrap items-center justify-center gap-3">
<Link <Link
@@ -927,6 +966,13 @@ function MyPageContent() {
> >
</Link> </Link>
<Link
href="/music"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-colors hover:bg-[var(--jsm-surface-alt)]"
style={{ color: 'var(--jsm-ink)', border: '1px solid var(--jsm-line)' }}
>
</Link>
</div> </div>
</div> </div>
) : ( ) : (
@@ -934,13 +980,19 @@ function MyPageContent() {
{aiRecords.map((item) => {aiRecords.map((item) =>
item.kind === 'saju' ? ( item.kind === 'saju' ? (
<SajuAiCard key={`saju-${item.data.id}`} record={item.data} /> <SajuAiCard key={`saju-${item.data.id}`} record={item.data} />
) : ( ) : item.kind === 'tarot' ? (
<TarotAiCard <TarotAiCard
key={`tarot-${item.data.id}`} key={`tarot-${item.data.id}`}
reading={item.data} reading={item.data}
expanded={expandedAiCards.has(item.data.id)} expanded={expandedAiCards.has(item.data.id)}
onToggle={() => toggleAiCard(item.data.id)} onToggle={() => toggleAiCard(item.data.id)}
/> />
) : (
<MusicAiCard
key={`music-${item.data.id}`}
track={item.data}
onRequestVideo={handleRequestVideo}
/>
) )
)} )}
</div> </div>
@@ -948,6 +1000,17 @@ function MyPageContent() {
</div> </div>
)} )}
</div> </div>
{/* 음악 영상화 신청 — 계좌이체 모달 (영상화 상품 로드 완료 시에만 오픈) */}
<BankTransferModal
isOpen={!!videoModalTrackId && !!videoProduct}
product={videoProduct ?? { id: '', name: '', price: 0 }}
musicTrackId={videoModalTrackId ?? undefined}
onClose={() => {
setVideoModalTrackId(null);
loadAiRecords();
}}
/>
</div> </div>
); );
} }
@@ -1569,6 +1632,108 @@ function TarotAiCard({
); );
} }
// AI 기록 — 음악 카드. 제목·날짜 + 스토리 요약 + 오디오 플레이어(생성 완료 시) + 영상화 신청/상태.
function MusicAiCard({
track,
onRequestVideo,
}: {
track: MusicTrackRow;
onRequestVideo: (trackId: string) => void;
}) {
return (
<Card compact>
<div className="flex items-center justify-between gap-3 mb-3">
<span
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
</span>
<span className="text-xs flex-shrink-0" style={{ color: 'var(--jsm-ink-faint)' }}>
{new Date(track.created_at).toLocaleDateString('ko-KR')}
</span>
</div>
<div className="text-sm font-semibold mb-2 break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{track.title || '제목 없는 트랙'}
</div>
{track.story && (
<p
className="text-sm line-clamp-2 break-keep mb-3"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{track.story}
</p>
)}
{track.audio_url ? (
<audio controls className="w-full mb-3" style={{ height: 36 }}>
<source src={track.audio_url} />
</audio>
) : (
<p className="text-xs mb-3" style={{ color: 'var(--jsm-ink-faint)' }}>
. .
</p>
)}
<div className="pt-3 border-t" style={{ borderColor: 'var(--jsm-line)' }}>
{(!track.video_status || track.video_status === 'none') && (
<button
type="button"
onClick={() => onRequestVideo(track.id)}
className="px-4 py-2 rounded-lg text-xs font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)' }}
>
</button>
)}
{track.video_status === 'requested' && (
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
·
</span>
)}
{track.video_status === 'in_progress' && (
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
</span>
)}
{track.video_status === 'delivered' && (
track.video_url ? (
<div>
<video controls src={track.video_url} className="w-full rounded-lg mb-2" />
<a
href={track.video_url}
download
className="text-xs font-semibold underline"
style={{ color: 'var(--jsm-accent)' }}
>
</a>
</div>
) : (
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
</span>
)
)}
</div>
</Card>
);
}
function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) { function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
return ( return (
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden> <svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden>

View File

@@ -0,0 +1,436 @@
# Phase 3a 음악 서비스 공개화 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 숨김 상태의 Suno 음악 스튜디오를 공개·무료화하고 "스토리→음악"(Gemini) 흐름·회원 저장·라이트 디자인을 붙인다.
**Architecture:** 사주·타로의 "공개+무료+로그인 저장+일일제한" 패턴을 음악에 적용. Gemini가 스토리→가사/스타일 변환, Suno가 음악 생성(폴링), 완료 시 회원 저장. 음악 페이지는 --jsm 라이트로 재스킨.
**Tech Stack:** Next.js 16 (App Router, TS), Tailwind v4(`--jsm-*`), Supabase, @google/generative-ai, Suno API, vitest
**Spec:** `docs/superpowers/specs/2026-07-03-phase3a-music-public-design.md`
## Global Constraints
- **순수 시각 변경 태스크**에서는 로직 라인 미변경(className/style만); **API 태스크**에서는 인증→제한→호출→(성공)recordUsage 순서 준수
- 신규 색 토큰 금지 — 11개 `--jsm-*`만. 음악 신규/재스킨 파일에서 `gradient`/`violet`/`purple`/`blur`/이모지 **0건**
- 일일 제한: `MUSIC_DAILY_LIMIT = 1`. 생성(Suno) 성공 시에만 `recordUsage('music')`. story(Gemini) 단계는 인증만, 미집계
- GEMINI_API_KEY/SUNO_API_KEY 미설정 시 각각 503(예시 폴백 금지)
- `ai_usage_log` CHECK ALTER는 **phase2-saju-tarot 마이그 DB 적용 후** 실행 전제(플랜/CEO 안내에 명시)
- next.config.ts 수정 금지, 기존 supabase/migrations/ 파일 수정 금지(신규만)
- 커밋은 스코프 파일만 — **`git add -A`·`git commit -a` 금지**, 커밋 전 `git status` 확인
- 각 Task 종료 시 `npm run build` 성공 + `npm test`(30→) 유지 후 커밋
- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
## 확인된 기존 계약
- `POST /api/studio/generate`(무인증): body `{mode:'simple'|'custom', prompt?, title?, lyrics?, tags?, make_instrumental?, model?}` → Suno `/api/v1/generate``{ ok, data }`. `callBackUrl=${origin}/api/studio/callback`(부재)
- `GET /api/studio/status?taskId=`(무인증) → Suno record-info `{ ok, data }`
- `lib/ai-usage.ts`: `AiService='saju'|'tarot'`, `kstDayStartISO`, `getTodayUsage(admin,userId,service)`, `recordUsage(admin,userId,service)`
- supabase 헬퍼: `createClient()`(세션·RLS), `createAdminClient()`(service role). 타로 prompt 방어 패턴: `app/api/tarot/interpret/route.ts`, `lib/tarot/prompt.ts`
---
### Task 1: ai-usage 확장 + DB 마이그레이션
**Files:**
- Modify: `lib/ai-usage.ts`
- Create: `supabase/migrations/2026-07-03-phase3a-music.sql`
- Modify: `lib/__tests__/ai-usage.test.ts`
**Interfaces:**
- Produces: `AiService = 'saju'|'tarot'|'music'`, `MUSIC_DAILY_LIMIT = 1`, `music_tracks` 테이블. Task 3·4·6이 소비
- [ ] **Step 1: 테스트에 music 상수 추가**
`lib/__tests__/ai-usage.test.ts`의 상수 검증 `it`에 추가(기존 테스트 유지):
```typescript
import { kstDayStartISO, SAJU_DAILY_LIMIT, TAROT_DAILY_LIMIT, MUSIC_DAILY_LIMIT } from '../ai-usage';
// ... 기존 KST 테스트 유지 ...
it('음악 일일 제한 상수', () => {
expect(MUSIC_DAILY_LIMIT).toBe(1);
});
```
- [ ] **Step 2: 실패 확인**`npx vitest run lib/__tests__/ai-usage.test.ts` → FAIL(MUSIC_DAILY_LIMIT 없음)
- [ ] **Step 3: ai-usage.ts 확장**
`lib/ai-usage.ts`:
```typescript
export const SAJU_DAILY_LIMIT = 1;
export const TAROT_DAILY_LIMIT = 3;
export const MUSIC_DAILY_LIMIT = 1;
export type AiService = 'saju' | 'tarot' | 'music';
```
(getTodayUsage/recordUsage/kstDayStartISO 본문 무변경 — AiService 타입만 확장되어 'music' 허용)
- [ ] **Step 4: 통과 확인**`npx vitest run lib/__tests__/ai-usage.test.ts` → PASS
- [ ] **Step 5: 마이그레이션 파일**
`supabase/migrations/2026-07-03-phase3a-music.sql`:
```sql
-- Phase 3a (2026-07-03): 음악 회원 저장 + 사용량 로그 확장 + 음악 숨김 해제
-- 의존성: 2026-07-02-phase2-saju-tarot.sql(ai_usage_log 생성) 적용 후 실행
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
CREATE TABLE IF NOT EXISTS music_tracks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title text,
story text,
lyrics text,
style text,
audio_url text,
task_id text,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE music_tracks ENABLE ROW LEVEL SECURITY;
CREATE POLICY music_select_own ON music_tracks FOR SELECT USING (auth.uid() = user_id);
-- ai_usage_log CHECK에 'music' 추가 (phase2의 인라인 CHECK auto-name 제거 후 재정의)
ALTER TABLE ai_usage_log DROP CONSTRAINT IF EXISTS ai_usage_log_service_check;
ALTER TABLE ai_usage_log ADD CONSTRAINT ai_usage_log_service_check CHECK (service IN ('saju','tarot','music'));
DELETE FROM service_settings WHERE id = 'music';
```
- [ ] **Step 6: 검증·커밋**`npm test && npm run build` PASS. `git add lib/ai-usage.ts lib/__tests__/ai-usage.test.ts supabase/migrations/2026-07-03-phase3a-music.sql && git commit -m "feat(phase3a): ai-usage에 music 추가 + music_tracks·CHECK 마이그레이션"`
---
### Task 2: 음악 공개화 (가드 제거)
**Files:**
- Modify: `app/music/layout.tsx`
- Modify: `lib/service-visibility.ts`
- Modify: `app/api/admin/services/route.ts`
**Interfaces:**
- Consumes: 없음
- Produces: `/music*` 공개
- [ ] **Step 1: layout 가드 제거**
`app/music/layout.tsx`: `isServiceVisible`/`notFound` import·호출 제거, metadata 있으면 유지, 단순 `return <>{children}</>`. (파일 먼저 Read — metadata 유무 확인)
- [ ] **Step 2: HideableService에서 music 제거**
`lib/service-visibility.ts`: `export type HideableService = 'gyeol' | 'lotto';`
- [ ] **Step 3: DEFAULT_SERVICES music 행 제거**
`app/api/admin/services/route.ts` DEFAULT_SERVICES에서 `{ id: 'music', ... }` 한 줄 삭제(gyeol/lotto 유지). (service_settings music DELETE는 Task 1 마이그레이션이 담당)
- [ ] **Step 4: 검증·커밋**`npm test && npm run build`(빌드 라우트에 /music이 static/공개로 등장). `git add app/music/layout.tsx lib/service-visibility.ts app/api/admin/services/route.ts && git commit -m "feat(phase3a): 음악 서비스 공개화 — 가드·HideableService·DEFAULT_SERVICES 정리"`
---
### Task 3: 스토리→음악 (story-prompt + story API + generate 인증/제한 + callback)
**Files:**
- Create: `lib/music/story-prompt.ts`
- Create: `app/api/studio/story/route.ts`
- Create: `app/api/studio/callback/route.ts`
- Modify: `app/api/studio/generate/route.ts`
**Interfaces:**
- Consumes: `getTodayUsage`/`recordUsage`/`MUSIC_DAILY_LIMIT`(T1), `createClient`/`createAdminClient`
- Produces:
- `type MusicStory = { title: string; lyrics: string; style: string; mood: string }`
- `POST /api/studio/story` (로그인) → 200 `{ story: MusicStory }` / 401 / 503
- `POST /api/studio/generate` (로그인+제한) → 기존 `{ ok, data }` / 401 / 429 / 503
- `POST /api/studio/callback``{ ok: true }`
- Task 6이 소비
- [ ] **Step 1: story-prompt 모듈** (타로 prompt.ts 방어 패턴 포팅)
`lib/music/story-prompt.ts`:
```typescript
export type MusicStory = { title: string; lyrics: string; style: string; mood: string };
export const STORY_SYSTEM_PROMPT = `당신은 사용자의 개인적 이야기를 노래로 바꾸는 작사가 겸 음악 프로듀서입니다.
사용자가 들려준 이야기를 바탕으로:
1. title: 노래 제목(짧고 인상적으로)
2. lyrics: 이야기의 감정과 장면을 담은 한국어 가사(절/후렴 구조, 6~16줄)
3. style: 어울리는 음악 장르·악기·템포를 영어 키워드로(Suno style, 예 "acoustic ballad, warm piano, mid tempo")
4. mood: 전체 정서를 한 단어로(예 "그리움", "희망")
반드시 코드블록 없이 순수 JSON만 출력합니다: {"title","lyrics","style","mood"}
사용자 이야기에 없는 사실을 지어내지 말고, 감정에 충실하게 각색합니다.`;
export function buildStoryUserMessage(story: string): string {
return `사용자의 이야기:\n${story}\n\n위 이야기를 노래로 만들기 위한 JSON을 생성하세요.`;
}
export function parseStoryJson(raw: string): MusicStory | null {
let text = raw.trim().replace(/^\`\`\`(json)?/i, '').replace(/\`\`\`$/,'').trim();
const first = text.indexOf('{'); const last = text.lastIndexOf('}');
if (first >= 0 && last > first) text = text.slice(first, last + 1);
try { return JSON.parse(text) as MusicStory; } catch { return null; }
}
export function validateStory(obj: unknown): string | null {
if (!obj || typeof obj !== 'object') return 'not an object';
const o = obj as Record<string, unknown>;
for (const k of ['title', 'lyrics', 'style', 'mood']) {
if (typeof o[k] !== 'string' || !(o[k] as string).trim()) return `${k} 누락`;
}
return null;
}
```
- [ ] **Step 2: story API** (타로 interpret의 Gemini 폴백·45s 가드·reroll 패턴)
`app/api/studio/story/route.ts``app/api/tarot/interpret/route.ts`를 참고해 동일 SDK 사용법으로:
```typescript
import { NextResponse } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { createClient } from '@/lib/supabase/server';
import { STORY_SYSTEM_PROMPT, buildStoryUserMessage, parseStoryJson, validateStory } from '@/lib/music/story-prompt';
import { config as loadDotenv } from 'dotenv';
import { resolve } from 'path';
export const runtime = 'nodejs';
export const maxDuration = 60;
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
const MODELS = [{ id: 'gemini-2.5-pro', maxTokens: 8192 }, { id: 'gemini-2.5-flash', maxTokens: 8192 }, { id: 'gemini-2.0-flash', maxTokens: 8192 }];
const MAX_ATTEMPTS = 3;
const TIME_BUDGET_MS = 45_000;
export async function POST(request: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
let body: Record<string, unknown>;
try { body = await request.json(); } catch { return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 }); }
const story = typeof body.story === 'string' ? body.story.trim() : '';
if (!story) return NextResponse.json({ error: '이야기를 입력해주세요.' }, { status: 400 });
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
const genAI = new GoogleGenerativeAI(apiKey);
const userMsg = buildStoryUserMessage(story);
const startedAt = Date.now();
let attempts = 0; let feedback = '';
for (const m of MODELS) {
for (let retry = 0; retry < 2; retry += 1) {
if (attempts >= MAX_ATTEMPTS || Date.now() - startedAt > TIME_BUDGET_MS) break;
attempts += 1;
try {
const model = genAI.getGenerativeModel({ model: m.id, systemInstruction: STORY_SYSTEM_PROMPT, generationConfig: { temperature: 0.9, topP: 0.95, maxOutputTokens: m.maxTokens } });
const prompt = feedback ? `${userMsg}\n\n[이전 오류: ${feedback}] 스키마를 지켜 다시 출력하세요.` : userMsg;
const res = await model.generateContent(prompt);
const parsed = parseStoryJson(res.response.text());
const invalid = parsed ? validateStory(parsed) : 'JSON 파싱 실패';
if (parsed && !invalid) return NextResponse.json({ story: parsed });
feedback = invalid ?? 'JSON 파싱 실패';
} catch (e) { feedback = e instanceof Error ? e.message : 'model error'; break; }
}
if (attempts >= MAX_ATTEMPTS) break;
}
return NextResponse.json({ error: '가사 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' }, { status: 502 });
}
```
(작성 전 `app/api/tarot/interpret/route.ts`를 Read해 실제 SDK 시그니처와 일치시킬 것)
- [ ] **Step 3: callback 최소 라우트**
`app/api/studio/callback/route.ts`:
```typescript
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
// Suno webhook 수신용 최소 엔드포인트. 회원 저장은 폴링+클라 트리거(/api/studio/tracks)가 담당하므로 여기선 200만.
export async function POST() { return NextResponse.json({ ok: true }); }
```
- [ ] **Step 4: generate에 인증 + 일일제한**
`app/api/studio/generate/route.ts` POST 최상단(Suno 키 체크 전 또는 직후)에 인증·제한 추가:
```typescript
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { getTodayUsage, recordUsage, MUSIC_DAILY_LIMIT } from '@/lib/ai-usage';
// POST 시작부:
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
const admin = createAdminClient();
if ((await getTodayUsage(admin, user.id, 'music')) >= MUSIC_DAILY_LIMIT) {
return NextResponse.json({ error: `오늘 음악 생성을 모두 사용했습니다. (${MUSIC_DAILY_LIMIT}회/일)` }, { status: 429 });
}
```
그리고 Suno task 생성이 성공 반환(`return NextResponse.json({ ok: true, data })`)되기 **직전**에 `await recordUsage(admin, user.id, 'music');` 추가. (503/502/400 실패 경로엔 넣지 않음)
- [ ] **Step 5: 검증·커밋**`npm test && npm run build`(라우트 /api/studio/story·/callback 등장). `git add lib/music/story-prompt.ts app/api/studio/story/route.ts app/api/studio/callback/route.ts app/api/studio/generate/route.ts && git commit -m "feat(phase3a): 스토리→가사(Gemini) + generate 인증·일일제한 + callback 정리"`
---
### Task 4: 음악 저장·조회 API
**Files:**
- Create: `app/api/studio/tracks/route.ts`
**Interfaces:**
- Consumes: `createClient`/`createAdminClient`, `music_tracks`(T1)
- Produces:
- `POST /api/studio/tracks` (로그인) body `{ title?, story?, lyrics?, style?, audio_url?, task_id? }``{ id, created_at }` / 401
- `GET /api/studio/tracks` (로그인) → `{ tracks: [{ id, title, story, lyrics, style, audio_url, task_id, created_at }] }` / 401
- Task 6이 소비
- [ ] **Step 1: 구현** (타로 readings 패턴)
`app/api/studio/tracks/route.ts`:
```typescript
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
let body: Record<string, unknown>;
try { body = await request.json(); } catch { return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 }); }
const str = (k: string) => (typeof body[k] === 'string' ? (body[k] as string) : null);
const admin = createAdminClient();
const { data, error } = await admin.from('music_tracks').insert({
user_id: user.id,
title: str('title'), story: str('story'), lyrics: str('lyrics'),
style: str('style'), audio_url: str('audio_url'), task_id: str('task_id'),
}).select('id, created_at').single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data);
}
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
const { data, error } = await supabase
.from('music_tracks')
.select('id, title, story, lyrics, style, audio_url, task_id, created_at')
.order('created_at', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ tracks: data ?? [] });
}
```
- [ ] **Step 2: 검증·커밋**`npm test && npm run build`. `git add app/api/studio/tracks/route.ts && git commit -m "feat(phase3a): 음악 트랙 저장·조회 API (user_id + RLS)"`
---
### Task 5: music/page·samples 라이트 재스킨
**Files:**
- Modify: `app/music/page.tsx` (72줄)
- Modify: `app/music/samples/page.tsx` (102줄)
**Interfaces:**
- Consumes: 없음
- Produces: 없음
- [ ] **Step 1: 두 파일 Read 후 --jsm 치환**
색상 매핑(사주 재스킨과 동일): 다크 hex/`gradient`/`violet`/`purple`/`blur`/amber → `--jsm-navy`(밴드 플랫)/`accent`/`accent-soft`/`surface`/`line`/`ink`. 이모지 있으면 제거 또는 인라인 SVG. **로직·데이터 조회·JSX 구조 미변경.** navy 밴드=무테두리 flat + 흰 CTA 관용구.
- [ ] **Step 2: 게이트·검증**
```bash
grep -nE "gradient|violet|purple|blur" app/music/page.tsx app/music/samples/page.tsx # 0
npm run build && npm test
```
- [ ] **Step 3: 커밋**`git add app/music/page.tsx app/music/samples/page.tsx && git commit -m "feat(phase3a): 음악 랜딩·샘플 라이트 재스킨"`
---
### Task 6: studio 라이트 재스킨 + 스토리 UI 흐름
**Files:**
- Modify: `app/music/studio/page.tsx` (543줄)
**Interfaces:**
- Consumes: `/api/studio/story`(T3), `/api/studio/generate`(T3), `/api/studio/status`(기존), `/api/studio/tracks`(T4)
- Produces: 없음
- [ ] **Step 1: Read 후 라이트 재스킨 + 스토리 흐름 재구성**
`app/music/studio/page.tsx` 전체 Read. 두 가지 동시:
1. **라이트 재스킨**: 다크/gradient/violet/purple/amber/이모지 → --jsm 토큰(사주 스튜디오 아님 — 신규 라이트). 폼 필드는 라이트 관용구(`bg-white`+`border-[var(--jsm-line)]`+`focus:border-[var(--jsm-accent)]`)
2. **스토리 UI 흐름**(기존 prompt/lyrics 직접입력 → 스토리 우선 흐름으로 확장, 기존 custom/simple 모드는 "직접 입력" 탭으로 보존 가능):
- ①스토리 textarea + "가사 만들기" → `POST /api/studio/story` → 401이면 로그인 CTA(`/login?next=/music/studio`), 503/502면 안내
- ②반환된 `{title, lyrics, style, mood}` 미리보기(편집 가능한 필드)
- ③"음악 만들기" → `POST /api/studio/generate`(custom 모드, title/lyrics/tags=style) → 429면 제한 안내
- ④기존 `status` 폴링 로직 유지 → 완료 시 오디오 URL 표시(플레이어)
- ⑤완료+로그인 시 `POST /api/studio/tracks`로 자동 저장(best-effort, 실패해도 재생 유지)
- 디자인 가드레일: gradient/blur/보라/이모지 0건
- [ ] **Step 2: 게이트·검증**
```bash
grep -nE "gradient|violet|purple|blur" app/music/studio/page.tsx # 0
npm run build && npm test
```
- [ ] **Step 3: 커밋**`git add app/music/studio/page.tsx && git commit -m "feat(phase3a): 음악 스튜디오 라이트 재스킨 + 스토리→음악 흐름"`
---
### Task 7: TopNav + 마이페이지 AI기록 음악 + CLAUDE.md + 최종 검증
**Files:**
- Modify: `app/components/TopNav.tsx`
- Modify: `app/mypage/page.tsx`
- Modify: `CLAUDE.md`
**Interfaces:**
- Consumes: `/api/studio/tracks`(T4)
- Produces: 문서·네비 정합
- [ ] **Step 1: TopNav 링크**
`app/components/TopNav.tsx` LINKS에 `{ href: '/music', label: '음악' }` 추가(외주/소프트웨어/제작사례/사주/타로/음악 = 6링크). 다른 항목 무변.
- [ ] **Step 2: 마이페이지 AI 기록 탭에 음악 통합**
`app/mypage/page.tsx`의 'AI 기록' 탭(사주·타로 병합 리스트)에 음악 트랙 추가:
- 타입 `type MusicTrackRow = { id: string; title: string | null; story: string | null; audio_url: string | null; created_at: string }` 추가
- state·로드: `loadAiRecords``fetch('/api/studio/tracks')` 추가(try/catch, `{ tracks }`)
- 렌더: 병합 내림차순에 음악 카드 추가(제목·스토리 요약·오디오 링크/`<audio>`), 빈 상태 CTA에 `/music` 추가
- **기존 사주·타로 렌더·로직 미변경, 음악만 추가**
- [ ] **Step 3: CLAUDE.md 갱신**
- 핵심 IA 표에 `/music`(공개 음악 — 스토리→음악) 추가
- 숨김 서비스 표에서 `/music/*` 행 제거
- 파일 구조에 `lib/music/`, `api/studio/{story,tracks,callback}` 반영
- `/mypage` 탭 서술에 음악 포함(AI 기록: 사주·타로·음악)
- [ ] **Step 4: 최종 검증**
```bash
grep -rnE "gradient|violet|purple|blur" app/music --include="*.tsx" # 0
npm run build # /music·/music/studio·/music/samples·/api/studio/{story,tracks,callback} 라우트 존재
npm test # 30+ PASS
```
- [ ] **Step 5: 커밋**`git add app/components/TopNav.tsx app/mypage/page.tsx CLAUDE.md && git commit -m "feat(phase3a): TopNav 음악 + 마이페이지 AI기록 음악 통합 + CLAUDE.md"`
- [ ] **Step 6: CEO 안내(보고)**
- 마이그레이션 `2026-07-03-phase3a-music.sql`을 클라우드+NAS 양쪽 적용(**phase2 마이그 적용 후**)
- `SUNO_API_KEY`·`GEMINI_API_KEY` 운영 설정 확인(미설정 시 각 503)
- 수동 E2E: 비로그인 /music/studio → 스토리 입력→가사→로그인 CTA→로그인 후 생성(일1회)→저장→마이페이지 AI기록 음악
---
## 검증 요약
| 검증 | 명령 | 기대 |
|------|------|------|
| 음악 가드레일 | `grep -rnE "gradient\|violet\|purple\|blur" app/music --include="*.tsx"` | 0건 |
| 단위 테스트 | `npm test` | ai-usage music 포함 전체 PASS |
| 빌드 | `npm run build` | /music·studio·samples·api/studio/{story,tracks,callback} |

View File

@@ -0,0 +1,272 @@
# Phase 3b 음악 영상화 유료 발주 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 회원이 저장한 음악 트랙을 계좌이체로 "영상화 발주"하고 관리자가 URL로 완성 영상을 납품하는 흐름을 붙인다.
**Architecture:** 기존 orders(계좌이체)·admin/orders·메일 인프라를 재사용. `music_video` 제품 1행 + music_tracks의 video_status/video_url 컬럼으로 상태를 표현. 신청은 마이페이지 음악 카드, 납품은 admin/orders에서 URL 입력.
**Tech Stack:** Next.js 16 (App Router, TS), Supabase, Tailwind v4(`--jsm-*`), vitest
**Spec:** `docs/superpowers/specs/2026-07-09-phase3b-music-video-order-design.md`
## Global Constraints
- **순수 시각 태스크 없음** — 대부분 API/데이터. 신규/변경 UI는 라이트 가드레일(gradient/blur/보라/이모지 금지, `--jsm-*`만)
- orders POST의 일반 상품(products) 발주 동작은 **회귀 금지** — music_video 분기만 추가
- `BankTransferModal`은 다른 상품도 사용 — `musicTrackId`**optional**, 미전달 시 기존 동작 불변
- 인증→검증→admin insert(user_id는 세션), user_id 스푸핑 불가. 본인 트랙만 발주(music_tracks user_id 일치 검증)
- 마이그레이션은 phase3a(music_tracks) 적용 후 실행 전제 — 헤더에 명시. 기존 마이그레이션 수정 금지, 신규만
- 커밋은 스코프 파일만 — **`git add -A`·`git commit -a` 금지**, 커밋 전 `git status` 확인
- 각 Task 종료 시 `npm run build` 성공 + `npm test`(31) 유지 후 커밋
- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
## 확인된 기존 계약
- `products`: `{ id, name, price, is_active, ... }`. `getProductById(admin, id)`(`lib/supabase/product-files.ts`)
- `POST /api/orders`(`app/api/orders/route.ts`): 인증→rate-limit→`sanitizeStr(productId/depositorName)`→getProductById(active)→중복 pending 가드(user+product)→insert `{user_id, product_id, amount:product.price, status:'pending', metadata:{method:'bank_transfer', depositor_name}}`→접수 메일
- `admin/orders`(`app/api/admin/orders/route.ts`): GET select `id,user_id,product_id,amount,status,metadata,created_at`; PATCH `{id, status∈pending|paid|cancelled}`, paid 시 `sendOrderPaidEmail`
- `BankTransferModal({ product:{id,name,price}, isOpen, onClose })` → POST `{ productId, depositorName }`
- `music_tracks`: `{ id, user_id, title, story, lyrics, style, audio_url, task_id, created_at }`(Phase 3a), RLS `music_select_own`
- 마이페이지 AI기록: `MusicTrackRow = { id, title, story, audio_url, created_at }`, `MusicAiCard`
---
### Task 1: DB 마이그레이션 (music_video 제품 + music_tracks 확장)
**Files:**
- Create: `supabase/migrations/2026-07-09-phase3b-music-video.sql`
**Interfaces:**
- Produces: `products``music_video`, `music_tracks.video_status/video_url/video_order_id`. Task 2·3·4가 소비
- [ ] **Step 1: products 실제 컬럼 확인**
`005_all_products.sql` 또는 products 생성 마이그레이션을 Read해 필수(NOT NULL, default 없는) 컬럼 파악. `id,name,price,is_active` 외 NOT NULL 컬럼이 있으면 시드 INSERT에 값 포함.
- [ ] **Step 2: 마이그레이션 파일 작성**
`supabase/migrations/2026-07-09-phase3b-music-video.sql`:
```sql
-- Phase 3b (2026-07-09): 음악 영상화 발주
-- 의존성: 2026-07-03-phase3a-music.sql(music_tracks) 적용 후
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
INSERT INTO products (id, name, price, is_active)
VALUES ('music_video', '음악 영상화', 30000, true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE music_tracks
ADD COLUMN IF NOT EXISTS video_status text NOT NULL DEFAULT 'none'
CHECK (video_status IN ('none','requested','in_progress','delivered')),
ADD COLUMN IF NOT EXISTS video_url text,
ADD COLUMN IF NOT EXISTS video_order_id uuid;
```
(Step 1에서 products에 추가 NOT NULL 컬럼 발견 시 INSERT 컬럼 목록·값 확장)
- [ ] **Step 3: 검증·커밋**`npm test && npm run build` PASS. `git add supabase/migrations/2026-07-09-phase3b-music-video.sql && git commit -m "feat(phase3b): music_video 제품 시드 + music_tracks video 컬럼 마이그레이션"`
---
### Task 2: 발주 API 확장 (orders POST + video-product GET)
**Files:**
- Modify: `app/api/orders/route.ts`
- Create: `app/api/studio/video-product/route.ts`
**Interfaces:**
- Consumes: music_tracks(T1), `createAdminClient`, `getProductById`
- Produces:
- `POST /api/orders` body에 optional `musicTrackId` — music_video 발주 시 트랙 검증·연결
- `GET /api/studio/video-product` (로그인) → `{ product: { id, name, price, is_active } }` / 401 / 404
- Task 3이 소비
- [ ] **Step 1: orders POST에 musicTrackId 처리 추가**
`app/api/orders/route.ts`를 Read 후, body 파싱부에 `musicTrackId` 추출(`sanitizeStr(rawMusicTrackId, 64)`). productId 검증 이후·insert 이전에:
```typescript
// music_video 발주: 본인 트랙 검증 + 트랙별 중복 가드
let musicTrackId: string | null = null;
if (productId === 'music_video') {
musicTrackId = sanitizeStr((body as Record<string, unknown>).musicTrackId, 64) || null;
if (!musicTrackId) return NextResponse.json({ error: '영상화할 트랙이 필요합니다' }, { status: 400 });
const { data: track } = await admin
.from('music_tracks').select('id').eq('id', musicTrackId).eq('user_id', user.id).maybeSingle();
if (!track) return NextResponse.json({ error: '본인 음악 트랙만 영상화할 수 있습니다' }, { status: 404 });
}
```
중복 pending 가드를 분기: music_video면 트랙 단위로
```typescript
const dupQuery = admin.from('orders').select('id')
.eq('user_id', user.id).eq('product_id', productId).eq('status', 'pending');
const { data: existing } = productId === 'music_video' && musicTrackId
? await dupQuery.eq('metadata->>music_track_id', musicTrackId).maybeSingle()
: await dupQuery.maybeSingle();
```
insert의 metadata에 music_track_id 포함:
```typescript
metadata: {
method: 'bank_transfer',
depositor_name: depositorName,
...(musicTrackId ? { music_track_id: musicTrackId } : {}),
},
```
insert 성공(orderId 확보) 후, 메일 발송 전/후에:
```typescript
if (productId === 'music_video' && musicTrackId) {
await admin.from('music_tracks')
.update({ video_status: 'requested', video_order_id: orderId })
.eq('id', musicTrackId).eq('user_id', user.id);
}
```
(일반 상품 경로는 위 분기 밖 — 기존 동작 그대로)
- [ ] **Step 2: video-product GET**
`app/api/studio/video-product/route.ts`:
```typescript
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { getProductById } from '@/lib/supabase/product-files';
export const runtime = 'nodejs';
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
const admin = createAdminClient();
const product = await getProductById(admin, 'music_video');
if (!product || !product.is_active) return NextResponse.json({ error: '영상화 상품이 준비 중입니다.' }, { status: 404 });
return NextResponse.json({ product: { id: product.id, name: product.name, price: product.price, is_active: product.is_active } });
}
```
- [ ] **Step 3: 검증·커밋**`npm test && npm run build`(라우트 /api/studio/video-product 등장). `git add app/api/orders/route.ts app/api/studio/video-product/route.ts && git commit -m "feat(phase3b): orders에 영상화(music_video) 발주 + video-product 조회"`
---
### Task 3: BankTransferModal 확장 + 마이페이지 신청/상태
**Files:**
- Modify: `app/components/BankTransferModal.tsx`
- Modify: `app/api/studio/tracks/route.ts` (GET select에 video 컬럼 추가)
- Modify: `app/mypage/page.tsx` (MusicAiCard + MusicTrackRow)
**Interfaces:**
- Consumes: `POST /api/orders`(musicTrackId, T2), `GET /api/studio/video-product`(T2)
- Produces: 없음
- [ ] **Step 1: tracks GET에 video 컬럼**
`app/api/studio/tracks/route.ts` GET select에 `video_status, video_url` 추가:
```typescript
.select('id, title, story, lyrics, style, audio_url, task_id, created_at, video_status, video_url')
```
- [ ] **Step 2: BankTransferModal에 musicTrackId optional prop**
`app/components/BankTransferModal.tsx`: Props에 `musicTrackId?: string` 추가. POST body를 조건부 병합:
```typescript
body: JSON.stringify({ productId: product.id, depositorName: name, ...(musicTrackId ? { musicTrackId } : {}) }),
```
(useCallback deps에 `musicTrackId` 추가. 다른 로직·UI 무변경)
- [ ] **Step 3: 마이페이지 음악 카드 영상화 UI**
`app/mypage/page.tsx`:
- `MusicTrackRow` 타입에 `video_status?: 'none'|'requested'|'in_progress'|'delivered'`, `video_url?: string | null` 추가
- state: `videoProduct`({id,name,price} | null), `videoModalTrackId`(string | null)
- `MusicAiCard`에 video 영역:
- `video_status` 없음/`'none'`: "영상화 신청" 버튼 → `GET /api/studio/video-product``videoProduct` 로드 후 `videoModalTrackId=track.id``BankTransferModal` 오픈(product=videoProduct, musicTrackId=track.id, onClose로 목록 갱신 loadAiRecords)
- `'requested'`: 뱃지 "영상화 접수됨 · 입금 확인 중"
- `'in_progress'`: 뱃지 "영상 제작 중"
- `'delivered'` + `video_url`: `<video controls src={video_url} className="...">` + "영상 다운로드" 링크(`href={video_url}` download)
- 뱃지·버튼은 `--jsm-*` 토큰(gradient/보라/이모지 금지)
- 페이지 하단에 `BankTransferModal` 1개 마운트: `isOpen={!!videoModalTrackId && !!videoProduct}`, `product={videoProduct}`, `musicTrackId={videoModalTrackId}`, `onClose={() => { setVideoModalTrackId(null); loadAiRecords(); }}`
- 기존 사주·타로 카드·로직 미변경
- [ ] **Step 4: 검증·커밋**`npm test && npm run build`. `grep -nE "gradient|violet|purple|blur" app/mypage/page.tsx app/components/BankTransferModal.tsx`(신규분 0). `git add app/components/BankTransferModal.tsx app/api/studio/tracks/route.ts app/mypage/page.tsx && git commit -m "feat(phase3b): 마이페이지 음악 영상화 신청·상태 + 모달 트랙 연결"`
---
### Task 4: admin 납품 (연결 트랙 표시 + 영상 URL 입력)
**Files:**
- Modify: `app/api/admin/orders/route.ts` (GET 트랙 조인 + PATCH videoUrl/videoStatus)
- Modify: `app/admin/orders/page.tsx` (music_video 주문 UI)
**Interfaces:**
- Consumes: music_tracks(T1), orders metadata.music_track_id(T2)
- Produces: 없음
- [ ] **Step 1: admin/orders GET에 트랙 정보 조인**
`app/api/admin/orders/route.ts` GET: 주문 조회 후, `product_id==='music_video'`인 주문들의 `metadata.music_track_id`를 모아 `music_tracks`에서 `id,title,audio_url,video_status,video_url` 조회 → 각 주문에 `musicTrack` 필드로 첨부. 응답 형태에 `musicTrack?: { id, title, audio_url, video_status, video_url }` 포함.
- [ ] **Step 2: admin/orders PATCH에 videoUrl/videoStatus**
`app/api/admin/orders/route.ts` PATCH: body `{ id, status?, videoUrl?, videoStatus? }`. 기존 status 검증은 status가 있을 때만:
```typescript
const { id, status, videoUrl, videoStatus } = await request.json();
if (typeof id !== 'string') return NextResponse.json({ error: 'invalid request' }, { status: 400 });
if (status !== undefined && !['pending','paid','cancelled'].includes(status)) return NextResponse.json({ error: 'invalid status' }, { status: 400 });
if (videoStatus !== undefined && !['none','requested','in_progress','delivered'].includes(videoStatus)) return NextResponse.json({ error: 'invalid videoStatus' }, { status: 400 });
```
- status 있으면 기존 orders update + paid 메일(기존 로직 유지)
- 해당 주문이 music_video이고 `status==='paid'`이면(또는 videoUrl 제공되면): 주문 metadata.music_track_id로 `music_tracks` update `video_url=videoUrl ?? 기존, video_status='delivered'`
- `videoStatus==='in_progress'`만 오면: 트랙 `video_status='in_progress'`만 갱신(status 변경 없음)
- 주문의 metadata·product_id 조회는 update 전에 select로 확보
- [ ] **Step 3: admin/orders 페이지 UI**
`app/admin/orders/page.tsx`: `product_id==='music_video'` 주문 행에
- 연결 트랙 표시(`musicTrack.title`, `<audio src={musicTrack.audio_url}>`)
- 영상 URL `<input>` + "납품 완료" 버튼 → `PATCH { id, status:'paid', videoUrl }`
- "제작 중" 버튼 → `PATCH { id, videoStatus:'in_progress' }`
- 기존 일반 주문 행 렌더·paid/cancel 버튼 무변경. admin 다크 콘솔 관용구 유지
- [ ] **Step 4: 검증·커밋**`npm test && npm run build`. `git add app/api/admin/orders/route.ts app/admin/orders/page.tsx && git commit -m "feat(phase3b): admin 영상화 납품 — 연결 트랙 표시 + 영상 URL 입력"`
---
### Task 5: CLAUDE.md + 로드맵 메모 + 최종 검증
**Files:**
- Modify: `CLAUDE.md`
**Interfaces:**
- Consumes: Task 1~4 완료
- Produces: 문서 정합
- [ ] **Step 1: CLAUDE.md 갱신**
- 결제/발주 플로우 섹션에 "음악 영상화(music_video) — 마이페이지 음악 카드 발주 → admin URL 납품 → video_status delivered" 추가
- 파일 구조에 `api/studio/video-product` 반영
- music_tracks의 video 컬럼 언급(있으면 사주 시스템 하단 음악 관련 서술에)
- 무관 섹션 무변
- [ ] **Step 2: 최종 검증**
```bash
npm run build # /api/studio/video-product, /mypage, /admin/orders 정상
npm test # 31 PASS
grep -nE "gradient|violet|purple|blur" app/mypage/page.tsx app/components/BankTransferModal.tsx # 신규분 0
```
- [ ] **Step 3: 커밋**`git add CLAUDE.md && git commit -m "docs(phase3b): CLAUDE.md — 음악 영상화 발주 플로우 반영"`
- [ ] **Step 4: CEO 안내(보고) + 로드맵**
- 마이그레이션 `2026-07-09-phase3b-music-video.sql`을 클라우드+NAS 양쪽 적용(**phase3a 후**). 순서: phase0→1→2→3a→3b
- 수동 E2E: 마이페이지 음악 카드 "영상화 신청"→계좌이체→admin/orders에서 URL 입력·납품 완료→마이페이지 영상 재생
- 스펙 §"향후 방향 기록"의 5개 후보를 다음 방향 논의 근거로 제시
---
## 검증 요약
| 검증 | 명령 | 기대 |
|------|------|------|
| 단위 테스트 | `npm test` | 31 PASS(무변) |
| 빌드 | `npm run build` | /api/studio/video-product·/mypage·/admin/orders |
| 가드레일 | grep(신규 UI) | 0건 |
| 회귀 | 일반 상품 발주 경로 미변경 | orders POST 일반 분기 무변 |

View File

@@ -0,0 +1,90 @@
# Phase 3a 음악 서비스 공개화 — "나의 이야기를 음악으로" 설계
- 날짜: 2026-07-03
- 선행: Phase 2(사주/타로)·2.5/2.6(사주 재스킨) main 머지 완료
- 배경: 운영 비전 2축 3번째 서비스(음악). 숨김 상태의 Suno 스튜디오를 공개·무료화하고, "스토리→음악" 흐름과 회원 저장을 추가. 영상화 유료는 Phase 3b로 분리.
## 결정 사항 (CEO 확정, 2026-07-03)
| 결정 | 내용 |
|------|------|
| 영상화 | **Phase 3b로 분리** — 계좌이체 발주(관리자 수동 제작·납품). 이번 3a 범위 밖 |
| 음악 노출·과금 | **공개+무료** — 페이지 공개(숨김 해제), 생성은 로그인+일일제한(무료). 사주·타로 패턴 |
| 스토리→음악 | **Gemini가 스토리→{가사·스타일·무드} 변환** 후 Suno 생성 |
| 일일 제한 | 음악 생성 **1회/일**(Suno 비용) |
| callback | `/api/studio/callback` **폴링 전용 확정** — 최소 200 응답 route로 댕글링 해소, 회원 저장은 폴링 완료 후 클라 트리거 |
## 확인된 기존 구조
- `POST /api/studio/generate`: **무인증**, `SUNO_API_KEY` 미설정 시 503, custom/simple 모드, `callBackUrl=${origin}/api/studio/callback`(부재), Suno `/api/v1/generate` 호출 → task 반환
- `GET /api/studio/status?taskId=`: **무인증**, Suno `record-info` 폴링
- `lib/ai-usage.ts`: `AiService='saju'|'tarot'`, `getTodayUsage`/`recordUsage`, KST 일일 집계. `ai_usage_log` CHECK는 `('saju','tarot')`(phase2 마이그, auto-name `ai_usage_log_service_check`)
- `app/music/`: layout(가드 `isServiceVisible('music')`), page(72), samples(102), studio(543, 다크 테마 Suno UI)
- 저장·마이페이지 통합 패턴: 타로 `interpret→readings save`, 마이페이지 'AI 기록' 탭(사주·타로)
## 워크스트림 5개
### WS1. 음악 공개화 + 무료화
- `app/music/layout.tsx``isServiceVisible('music')`+`notFound()`+import 제거 → 공개
- `lib/service-visibility.ts` `HideableService`에서 `'music'` 제거 → `'gyeol'|'lotto'`
- `app/api/admin/services/route.ts` DEFAULT_SERVICES music 행 제거
- 마이그레이션에서 `service_settings` music 행 DELETE
- Suno 키 미설정 시 503 유지(예시 폴백 없음)
### WS2. 스토리 → 음악 (Gemini + Suno)
- 신규 `lib/music/story-prompt.ts`: `STORY_SYSTEM_PROMPT` + `buildStoryUserMessage(story)` + `parseStoryJson`/`validateStory`. Gemini가 스토리 텍스트 → `{ title, lyrics, style, mood }` strict JSON. 타로 prompt.ts 방어(코드블록 스트립·검증·reroll 1회, 45s 가드) 패턴 재사용
- 신규 `POST /api/studio/story`: 인증(401) → Gemini 변환(GEMINI_API_KEY, 미설정 503) → `{ title, lyrics, style, mood }` 반환(사용자 편집 가능). **story 단계는 일일제한 미집계**(값싼 단계)
- 기존 `POST /api/studio/generate`(Suno) 수정: 인증(401) 추가 + **일일제한(429, `MUSIC_DAILY_LIMIT`)** + Suno task 생성 성공 시에만 `recordUsage('music')`. body에 `title/lyrics/tags(style)`를 story 결과에서 채워 custom 모드로 호출
- `GET /api/studio/status` 유지(무인증 폴링 가능 — taskId만 필요, 민감정보 없음)
- **callback 정리**: 신규 `POST /api/studio/callback`이 최소 `{ ok: true }` 200 반환(Suno webhook 404 방지). 회원 저장은 콜백이 아니라 폴링 완료 후 클라가 `POST /api/studio/tracks` 트리거
### WS3. 회원 저장 + 일일제한
- 마이그레이션 `supabase/migrations/2026-07-03-phase3a-music.sql`:
```sql
CREATE TABLE IF NOT EXISTS music_tracks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title text,
story text,
lyrics text,
style text,
audio_url text,
task_id text,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE music_tracks ENABLE ROW LEVEL SECURITY;
CREATE POLICY music_select_own ON music_tracks FOR SELECT USING (auth.uid() = user_id);
-- ai_usage_log CHECK에 'music' 추가 (phase2 마이그 적용 후 실행 전제)
ALTER TABLE ai_usage_log DROP CONSTRAINT IF EXISTS ai_usage_log_service_check;
ALTER TABLE ai_usage_log ADD CONSTRAINT ai_usage_log_service_check CHECK (service IN ('saju','tarot','music'));
DELETE FROM service_settings WHERE id = 'music';
```
**의존성**: phase2-saju-tarot 마이그(ai_usage_log 생성)가 먼저 적용돼야 함 — 스펙·플랜·CEO 안내에 명시
- `lib/ai-usage.ts`: `AiService`에 `'music'` 추가, `export const MUSIC_DAILY_LIMIT = 1;`
- 신규 `POST/GET /api/studio/tracks`: 저장(admin insert, user_id 세션)·본인 조회(세션 client RLS). 타로 readings 패턴
### WS4. 라이트 재스킨 + 스토리 UI
- `app/music/{page,samples,studio}.tsx` 다크 → `--jsm` 라이트 재스킨(사주 2.5/2.6 패턴, gradient/blur/보라/이모지 0건). navy 밴드 무테두리 flat 관용구
- **스토리 UI 흐름**(studio 재구성): ①스토리 textarea → `POST /studio/story` → ②가사·스타일 미리보기(편집 가능) → ③"음악 만들기" → `POST /studio/generate` → ④`status` 폴링 → ⑤플레이어 + (로그인 시) 자동 저장(`POST /studio/tracks`). 비로그인 생성 시도 → 로그인 CTA(`/login?next=/music/studio`)
- 셔플류 클라 이슈 없음(폼 기반)
### WS5. 진입점 + AI기록 통합 + 문서
- TopNav `음악` 링크 추가(외주/소프트웨어/제작사례/사주/타로/음악 = 6링크)
- 마이페이지 'AI 기록' 탭에 음악 트랙 통합(`GET /api/studio/tracks`) — 사주·타로·음악 3종 병합 리스트, 음악은 제목·스토리 요약·오디오 링크
- CLAUDE.md: 음악 공개 서비스·스토리→음악·music_tracks·studio API 반영, 숨김 서비스 표에서 music 제거
- 최종 검증: `grep -rnE "gradient|violet|purple|blur" app/music/**/*.tsx` 0건, build+test 30, sajumusic 라우트 존재
## 범위 밖 (Phase 3b)
- 영상화 유료 발주(계좌이체 order로 video 신청·관리자 제작·납품·다운로드)
- 음악 자동 영상 생성 API 연동
## 리스크·주의
- **Suno 실동작**은 `SUNO_API_KEY` 운영 설정 의존 — 로컬 미설정 시 503(사주 Gemini와 동일 정책). 수동 E2E는 운영 키 있는 환경에서
- `ai_usage_log` CHECK ALTER는 phase2 마이그 DB 적용 후 실행돼야 함(미적용 시 ALTER 실패) — CEO 안내
- studio 페이지가 큼(543줄) — 재스킨 + 스토리 UI 재구성은 태스크 분할(라이트 재스킨과 스토리 흐름 분리 가능)
- Suno 응답 스키마(task/record-info)는 기존 status route가 이미 다룸 — 저장 시 audio_url 추출 지점은 구현 시 record-info 응답 구조 확인
- 생성은 비동기(task) — recordUsage는 task 생성 성공(Suno 202/200) 시점 집계(완료 아님). 완료 실패해도 1회 소진되나 개인 서비스 규모에서 허용(사주·타로와 동일 기조)

View File

@@ -0,0 +1,104 @@
# Phase 3b 음악 영상화 유료 발주 설계
- 날짜: 2026-07-09
- 선행: Phase 3a(음악 공개화·스토리→음악) main 머지 완료(677012a), /music 카피 정리(6a67a9d)
- 배경: 음악 서비스 2번째 축 = "영상화"(유료). 회원이 저장한 음악 트랙을 계좌이체 발주하면 관리자가 수동 제작·납품. 자동 영상 생성은 범위 밖.
## 결정 사항 (CEO 확정, 2026-07-09)
| 결정 | 내용 |
|------|------|
| 영상화 방식 | **계좌이체 발주 + 관리자 수동 제작·납품** (Phase 3 스코핑서 확정) |
| 가격 | **고정가 ₩30,000**`music_video` products 1행, admin/products에서 조정 가능 |
| 주문 모델 | **기존 orders 재사용** + `metadata.music_track_id` 트랙 참조. music_tracks에 video 상태 추가 |
| 납품 | **`music_tracks.video_url`** — 관리자가 admin/orders에서 완성 영상 URL 직접 입력(별도 업로더 없음) |
| 진입점 | **마이페이지 AI기록 음악 카드**의 "영상화 신청" 버튼 |
## 확인된 기존 구조
- `products`: `{ id, name, price, is_active, ... }` (`lib/supabase/product-files.ts` ProductRow, `getProductById(admin, id)`)
- `POST /api/orders`: 인증→rate-limit→productId/depositorName 검증→`getProductById`(active)→**중복 pending 가드**(user+product)→insert `{user_id, product_id, amount:product.price, status:'pending', metadata:{method:'bank_transfer', depositor_name}}`→접수 메일
- `admin/orders PATCH`: body `{id, status∈pending|paid|cancelled}` → update; paid 전환 시 `sendOrderPaidEmail`
- `BankTransferModal({ product, isOpen, onClose })`: POST `{ productId: product.id, depositorName }`
- `music_tracks`(Phase 3a): `{ id, user_id, title, story, lyrics, style, audio_url, task_id, created_at }`, RLS `music_select_own`
- 마이페이지 AI기록 탭: 사주·타로·음악 병합, `MusicAiCard`(제목·스토리·`<audio>`)
## 워크스트림
### WS1. DB 마이그레이션 (products 시드 + music_tracks 확장)
- 신규 `supabase/migrations/2026-07-09-phase3b-music-video.sql`:
```sql
-- 의존성: 2026-07-03-phase3a-music.sql(music_tracks) 적용 후
INSERT INTO products (id, name, price, is_active)
VALUES ('music_video', '음악 영상화', 30000, true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE music_tracks
ADD COLUMN IF NOT EXISTS video_status text NOT NULL DEFAULT 'none'
CHECK (video_status IN ('none','requested','in_progress','delivered')),
ADD COLUMN IF NOT EXISTS video_url text,
ADD COLUMN IF NOT EXISTS video_order_id uuid;
```
(products 실제 컬럼 확인 후 필수 NOT NULL 컬럼 있으면 값 채워 시드. 클라우드+NAS 양쪽)
### WS2. 발주 API 확장 (orders + music_tracks 연결)
- `POST /api/orders` 확장: body에 optional `musicTrackId`(string) 허용
- `productId==='music_video'`이고 `musicTrackId` 있으면: admin으로 `music_tracks`에서 `id=musicTrackId AND user_id=user.id` 조회 → 없으면 404(본인 트랙 아님)
- metadata에 `music_track_id` 포함해 insert
- **중복 가드 확장**: music_video는 `(user_id, product_id='music_video', metadata->>music_track_id = trackId, status='pending')`로 조회(트랙별). 일반 상품은 기존 (user, product) 가드 유지
- 주문 insert 성공 후: `music_tracks` update `video_status='requested', video_order_id=order.id` where `id=musicTrackId AND user_id=user.id`
- 신규 `GET /api/studio/video-product`: 로그인, `music_video` 제품 `{ id, name, price, is_active }` 반환(모달 가격 표시용). 미존재/비활성 시 안내
### WS3. 마이페이지 신청 진입점 + 상태 표시
- `BankTransferModal` 확장: optional prop `musicTrackId?: string` → POST body에 `musicTrackId` 병합(없으면 기존 동작 그대로)
- 마이페이지 `MusicAiCard`(AI기록 탭):
- `video_status` 뱃지 — `none`: "영상화 신청" 버튼(→ `GET /api/studio/video-product`로 제품 로드 후 `BankTransferModal` 오픈, `musicTrackId` 전달) / `requested`: "영상화 접수됨(입금 확인 중)" / `in_progress`: "영상 제작 중" / `delivered`: "영상 완료"
- `delivered` + `video_url` 있으면 `<video controls src={video_url}>` + 다운로드 링크
- `MusicTrackRow` 타입에 `video_status`, `video_url` 추가(GET /api/studio/tracks가 이 컬럼 반환하도록 select 확장)
- 신청 완료 후 목록 갱신(status='requested' 반영)
### WS4. admin 납품
- `app/admin/orders/page.tsx`: `product_id==='music_video'` 주문에
- 연결 트랙 표시(metadata.music_track_id로 music_tracks 조회 — GET 응답에 트랙 title/audio_url 조인 포함)
- **영상 URL 입력 + "납품 완료" 버튼** → `PATCH /api/admin/orders` `{ id, status:'paid', videoUrl }`
- (선택) "제작 중" 버튼 → `{ id, videoStatus:'in_progress' }`
- `admin/orders GET` 확장: music_video 주문에 연결 트랙 정보(title, audio_url) 포함(admin이 metadata.music_track_id로 music_tracks 조인)
- `admin/orders PATCH` 확장:
- optional `videoUrl`, `videoStatus` 허용
- `status='paid'` + music_video 주문이면: 주문 metadata.music_track_id로 `music_tracks` update `video_url=videoUrl, video_status='delivered'`
- `videoStatus='in_progress'`만 오면 status 변경 없이 트랙 video_status만 갱신
- 기존 일반 상품 paid 흐름·메일은 무변경
### WS5. 메일 + 문서 + 검증
- 발주 접수/완료 메일 재사용(`sendOrderReceivedEmails`/`sendOrderPaidEmail`). 영상화 문구는 제품명("음악 영상화")으로 자연 반영 — 별도 템플릿 추가 없음(YAGNI)
- CLAUDE.md: 결제/발주 플로우에 영상화(music_video) 추가, music_tracks video 컬럼·admin 납품 반영
- 검증: `npm run build`·`npm test`·가드레일(신규 UI 라이트) grep, 수동 E2E(신청→admin 납품→마이페이지 완료 재생)
## 향후 방향 기록 (로드맵 — "추후 방향 재설정" 근거)
이 스펙 완료 시점 기준 전체 상태와 다음 후보를 기록한다(메모리 `saas-refactor-2026-07`에도 반영).
**완료(main·양쪽 리모트 푸시)**: Phase 0(정리) · 1(외주 코어) · 2(사주/타로) · 2.5/2.6(사주 재스킨) · 3a(음악 공개화) · /music 카피 · **3b(영상화 발주 — 이 스펙)**
**미결 운영(CEO 수동, 코드 불가)**:
- DB 마이그레이션 **5개** 순차 적용(클라우드+NAS): phase0 → phase1 → phase2 → phase3a → **phase3b**
- 운영 env: `GEMINI_API_KEY`(사주·타로·스토리), `SUNO_API_KEY`(음악), `RESEND_API_KEY`(메일)
- 수동 E2E 전 서비스 스모크
**향후 후보(다음 방향 잡기용)**:
1. **영상 자동 생성 연동** — 수동 제작 → sora/veo/kling 등 API(비용·게이트웨이 설계 필요, NAS video-lab 참고)
2. **NAS 풀 self-host 컷오버** — Vercel/클라우드 Supabase → NAS(진행 중 project [[nas-selfhost-transition]])
3. **운영 대시보드·정산** — 서비스별 매출/사용량 통합(admin/analytics 확장), AI 사용량·Suno 비용 집계
4. **회원 등급/구독 재도입** — Phase 0에서 제거한 subscription 인프라 재설계(SaaS 피벗 시)
5. **사주·타로·음악 크로스 프로모션**, 마케팅 채널(admin 광고 관리) 활용 캠페인
## 범위 밖 (Phase 3b)
- 영상 자동 생성, 영상 편집 UI, 관리자 파일 업로더(URL 문자열로 충분)
- 결제 PG(계좌이체 단일 소스 유지)
## 리스크·주의
- orders POST의 중복 가드를 music_video만 트랙 단위로 분기 — 일반 상품 가드 회귀 없도록 주의
- `metadata->>music_track_id` jsonb 쿼리 문법(Supabase) 정확히
- BankTransferModal은 다른 상품(products)도 쓰므로 musicTrackId는 optional, 미전달 시 기존 동작 불변
- video_url은 관리자 신뢰 입력(공개 재생) — 값 검증은 최소(문자열 길이), XSS는 `<video src>` 특성상 낮으나 URL 형식 권장
- admin/orders에 music_video 조인 추가 시 기존 일반 주문 렌더 회귀 없도록

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { kstDayStartISO, SAJU_DAILY_LIMIT, TAROT_DAILY_LIMIT } from '../ai-usage'; import { kstDayStartISO, SAJU_DAILY_LIMIT, TAROT_DAILY_LIMIT, MUSIC_DAILY_LIMIT } from '../ai-usage';
describe('kstDayStartISO', () => { describe('kstDayStartISO', () => {
it('KST 자정을 UTC로 환산한다 (KST 15:00 UTC = 당일 00:00 KST)', () => { it('KST 자정을 UTC로 환산한다 (KST 15:00 UTC = 당일 00:00 KST)', () => {
@@ -14,4 +14,7 @@ describe('kstDayStartISO', () => {
expect(SAJU_DAILY_LIMIT).toBe(1); expect(SAJU_DAILY_LIMIT).toBe(1);
expect(TAROT_DAILY_LIMIT).toBe(3); expect(TAROT_DAILY_LIMIT).toBe(3);
}); });
it('음악 일일 제한 상수', () => {
expect(MUSIC_DAILY_LIMIT).toBe(1);
});
}); });

View File

@@ -2,7 +2,8 @@ import type { SupabaseClient } from '@supabase/supabase-js';
export const SAJU_DAILY_LIMIT = 1; export const SAJU_DAILY_LIMIT = 1;
export const TAROT_DAILY_LIMIT = 3; export const TAROT_DAILY_LIMIT = 3;
export type AiService = 'saju' | 'tarot'; export const MUSIC_DAILY_LIMIT = 1;
export type AiService = 'saju' | 'tarot' | 'music';
/** KST(UTC+9) 자정을 UTC ISO로. 오늘 사용량 집계 하한. */ /** KST(UTC+9) 자정을 UTC ISO로. 오늘 사용량 집계 하한. */
export function kstDayStartISO(now: Date): string { export function kstDayStartISO(now: Date): string {

45
lib/music/story-prompt.ts Normal file
View File

@@ -0,0 +1,45 @@
// 스토리→가사 생성 프롬프트·스키마.
// lib/tarot/prompt.ts의 방어 패턴(코드블록 스트립 + {...} 추출 + 스키마 검증)을 음악 스토리용으로 포팅.
export type MusicStory = {
title: string;
lyrics: string;
style: string;
mood: string;
};
export const STORY_SYSTEM_PROMPT = `당신은 사용자의 개인적 이야기를 노래로 바꾸는 작사가 겸 음악 프로듀서입니다.
사용자가 들려준 이야기를 바탕으로:
1. title: 노래 제목(짧고 인상적으로)
2. lyrics: 이야기의 감정과 장면을 담은 한국어 가사(절/후렴 구조, 6~16줄)
3. style: 어울리는 음악 장르·악기·템포를 영어 키워드로(Suno style, 예 "acoustic ballad, warm piano, mid tempo")
4. mood: 전체 정서를 한 단어로(예 "그리움", "희망")
반드시 코드블록 없이 순수 JSON만 출력합니다: {"title","lyrics","style","mood"}
사용자 이야기에 없는 사실을 지어내지 말고, 감정에 충실하게 각색합니다.`;
export function buildStoryUserMessage(story: string): string {
return `사용자의 이야기:\n${story}\n\n위 이야기를 노래로 만들기 위한 JSON을 생성하세요.`;
}
/** 코드블록 스트립 + {...} 추출 후 파싱. 실패 시 null */
export function parseStoryJson(raw: string): MusicStory | null {
let text = raw.trim().replace(/^```(json)?/i, '').replace(/```$/, '').trim();
const first = text.indexOf('{');
const last = text.lastIndexOf('}');
if (first >= 0 && last > first) text = text.slice(first, last + 1);
try {
return JSON.parse(text) as MusicStory;
} catch {
return null;
}
}
/** 스키마 검증. 통과 못하면 사유 문자열, 통과면 null */
export function validateStory(obj: unknown): string | null {
if (!obj || typeof obj !== 'object') return 'not an object';
const o = obj as Record<string, unknown>;
for (const k of ['title', 'lyrics', 'style', 'mood']) {
if (typeof o[k] !== 'string' || !(o[k] as string).trim()) return `${k} 누락`;
}
return null;
}

View File

@@ -3,7 +3,7 @@ import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth'; import { verifyAdminTokenNode } from '@/lib/admin-auth';
/** 숨김 가능 서비스 id (service_settings.id와 일치) */ /** 숨김 가능 서비스 id (service_settings.id와 일치) */
export type HideableService = 'music' | 'gyeol' | 'lotto'; export type HideableService = 'gyeol' | 'lotto';
/** /**
* 서비스 노출 여부. admin_token 세션이면 항상 true. * 서비스 노출 여부. admin_token 세션이면 항상 true.

View File

@@ -0,0 +1,23 @@
-- Phase 3a (2026-07-03): 음악 회원 저장 + 사용량 로그 확장 + 음악 숨김 해제
-- 의존성: 2026-07-02-phase2-saju-tarot.sql(ai_usage_log 생성) 적용 후 실행
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
CREATE TABLE IF NOT EXISTS music_tracks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title text,
story text,
lyrics text,
style text,
audio_url text,
task_id text,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE music_tracks ENABLE ROW LEVEL SECURITY;
CREATE POLICY music_select_own ON music_tracks FOR SELECT USING (auth.uid() = user_id);
-- ai_usage_log CHECK에 'music' 추가 (phase2의 인라인 CHECK auto-name 제거 후 재정의)
ALTER TABLE ai_usage_log DROP CONSTRAINT IF EXISTS ai_usage_log_service_check;
ALTER TABLE ai_usage_log ADD CONSTRAINT ai_usage_log_service_check CHECK (service IN ('saju','tarot','music'));
DELETE FROM service_settings WHERE id = 'music';

View File

@@ -0,0 +1,13 @@
-- Phase 3b (2026-07-09): 음악 영상화 발주
-- 의존성: 2026-07-03-phase3a-music.sql(music_tracks) 적용 후
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
INSERT INTO products (id, name, price, category, is_active)
VALUES ('music_video', '음악 영상화', 30000, 'software', true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE music_tracks
ADD COLUMN IF NOT EXISTS video_status text NOT NULL DEFAULT 'none'
CHECK (video_status IN ('none','requested','in_progress','delivered')),
ADD COLUMN IF NOT EXISTS video_url text,
ADD COLUMN IF NOT EXISTS video_order_id uuid;