19 Commits

Author SHA1 Message Date
95d74c1913 fix(agent-office): 코드 리뷰 이슈 수정
- 하드코딩된 claude 에이전트 참조 제거 (백엔드 에이전트 기반 동적 렌더링)
- TaskHistory에 rejected 상태 배지 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 09:09:03 +09:00
d8bc6af062 feat(agent-office): AgentOffice main page with canvas + overlay panels + CSS
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 09:00:16 +09:00
226e368347 feat(agent-office): ChatPanel with commands/approval + TaskHistory panel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:59:19 +09:00
310679de61 feat(agent-office): useAgentManager WebSocket hook + useOfficeCanvas rendering hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:58:41 +09:00
916d16c235 feat(agent-office): AgentSprite movement + OfficeRenderer game loop
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:58:07 +09:00
96a5d97ff7 feat(agent-office): Canvas engine — SpriteSheet, TileMap, office-map data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:57:26 +09:00
2ef43b070a feat(agent-office): API helpers, route, Lab entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:56:16 +09:00
7fc2d3aaf7 feat(music-lab): Suno API 전체 기능 확장 — Phase 1~3 UI 2026-04-09 07:34:21 +09:00
b215a93c89 fix(music-lab): RemixTab default_param_flag 로직 수정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:19:48 +09:00
1f00866694 feat(music-lab): Phase 3 UI — RemixTab + 뮤직비디오 생성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:14:18 +09:00
0849c70644 feat(music-lab): Phase 2 UI — StemModal, SyncedLyricsPlayer, Style Boost, WAV 변환
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:05:07 +09:00
7a591bb0f1 feat(music-lab): Phase 1 UI — 보컬 성별, 제외 스타일, weight 슬라이더, 더보기 메뉴, CoverArtModal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 08:53:47 +09:00
312677e624 refactor(music-lab): 컴포넌트 분할 — AudioPlayer, LyricsTab, CreditsBadge 추출
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 08:48:35 +09:00
6786f8c883 feat(realestate): 청약 가점 현황 카드 + 매칭 가점 비교
- 내 프로필 탭: 가점 현황 카드 (무주택/부양가족/통장 프로그레스 바)
- 매칭 결과 탭: 상단에 내 가점 뱃지, 각 카드에 가점 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 00:27:57 +09:00
45b74e672a feat(realestate): 공고 카드 매칭 점수 + 매칭 결과 탭 강화
- 공고 카드에 매칭 점수 뱃지 표시 (70+녹색, 40+주황, 기본회색)
- 상세 패널 헤더에 매칭 점수 + 자격 유형 태그 표시
- 매칭 결과 카드에 D-day + 접수일정 + 매칭 사유 표시 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 23:51:09 +09:00
bf5c7ba54e feat(realestate): 즐겨찾기 + D-day 오차 수정 + 가격 표시 + 필드명 수정
- D-day 계산 로컬 타임존 통일 (UTC 파싱 → 로컬 Date 파싱, 1일 오차 해결)
- 즐겨찾기 토글 (카드 ☆/★ + 상세 패널 버튼 + 즐겨찾기 필터)
- 대시보드에 즐겨찾기 섹션 + 가격 표시
- 모델 필드명 수정: supply_price→top_amount, exclusive_area→supply_area
- 카드에 가격 범위 표시 (억/만원 자동 포맷)
- 매칭 결과 필드명 수정: score→match_score, status→ann_status, matched_at→created_at

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 23:39:02 +09:00
8af2824c12 fix: 필수 표시를 텍스트 * 로 변경 — span 요소가 레이아웃 깨트리는 문제 수정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:44:58 +09:00
ff0ee3757c fix: 프로필 preferred_regions/types 배열 변환 + 필수 입력 표시
- 쉼표 구분 문자열 → List[str] 변환 (백엔드 422 에러 수정)
- API 응답 배열 → 표시용 문자열 변환
- 매칭 필수 필드에 * 표시 (무주택, 세대주, 납입기간, 가족수, 선호지역)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:43:15 +09:00
0eb55fe731 realestate: 프론트 청약 페이지를 realestate-lab API로 전면 리디자인
- Subscription.jsx: /api/subscription/* → /api/realestate/* 전환
- 4탭 구성: 대시보드, 공고 목록, 매칭 결과, 내 프로필
- 대시보드: 수집 상태/실행, 진행중 공고, 신규 매칭 통계
- 공고 목록: 자동 수집 공고 카드 그리드 + 필터 + 상세 패널
- 매칭 결과: 프로필 기반 추천 점수순 목록
- 내 프로필: 자격 조건 + 선호 조건 폼
- routes.jsx: /realestate/property 라우트 제거 (RealEstate.jsx 미사용)
- 구 API 경로(/api/subscription/*, /api/realestate/complexes) 완전 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:35:30 +09:00
24 changed files with 3832 additions and 1522 deletions

View File

@@ -334,6 +334,62 @@ export function deleteLyrics(id) {
return apiDelete(`/api/music/lyrics/library/${id}`); return apiDelete(`/api/music/lyrics/library/${id}`);
} }
// ── Phase 1: 커버 이미지 ────────────────────────────────────────────────────
// POST /api/music/cover-image body: { suno_task_id, track_id }
export function generateCoverImage(payload) {
return apiPost('/api/music/cover-image', payload);
}
// ── Phase 2 API ─────────────────────────────────────────────────────────────
// POST /api/music/wav body: { suno_task_id, suno_id, track_id }
export function convertToWav(payload) {
return apiPost('/api/music/wav', payload);
}
// POST /api/music/stem-split body: { suno_task_id, suno_id, track_id }
export function splitStems(payload) {
return apiPost('/api/music/stem-split', payload);
}
// GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
export function getTimestampedLyrics(taskId, sunoId) {
return apiGet(`/api/music/timestamped-lyrics?task_id=${encodeURIComponent(taskId)}&suno_id=${encodeURIComponent(sunoId)}`);
}
// POST /api/music/style-boost body: { content }
export function generateStyleBoost(content) {
return apiPost('/api/music/style-boost', { content });
}
// ── Phase 3 API ─────────────────────────────────────────────────────────────
// POST /api/music/upload-cover
export function uploadAndCover(payload) {
return apiPost('/api/music/upload-cover', payload);
}
// POST /api/music/upload-extend
export function uploadAndExtend(payload) {
return apiPost('/api/music/upload-extend', payload);
}
// POST /api/music/add-vocals
export function addVocals(payload) {
return apiPost('/api/music/add-vocals', payload);
}
// POST /api/music/add-instrumental
export function addInstrumental(payload) {
return apiPost('/api/music/add-instrumental', payload);
}
// POST /api/music/video
export function generateVideo(payload) {
return apiPost('/api/music/video', payload);
}
// ── 로또 고도화 API ──────────────────────────────────────────────────────────── // ── 로또 고도화 API ────────────────────────────────────────────────────────────
// GET /api/lotto/stats/performance // GET /api/lotto/stats/performance
@@ -532,3 +588,14 @@ export function deleteBrandLink(id) {
return apiDelete(`/api/blog-marketing/links/${id}`); return apiDelete(`/api/blog-marketing/links/${id}`);
} }
// ── Agent Office ──────────────────────────────────
export const getAgents = () => apiGet('/api/agent-office/agents');
export const getAgentDetail = (id) => apiGet(`/api/agent-office/agents/${id}`);
export const updateAgentConfig = (id, body) => apiPut(`/api/agent-office/agents/${id}`, body);
export const getAgentTasks = (id, limit=20) => apiGet(`/api/agent-office/agents/${id}/tasks?limit=${limit}`);
export const getAgentLogs = (id, limit=50) => apiGet(`/api/agent-office/agents/${id}/logs?limit=${limit}`);
export const getPendingTasks = () => apiGet('/api/agent-office/tasks/pending');
export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/agent-office/command', { agent, action, params });
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
export const getAgentStates = () => apiGet('/api/agent-office/states');

View File

@@ -0,0 +1,331 @@
.ao-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #0d0d1a;
color: #e0e0e0;
font-family: 'Courier New', monospace;
}
.ao-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: #1a1a2e;
border-bottom: 1px solid #2a2a4a;
}
.ao-title {
font-size: 1.4rem;
color: #8b5cf6;
margin: 0;
letter-spacing: 2px;
}
.ao-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: #888;
}
.ao-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.ao-dot--on { background: #34d399; }
.ao-dot--off { background: #f87171; }
.ao-workspace {
flex: 1;
position: relative;
overflow: hidden;
}
.ao-canvas-container {
width: 100%;
height: 100%;
}
.ao-agent-bar {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.6);
border-radius: 20px;
backdrop-filter: blur(8px);
}
.ao-agent-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border: 1px solid #333;
border-radius: 12px;
background: transparent;
color: #ccc;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
}
.ao-agent-chip:hover { border-color: #8b5cf6; }
.ao-agent-chip--selected { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.15); }
.ao-agent-chip--alert { animation: ao-pulse 1s infinite; }
@keyframes ao-pulse {
0%, 100% { border-color: #fbbf24; }
50% { border-color: #f59e0b; box-shadow: 0 0 8px rgba(251, 191, 36, 0.4); }
}
.ao-chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.ao-chip-dot--idle { background: #666; }
.ao-chip-dot--working { background: #818cf8; }
.ao-chip-dot--waiting { background: #fbbf24; }
.ao-chip-dot--reporting { background: #34d399; }
.ao-chip-dot--break { background: #a78bfa; }
.ao-chip-badge {
background: #f87171;
color: #fff;
font-size: 0.65rem;
padding: 0 4px;
border-radius: 4px;
font-weight: bold;
}
.ao-pending-count {
color: #fbbf24;
font-size: 0.75rem;
align-self: center;
}
.ao-chat-panel {
position: absolute;
right: 16px;
top: 60px;
width: 340px;
max-height: calc(100% - 80px);
background: rgba(26, 26, 46, 0.95);
border: 1px solid #333;
border-radius: 12px;
overflow-y: auto;
backdrop-filter: blur(12px);
}
.ao-chat-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #2a2a4a;
}
.ao-chat-title {
flex: 1;
font-weight: bold;
color: #e0e0e0;
}
.ao-chat-state {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 8px;
text-transform: uppercase;
}
.ao-chat-state--idle { background: #333; }
.ao-chat-state--working { background: #3730a3; }
.ao-chat-state--waiting { background: #92400e; }
.ao-chat-state--break { background: #4c1d95; }
.ao-chat-close {
background: none;
border: none;
color: #888;
font-size: 1.2rem;
cursor: pointer;
}
.ao-chat-close:hover { color: #fff; }
.ao-chat-detail {
padding: 8px 16px;
color: #aaa;
font-size: 0.85rem;
}
.ao-chat-approval {
padding: 12px 16px;
background: rgba(251, 191, 36, 0.1);
border-top: 1px solid #2a2a4a;
border-bottom: 1px solid #2a2a4a;
}
.ao-chat-approval p {
margin: 0 0 8px;
color: #fbbf24;
font-size: 0.85rem;
}
.ao-chat-approval-btns {
display: flex;
gap: 8px;
}
.ao-btn {
padding: 6px 16px;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
.ao-btn--approve { background: #065f46; color: #34d399; }
.ao-btn--approve:hover { background: #047857; }
.ao-btn--reject { background: #7f1d1d; color: #f87171; }
.ao-btn--reject:hover { background: #991b1b; }
.ao-btn--send { background: #4c1d95; color: #c4b5fd; }
.ao-btn--send:hover { background: #5b21b6; }
.ao-chat-commands {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px 16px;
}
.ao-cmd-btn {
padding: 6px 12px;
border: 1px solid #333;
border-radius: 8px;
background: transparent;
color: #ccc;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
}
.ao-cmd-btn:hover { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.1); }
.ao-chat-input-area {
display: flex;
gap: 8px;
padding: 8px 16px 12px;
}
.ao-chat-input {
flex: 1;
padding: 8px 12px;
background: #111;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
font-family: inherit;
}
.ao-chat-input:focus { border-color: #8b5cf6; outline: none; }
.ao-chat-result {
padding: 8px 16px;
border-top: 1px solid #2a2a4a;
}
.ao-chat-result h4 {
margin: 0 0 8px;
font-size: 0.8rem;
color: #888;
}
.ao-chat-result pre {
font-size: 0.75rem;
color: #aaa;
overflow-x: auto;
white-space: pre-wrap;
margin: 0;
}
.ao-history-panel {
position: absolute;
left: 16px;
top: 60px;
width: 340px;
max-height: calc(100% - 80px);
background: rgba(26, 26, 46, 0.95);
border: 1px solid #333;
border-radius: 12px;
overflow-y: auto;
backdrop-filter: blur(12px);
}
.ao-history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #2a2a4a;
font-weight: bold;
}
.ao-history-list { padding: 8px; }
.ao-history-empty { text-align: center; color: #666; padding: 20px; }
.ao-history-item {
padding: 10px 12px;
border-bottom: 1px solid #1a1a2e;
}
.ao-history-item:last-child { border-bottom: none; }
.ao-history-item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.ao-history-type { font-size: 0.85rem; color: #ccc; }
.ao-history-badge {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 4px;
color: #fff;
}
.ao-history-time {
font-size: 0.75rem;
color: #666;
margin-top: 4px;
}
.ao-history-detail {
margin-top: 6px;
font-size: 0.75rem;
}
.ao-history-detail summary {
cursor: pointer;
color: #8b5cf6;
}
.ao-history-detail pre {
color: #aaa;
white-space: pre-wrap;
margin: 4px 0 0;
}
.ao-toolbar {
display: flex;
gap: 8px;
padding: 8px 20px;
background: #1a1a2e;
border-top: 1px solid #2a2a4a;
}
.ao-tool-btn {
padding: 6px 14px;
border: 1px solid #333;
border-radius: 6px;
background: transparent;
color: #aaa;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
}
.ao-tool-btn:hover { border-color: #8b5cf6; color: #e0e0e0; }

View File

@@ -0,0 +1,85 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { useAgentManager } from './hooks/useAgentManager';
import { useOfficeCanvas } from './hooks/useOfficeCanvas';
import ChatPanel from './components/ChatPanel';
import TaskHistory from './components/TaskHistory';
import './AgentOffice.css';
export function Component() {
const canvasContainerRef = useRef(null);
const [selectedAgent, setSelectedAgent] = useState(null);
const [showHistory, setShowHistory] = useState(null);
const { agents, pendingTasks, connected, sendCommand, sendApproval } = useAgentManager();
const handleAgentClick = useCallback((agentId) => {
setSelectedAgent(prev => prev === agentId ? null : agentId);
}, []);
const { updateAgentState, moveAgent } = useOfficeCanvas(canvasContainerRef, handleAgentClick);
useEffect(() => {
for (const [id, info] of Object.entries(agents)) {
updateAgentState(id, info.state, info.detail);
}
}, [agents, updateAgentState]);
return (
<div className="ao-page">
<div className="ao-header">
<h1 className="ao-title">Agent Office</h1>
<div className="ao-status">
<span className={`ao-dot ${connected ? 'ao-dot--on' : 'ao-dot--off'}`} />
{connected ? 'Connected' : 'Disconnected'}
</div>
</div>
<div className="ao-workspace">
<div className="ao-canvas-container" ref={canvasContainerRef} />
<div className="ao-agent-bar">
{Object.entries(agents).map(([id, info]) => (
<button
key={id}
className={`ao-agent-chip ${info.state === 'waiting' ? 'ao-agent-chip--alert' : ''} ${selectedAgent === id ? 'ao-agent-chip--selected' : ''}`}
onClick={() => handleAgentClick(id)}
>
<span className={`ao-chip-dot ao-chip-dot--${info.state}`} />
{id}
{info.state === 'waiting' && <span className="ao-chip-badge">!</span>}
</button>
))}
{pendingTasks.length > 0 && (
<span className="ao-pending-count">{pendingTasks.length} pending</span>
)}
</div>
{selectedAgent && (
<ChatPanel
agentId={selectedAgent}
agentState={agents[selectedAgent]}
onCommand={sendCommand}
onApproval={sendApproval}
onClose={() => setSelectedAgent(null)}
/>
)}
{showHistory && (
<TaskHistory
agentId={showHistory}
onClose={() => setShowHistory(null)}
/>
)}
</div>
<div className="ao-toolbar">
{Object.keys(agents).map(id => (
<button key={id} className="ao-tool-btn"
onClick={() => setShowHistory(prev => prev === id ? null : id)}>
📋 {id} 이력
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
{
"tileSize": 32,
"cols": 20,
"rows": 14,
"layers": {
"floor": [
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
]
},
"furniture": [
{"type": "desk", "x": 2, "y": 1, "label": "Stock"},
{"type": "desk", "x": 7, "y": 1, "label": "Music"},
{"type": "desk", "x": 12, "y": 1, "label": "Claude"},
{"type": "desk", "x": 17, "y": 1, "label": "(빈)"},
{"type": "table", "x": 8, "y": 6, "w": 4, "h": 2, "label": "회의 테이블"},
{"type": "sofa", "x": 1, "y": 10, "label": "휴게실"},
{"type": "coffee", "x": 3, "y": 10, "label": "☕"},
{"type": "desk", "x": 14, "y": 10, "w": 5, "h": 2, "label": "CEO"}
],
"waypoints": {
"stock_desk": {"x": 2, "y": 2},
"music_desk": {"x": 7, "y": 2},
"claude_desk": {"x": 12, "y": 2},
"meeting_table": {"x": 9, "y": 7},
"break_room": {"x": 2, "y": 11},
"ceo_desk": {"x": 16, "y": 11}
},
"colors": {
"1": "#3a3a50",
"2": "#4a3a2a"
}
}

View File

@@ -0,0 +1,84 @@
import { drawAgent, getAnimSpeed } from './SpriteSheet';
export class AgentSprite {
constructor(agentId, waypoints) {
this.agentId = agentId;
this.waypoints = waypoints;
this.state = 'idle';
this.detail = '';
const deskKey = `${agentId}_desk`;
const desk = waypoints[deskKey] || { x: 5, y: 3 };
this.x = desk.x;
this.y = desk.y;
this.targetX = desk.x;
this.targetY = desk.y;
this.deskPos = { x: desk.x, y: desk.y };
this.frameIndex = 0;
this._lastFrameTime = 0;
this._moveSpeed = 0.05;
}
setState(newState, detail = '') {
this.state = newState;
this.detail = detail;
this.frameIndex = 0;
}
moveTo(target) {
const wp = this.waypoints[target];
if (wp) {
this.targetX = wp.x;
this.targetY = wp.y;
}
}
moveToDesk() {
this.targetX = this.deskPos.x;
this.targetY = this.deskPos.y;
}
update(now) {
const speed = getAnimSpeed(this.state);
if (now - this._lastFrameTime > speed) {
this.frameIndex++;
this._lastFrameTime = now;
}
const dx = this.targetX - this.x;
const dy = this.targetY - this.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0.1) {
const step = Math.min(this._moveSpeed, dist);
this.x += (dx / dist) * step;
this.y += (dy / dist) * step;
} else {
this.x = this.targetX;
this.y = this.targetY;
}
}
draw(ctx, renderInfo) {
const { scale, offsetX, offsetY, tileSize } = renderInfo;
const canvasX = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
const canvasY = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;
const isMoving = Math.abs(this.targetX - this.x) > 0.1 || Math.abs(this.targetY - this.y) > 0.1;
const drawState = isMoving ? 'walk' : this.state;
drawAgent(ctx, this.agentId, canvasX, canvasY, drawState, this.frameIndex, scale * 1.5);
}
hitTest(canvasX, canvasY, renderInfo) {
const { scale, offsetX, offsetY, tileSize } = renderInfo;
const cx = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
const cy = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;
const hitW = 20 * scale;
const hitH = 30 * scale;
return canvasX >= cx - hitW && canvasX <= cx + hitW &&
canvasY >= cy - hitH && canvasY <= cy + hitH;
}
}

View File

@@ -0,0 +1,129 @@
import { drawTileMap } from './TileMap';
import { AgentSprite } from './AgentSprite';
import { getCharLabel } from './SpriteSheet';
const STATUS_ICONS = {
idle: null,
working: null,
waiting: '❗',
reporting: '📋',
break: '☕',
};
export class OfficeRenderer {
constructor(canvas, mapData) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.mapData = mapData;
this.renderInfo = null;
this.agents = {};
this._animId = null;
this._onClick = null;
const agentIds = ['stock', 'music'];
for (const id of agentIds) {
this.agents[id] = new AgentSprite(id, mapData.waypoints);
}
}
start() {
this._loop = this._loop.bind(this);
this._animId = requestAnimationFrame(this._loop);
}
stop() {
if (this._animId) {
cancelAnimationFrame(this._animId);
this._animId = null;
}
}
resize(width, height) {
this.canvas.width = width;
this.canvas.height = height;
}
setOnClick(handler) {
this._onClick = handler;
}
handleClick(canvasX, canvasY) {
if (!this.renderInfo) return null;
for (const [id, sprite] of Object.entries(this.agents)) {
if (sprite.hitTest(canvasX, canvasY, this.renderInfo)) {
if (this._onClick) this._onClick(id);
return id;
}
}
return null;
}
updateAgentState(agentId, state, detail) {
const sprite = this.agents[agentId];
if (sprite) {
sprite.setState(state, detail);
if (state === 'idle' || state === 'working' || state === 'waiting') {
sprite.moveToDesk();
}
}
}
moveAgent(agentId, target) {
const sprite = this.agents[agentId];
if (sprite) {
sprite.moveTo(target);
}
}
_loop(timestamp) {
const { ctx, canvas, mapData } = this;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
this.renderInfo = drawTileMap(ctx, mapData, canvas.width, canvas.height);
const now = Date.now();
for (const sprite of Object.values(this.agents)) {
sprite.update(now);
sprite.draw(ctx, this.renderInfo);
}
for (const [id, sprite] of Object.entries(this.agents)) {
this._drawOverlay(ctx, sprite, id);
}
this._animId = requestAnimationFrame(this._loop);
}
_drawOverlay(ctx, sprite, agentId) {
if (!this.renderInfo) return;
const { scale, offsetX, offsetY, tileSize } = this.renderInfo;
const cx = offsetX + sprite.x * tileSize * scale + (tileSize * scale) / 2;
const cy = offsetY + sprite.y * tileSize * scale - 10 * scale;
const icon = STATUS_ICONS[sprite.state];
if (icon) {
ctx.font = `${14 * scale}px serif`;
ctx.textAlign = 'center';
ctx.fillText(icon, cx, cy - 15 * scale);
}
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = `${8 * scale}px monospace`;
ctx.textAlign = 'center';
ctx.fillText(getCharLabel(agentId), cx, cy + 30 * scale + 30);
if (sprite.detail && (sprite.state === 'working' || sprite.state === 'waiting')) {
const bubbleY = cy - 25 * scale;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
const textW = ctx.measureText(sprite.detail).width;
ctx.fillRect(cx - textW / 2 - 6, bubbleY - 10, textW + 12, 16);
ctx.fillStyle = '#fff';
ctx.font = `${7 * scale}px monospace`;
ctx.fillText(sprite.detail, cx, bubbleY);
}
}
}

View File

@@ -0,0 +1,89 @@
const PIXEL_CHARS = {
stock: { body: '#4488cc', accent: '#cc4444', label: '주식', hair: '#332222' },
music: { body: '#44aa88', accent: '#ffaa00', label: '음악', hair: '#443322' },
claude: { body: '#8855cc', accent: '#cc88ff', label: 'Claude', hair: '#554466' },
};
const ANIM_FRAMES = {
idle: { frames: 2, speed: 800 },
working: { frames: 4, speed: 200 },
waiting: { frames: 2, speed: 400 },
break: { frames: 2, speed: 1000 },
walk: { frames: 4, speed: 150 },
};
export function drawAgent(ctx, agentId, x, y, state, frameIndex, scale = 2) {
const char = PIXEL_CHARS[agentId] || PIXEL_CHARS.claude;
const s = scale;
const anim = ANIM_FRAMES[state] || ANIM_FRAMES.idle;
const frame = frameIndex % anim.frames;
ctx.save();
ctx.translate(x, y);
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillRect(-4 * s, 14 * s, 8 * s, 2 * s);
// Body
ctx.fillStyle = char.body;
ctx.fillRect(-3 * s, 2 * s, 6 * s, 8 * s);
// Head
ctx.fillStyle = '#ffcc99';
ctx.fillRect(-3 * s, -4 * s, 6 * s, 6 * s);
// Hair
ctx.fillStyle = char.hair;
ctx.fillRect(-3 * s, -5 * s, 6 * s, 2 * s);
// Eyes
ctx.fillStyle = '#222';
const eyeOffset = state === 'break' && frame === 1 ? 0 : 1;
ctx.fillRect(-2 * s, -1 * s, 1 * s, eyeOffset * s);
ctx.fillRect(1 * s, -1 * s, 1 * s, eyeOffset * s);
// Legs
ctx.fillStyle = '#335';
const legSpread = state === 'walk' ? (frame % 2 === 0 ? 1 : -1) : 0;
ctx.fillRect(-2 * s, 10 * s, 2 * s, 4 * s);
ctx.fillRect(0 + legSpread * s, 10 * s, 2 * s, 4 * s);
// Accent
ctx.fillStyle = char.accent;
if (agentId === 'stock') {
ctx.fillRect(0, 2 * s, 1 * s, 5 * s);
} else if (agentId === 'music') {
ctx.fillRect(-4 * s, -4 * s, 1 * s, 4 * s);
ctx.fillRect(3 * s, -4 * s, 1 * s, 4 * s);
ctx.fillRect(-4 * s, -5 * s, 8 * s, 1 * s);
} else if (agentId === 'claude') {
ctx.globalAlpha = 0.3 + 0.2 * Math.sin(Date.now() / 500);
ctx.fillRect(-4 * s, -6 * s, 8 * s, 1 * s);
ctx.globalAlpha = 1;
}
// Working: typing hands
if (state === 'working') {
ctx.fillStyle = '#ffcc99';
const handY = 6 * s + (frame % 2) * s;
ctx.fillRect(-4 * s, handY, 1 * s, 2 * s);
ctx.fillRect(3 * s, handY, 1 * s, 2 * s);
}
// Waiting wobble
if (state === 'waiting') {
const wobble = Math.sin(Date.now() / 200) * s;
ctx.translate(wobble, 0);
}
ctx.restore();
}
export function getAnimSpeed(state) {
return (ANIM_FRAMES[state] || ANIM_FRAMES.idle).speed;
}
export function getCharLabel(agentId) {
return (PIXEL_CHARS[agentId] || {}).label || agentId;
}

View File

@@ -0,0 +1,90 @@
const WALL_COLOR = '#2a2a3a';
const DESK_COLOR = '#6b5b3a';
const DESK_TOP = '#8b7b5a';
const TABLE_COLOR = '#5a4a2a';
const SOFA_COLOR = '#884444';
const MONITOR_COLOR = '#224466';
const MONITOR_SCREEN = '#44aacc';
export function drawTileMap(ctx, mapData, width, height) {
const { tileSize, cols, rows, layers, furniture, colors } = mapData;
const scaleX = width / (cols * tileSize);
const scaleY = height / (rows * tileSize);
const scale = Math.min(scaleX, scaleY);
const offsetX = (width - cols * tileSize * scale) / 2;
const offsetY = (height - rows * tileSize * scale) / 2;
ctx.save();
ctx.translate(offsetX, offsetY);
ctx.scale(scale, scale);
const floor = layers.floor;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const tile = floor[r][c];
ctx.fillStyle = colors[String(tile)] || '#3a3a50';
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.strokeRect(c * tileSize, r * tileSize, tileSize, tileSize);
}
}
ctx.fillStyle = WALL_COLOR;
ctx.fillRect(0, 0, cols * tileSize, 4);
for (const f of furniture) {
const fx = f.x * tileSize;
const fy = f.y * tileSize;
const fw = (f.w || 2) * tileSize;
const fh = (f.h || 2) * tileSize;
if (f.type === 'desk') {
ctx.fillStyle = DESK_COLOR;
ctx.fillRect(fx, fy, fw, fh);
ctx.fillStyle = DESK_TOP;
ctx.fillRect(fx + 2, fy + 2, fw - 4, 6);
const mx = fx + fw / 2 - 8;
ctx.fillStyle = MONITOR_COLOR;
ctx.fillRect(mx, fy + 4, 16, 12);
ctx.fillStyle = MONITOR_SCREEN;
ctx.fillRect(mx + 2, fy + 6, 12, 8);
if (f.label) {
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '8px monospace';
ctx.textAlign = 'center';
ctx.fillText(f.label, fx + fw / 2, fy + fh + 12);
}
} else if (f.type === 'table') {
ctx.fillStyle = TABLE_COLOR;
ctx.fillRect(fx, fy, fw, fh);
ctx.fillStyle = '#7a6a4a';
ctx.fillRect(fx + 4, fy + 4, fw - 8, fh - 8);
} else if (f.type === 'sofa') {
ctx.fillStyle = SOFA_COLOR;
ctx.fillRect(fx, fy, 48, 32);
ctx.fillStyle = '#aa5555';
ctx.fillRect(fx + 4, fy + 4, 40, 24);
} else if (f.type === 'coffee') {
ctx.fillStyle = '#664422';
ctx.fillRect(fx + 8, fy + 8, 16, 20);
ctx.fillStyle = '#886644';
ctx.fillRect(fx + 6, fy + 6, 20, 4);
}
}
ctx.restore();
return { scale, offsetX, offsetY, tileSize };
}
export function worldToTile(mapData, renderInfo, canvasX, canvasY) {
const { scale, offsetX, offsetY, tileSize } = renderInfo;
const wx = (canvasX - offsetX) / scale;
const wy = (canvasY - offsetY) / scale;
return { col: Math.floor(wx / tileSize), row: Math.floor(wy / tileSize), worldX: wx, worldY: wy };
}
export function tileToCanvas(mapData, renderInfo, col, row) {
const { scale, offsetX, offsetY, tileSize } = renderInfo;
return { x: offsetX + col * tileSize * scale + (tileSize * scale) / 2, y: offsetY + row * tileSize * scale + (tileSize * scale) / 2 };
}

View File

@@ -0,0 +1,106 @@
import React, { useState } from 'react';
const AGENT_COMMANDS = {
stock: [
{ action: 'fetch_news', label: '뉴스 수집', icon: '📰' },
{ action: 'list_alerts', label: '알람 목록', icon: '🔔' },
],
music: [
{ action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true },
{ action: 'credits', label: '크레딧 확인', icon: '💳' },
],
};
const ChatPanel = ({ agentId, agentState, onCommand, onApproval, onClose }) => {
const [input, setInput] = useState('');
const [activeCommand, setActiveCommand] = useState(null);
const commands = AGENT_COMMANDS[agentId] || [];
const state = agentState || {};
const handleSend = () => {
if (!input.trim() || !activeCommand) return;
const params = activeCommand === 'compose'
? { prompt: input }
: { message: input };
onCommand(agentId, activeCommand, params);
setInput('');
setActiveCommand(null);
};
const handleQuickAction = (cmd) => {
if (cmd.needsInput) {
setActiveCommand(cmd.action);
} else {
onCommand(agentId, cmd.action, {});
}
};
return (
<div className="ao-chat-panel">
<div className="ao-chat-header">
<span className="ao-chat-title">
{agentId === 'stock' ? '주식 트레이더' :
agentId === 'music' ? '음악 프로듀서' : agentId}
</span>
<span className={`ao-chat-state ao-chat-state--${state.state || 'idle'}`}>
{state.state || 'idle'}
</span>
<button className="ao-chat-close" onClick={onClose}>&times;</button>
</div>
{state.detail && (
<div className="ao-chat-detail">{state.detail}</div>
)}
{state.state === 'waiting' && state.taskId && (
<div className="ao-chat-approval">
<p>승인 대기 중인 작업이 있습니다</p>
<div className="ao-chat-approval-btns">
<button className="ao-btn ao-btn--approve"
onClick={() => onApproval(agentId, state.taskId, true)}>
승인
</button>
<button className="ao-btn ao-btn--reject"
onClick={() => onApproval(agentId, state.taskId, false)}>
거절
</button>
</div>
</div>
)}
<div className="ao-chat-commands">
{commands.map(cmd => (
<button key={cmd.action} className="ao-cmd-btn"
onClick={() => handleQuickAction(cmd)}>
<span>{cmd.icon}</span> {cmd.label}
</button>
))}
</div>
{activeCommand && (
<div className="ao-chat-input-area">
<input
type="text"
className="ao-chat-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder={activeCommand === 'compose' ? '프롬프트 입력...' : '메시지 입력...'}
autoFocus
/>
<button className="ao-btn ao-btn--send" onClick={handleSend}>전송</button>
</div>
)}
{state.lastResult && (
<div className="ao-chat-result">
<h4>최근 결과</h4>
<pre>{JSON.stringify(state.lastResult, null, 2)}</pre>
</div>
)}
</div>
);
};
export default ChatPanel;

View File

@@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import { getAgentTasks } from '../../../api';
const STATUS_BADGE = {
pending: { label: '대기', color: '#fbbf24' },
approved: { label: '승인됨', color: '#60a5fa' },
working: { label: '진행중', color: '#818cf8' },
succeeded: { label: '완료', color: '#34d399' },
failed: { label: '실패', color: '#f87171' },
rejected: { label: '거절됨', color: '#fb923c' },
};
const TaskHistory = ({ agentId, onClose }) => {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!agentId) return;
setLoading(true);
getAgentTasks(agentId, 30)
.then(data => setTasks(data.tasks || []))
.catch(() => setTasks([]))
.finally(() => setLoading(false));
}, [agentId]);
return (
<div className="ao-history-panel">
<div className="ao-history-header">
<span>작업 이력 {agentId}</span>
<button className="ao-chat-close" onClick={onClose}>&times;</button>
</div>
<div className="ao-history-list">
{loading && <p className="ao-history-empty">로딩 ...</p>}
{!loading && tasks.length === 0 && <p className="ao-history-empty">이력 없음</p>}
{tasks.map(task => {
const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending;
return (
<div key={task.id} className="ao-history-item">
<div className="ao-history-item-header">
<span className="ao-history-type">{task.task_type}</span>
<span className="ao-history-badge" style={{ background: badge.color }}>
{badge.label}
</span>
</div>
<div className="ao-history-time">
{task.created_at?.replace('T', ' ').slice(0, 19)}
</div>
{task.result_data && (
<details className="ao-history-detail">
<summary>결과 보기</summary>
<pre>{JSON.stringify(task.result_data, null, 2)}</pre>
</details>
)}
</div>
);
})}
</div>
</div>
);
};
export default TaskHistory;

View File

@@ -0,0 +1,88 @@
import { useState, useEffect, useRef, useCallback } from 'react';
export function useAgentManager() {
const [agents, setAgents] = useState({});
const [pendingTasks, setPendingTasks] = useState([]);
const [connected, setConnected] = useState(false);
const wsRef = useRef(null);
const reconnectTimer = useRef(null);
const connect = useCallback(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/agent-office/ws`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setConnected(true);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
};
ws.onclose = () => {
setConnected(false);
reconnectTimer.current = setTimeout(connect, 3000);
};
ws.onerror = () => { ws.close(); };
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'init': {
const agentMap = {};
for (const a of msg.agents) {
agentMap[a.agent_id] = { state: a.state, detail: a.detail };
}
setAgents(agentMap);
setPendingTasks(msg.pending || []);
break;
}
case 'agent_state':
setAgents(prev => ({
...prev,
[msg.agent]: { state: msg.state, detail: msg.detail, taskId: msg.task_id },
}));
break;
case 'task_complete':
setAgents(prev => ({
...prev,
[msg.agent]: { ...prev[msg.agent], lastResult: msg.result },
}));
setPendingTasks(prev => prev.filter(id => id !== msg.task_id));
break;
case 'command_result':
setAgents(prev => ({
...prev,
[msg.agent]: { ...prev[msg.agent], lastCommand: msg.result },
}));
break;
default:
break;
}
};
}, []);
useEffect(() => {
connect();
return () => {
if (wsRef.current) wsRef.current.close();
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
};
}, [connect]);
const sendCommand = useCallback((agent, action, params = {}) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params }));
}
}, []);
const sendApproval = useCallback((agent, taskId, approved) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved }));
}
}, []);
return { agents, pendingTasks, connected, sendCommand, sendApproval };
}

View File

@@ -0,0 +1,62 @@
import { useRef, useEffect, useCallback } from 'react';
import { OfficeRenderer } from '../canvas/OfficeRenderer';
import officeMap from '../assets/office-map.json';
export function useOfficeCanvas(containerRef, onAgentClick) {
const rendererRef = useRef(null);
useEffect(() => {
if (!containerRef.current) return;
const canvas = document.createElement('canvas');
canvas.style.display = 'block';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.imageRendering = 'pixelated';
containerRef.current.appendChild(canvas);
const renderer = new OfficeRenderer(canvas, officeMap);
rendererRef.current = renderer;
const resize = () => {
const rect = containerRef.current.getBoundingClientRect();
renderer.resize(rect.width, rect.height);
};
resize();
renderer.start();
renderer.setOnClick((agentId) => {
if (onAgentClick) onAgentClick(agentId);
});
const handleClick = (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
renderer.handleClick(x, y);
};
canvas.addEventListener('click', handleClick);
window.addEventListener('resize', resize);
return () => {
renderer.stop();
canvas.removeEventListener('click', handleClick);
window.removeEventListener('resize', resize);
if (containerRef.current && canvas.parentNode === containerRef.current) {
containerRef.current.removeChild(canvas);
}
};
}, [containerRef, onAgentClick]);
const updateAgentState = useCallback((agentId, state, detail) => {
rendererRef.current?.updateAgentState(agentId, state, detail);
}, []);
const moveAgent = useCallback((agentId, target) => {
rendererRef.current?.moveAgent(agentId, target);
}, []);
return { updateAgentState, moveAgent };
}

View File

@@ -25,6 +25,17 @@ const LAB_ITEMS = [
icon: '📅', icon: '📅',
status: 'live', status: 'live',
}, },
{
id: 'agent-office',
path: '/agent-office',
title: 'Agent Office',
category: 'AI · 자동화',
desc: 'AI 에이전트들이 사무실에서 자동으로 작업하는 가상 오피스',
tags: ['Canvas 2D', 'WebSocket', 'AI Agent', 'Telegram'],
accent: '#8b5cf6',
icon: '🏢',
status: 'wip',
},
]; ];
const STATUS_LABEL = { const STATUS_LABEL = {

View File

@@ -2426,3 +2426,173 @@
animation: none !important; animation: none !important;
} }
} }
/* ── Phase 1: Credits Badge ─────────────────────────────── */
.ms-credits-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 20px;
background: rgba(245, 166, 35, 0.1);
border: 1px solid rgba(245, 166, 35, 0.25);
font-family: 'Courier Prime', monospace;
font-size: 0.85rem; color: var(--ms-accent);
}
.ms-credits-badge__icon { font-size: 1rem; }
.ms-credits-badge__value { font-weight: 700; font-size: 1.1rem; }
.ms-credits-badge__label { color: var(--ms-muted); font-size: 0.75rem; text-transform: uppercase; }
.ms-credits-badge.is-low {
background: rgba(231, 76, 60, 0.15);
border-color: rgba(231, 76, 60, 0.4);
color: #e74c3c;
animation: pulse-badge 1.5s ease-in-out infinite;
}
@keyframes pulse-badge {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* ── Phase 1: Vocal Gender Toggle ───────────────────────── */
.ms-gender-toggle { display: flex; gap: 6px; }
.ms-gender-btn {
flex: 1; padding: 8px 12px; border-radius: 8px;
background: var(--ms-surface); border: 1px solid var(--ms-line);
color: var(--ms-muted); font-family: 'Syne', sans-serif;
font-size: 0.82rem; cursor: pointer; transition: all 0.2s;
display: flex; align-items: center; gap: 6px; justify-content: center;
}
.ms-gender-btn:hover { border-color: var(--ms-accent); color: var(--ms-text); }
.ms-gender-btn.is-active { background: rgba(245, 166, 35, 0.12); border-color: var(--ms-accent); color: var(--ms-text); }
.ms-gender-btn.is-active.is-male { background: rgba(74, 158, 255, 0.12); border-color: #4a9eff; color: #4a9eff; }
.ms-gender-btn.is-active.is-female { background: rgba(255, 107, 157, 0.12); border-color: #ff6b9d; color: #ff6b9d; }
.ms-gender-btn__icon { font-size: 1.1rem; }
/* ── Phase 1: Negative Tags ─────────────────────────────── */
.ms-negative-tags { display: flex; flex-direction: column; gap: 8px; }
.ms-negative-tags__presets { display: flex; flex-wrap: wrap; gap: 6px; }
.ms-neg-chip {
padding: 4px 12px; border-radius: 14px;
background: var(--ms-surface); border: 1px solid var(--ms-line);
color: var(--ms-muted); font-size: 0.78rem; cursor: pointer;
font-family: 'Syne', sans-serif; transition: all 0.2s;
}
.ms-neg-chip:hover { border-color: #e74c3c; color: var(--ms-text); }
.ms-neg-chip.is-active {
background: rgba(231, 76, 60, 0.12); border-color: #e74c3c; color: #e74c3c;
text-decoration: line-through;
}
.ms-negative-tags__input {
padding: 8px 12px; border-radius: 8px;
background: var(--ms-surface); border: 1px solid var(--ms-line);
color: var(--ms-text); font-family: 'Syne', sans-serif; font-size: 0.82rem;
}
.ms-negative-tags__input::placeholder { color: var(--ms-dim); }
.ms-param-hint--inline {
font-size: 0.72rem; color: var(--ms-dim); margin: 0 0 4px;
font-family: 'Courier Prime', monospace;
}
/* ── More Menu ──────────────────────────────────────────── */
.ms-more-menu { position: relative; }
.ms-more-menu__dropdown {
position: absolute; bottom: 100%; right: 0;
background: var(--ms-surface2); border: 1px solid var(--ms-line);
border-radius: 8px; padding: 4px; min-width: 160px; z-index: 20;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.ms-more-menu__dropdown button {
display: block; width: 100%; padding: 8px 12px; border: none;
background: none; color: var(--ms-text); font-size: 0.82rem;
font-family: 'Syne', sans-serif; cursor: pointer; text-align: left;
border-radius: 6px;
}
.ms-more-menu__dropdown button:hover { background: rgba(245,166,35,0.1); }
.ms-more-menu__dropdown button:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Modal ──────────────────────────────────────────────── */
.ms-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.ms-modal {
background: var(--ms-surface); border: 1px solid var(--ms-line);
border-radius: 16px; padding: 24px; max-width: 520px; width: 90%;
max-height: 90vh; overflow-y: auto;
}
.ms-modal__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.ms-modal__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.3rem; color: var(--ms-text); }
.ms-modal__close { background: none; border: none; color: var(--ms-muted); font-size: 1.2rem; cursor: pointer; }
.ms-modal__actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
/* ── Cover Art Grid ─────────────────────────────────────── */
.ms-cover-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.ms-cover-option {
border: 2px solid var(--ms-line); border-radius: 12px; overflow: hidden;
cursor: pointer; background: none; padding: 0; transition: border-color 0.2s;
}
.ms-cover-option:hover { border-color: var(--ms-accent); }
.ms-cover-option.is-selected { border-color: var(--ms-accent); box-shadow: 0 0 12px rgba(245,166,35,0.3); }
.ms-cover-option__img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.ms-cover-option__label {
display: block; padding: 8px; text-align: center;
font-family: 'Courier Prime', monospace; font-size: 0.78rem; color: var(--ms-muted);
}
/* ── Stem Modal ─────────────────────────────────────────── */
.ms-modal--wide { max-width: 680px; }
.ms-modal__subtitle { font-size: 0.78rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
.ms-stem-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.ms-stem-card {
display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 12px 8px; border-radius: 10px;
background: var(--ms-surface2); border: 1px solid var(--ms-line);
transition: border-color 0.2s;
}
.ms-stem-card.is-playing { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
.ms-stem-card__icon { font-size: 1.4rem; }
.ms-stem-card__name {
font-family: 'Courier Prime', monospace; font-size: 0.72rem;
color: var(--ms-muted); text-transform: capitalize;
}
.ms-stem-card__actions { display: flex; gap: 6px; }
/* ── Synced Lyrics Player ───────────────────────────────── */
.ms-synced-player {
background: var(--ms-surface); border: 1px solid var(--ms-line);
border-radius: 12px; padding: 16px; margin-top: 12px;
}
.ms-synced-player__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.ms-synced-player__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
.ms-synced-player__audio { width: 100%; margin-bottom: 12px; }
.ms-synced-player__lyrics { line-height: 1.8; font-family: 'Syne', sans-serif; font-size: 0.95rem; }
.ms-synced-word { color: var(--ms-dim); transition: color 0.15s; }
.ms-synced-word.is-active { color: var(--synced-accent, var(--ms-accent)); font-weight: 600; }
.ms-synced-word.is-past { color: var(--ms-muted); }
/* ── Style Boost Button ─────────────────────────────────── */
.ms-style-boost-btn { margin-left: auto; }
.ms-style-boost-btn.is-loading { opacity: 0.6; }
/* ── Remix Tab ──────────────────────────────────────────── */
.ms-remix-tab { display: flex; flex-direction: column; gap: 20px; }
.ms-remix-tab__header { }
.ms-remix-tab__title { font-family: 'Bebas Neue', sans-serif; font-size: 1.8rem; color: var(--ms-text); }
.ms-remix-tab__desc { font-size: 0.85rem; color: var(--ms-muted); }
.ms-remix-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.ms-remix-card {
display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 20px 12px; border-radius: 12px; cursor: pointer;
background: var(--ms-surface); border: 1px solid var(--ms-line);
transition: all 0.2s; text-align: center;
}
.ms-remix-card:hover { border-color: var(--ms-accent); background: var(--ms-surface2); }
.ms-remix-card.is-active { border-color: var(--ms-accent); background: rgba(245,166,35,0.08); }
.ms-remix-card__icon { font-size: 2rem; }
.ms-remix-card__label { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; color: var(--ms-text); }
.ms-remix-card__desc { font-size: 0.72rem; color: var(--ms-muted); font-family: 'Courier Prime', monospace; }
.ms-remix-params {
display: flex; flex-direction: column; gap: 12px;
padding: 16px; border-radius: 12px;
background: var(--ms-surface); border: 1px solid var(--ms-line);
}
.ms-remix-submit { align-self: flex-start; margin-top: 8px; }

View File

@@ -7,15 +7,24 @@ import {
getMusicProviders, getMusicProviders,
getMusicStatus, getMusicStatus,
getMusicModels, getMusicModels,
getMusicCredits,
extendMusicTrack, extendMusicTrack,
removeVocals, removeVocals,
getSavedLyrics, generateCoverImage,
saveLyrics, convertToWav,
updateLyrics, splitStems,
deleteLyrics, getTimestampedLyrics,
generateStyleBoost,
generateVideo,
} from '../../api'; } from '../../api';
import './MusicStudio.css'; import './MusicStudio.css';
import AudioPlayer from './components/AudioPlayer';
import { fmtTime } from './components/AudioPlayer';
import CreditsBadge from './components/CreditsBadge';
import CoverArtModal from './components/CoverArtModal';
import LyricsTab from './components/LyricsTab';
import StemModal from './components/StemModal';
import SyncedLyricsPlayer from './components/SyncedLyricsPlayer';
import RemixTab from './components/RemixTab';
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
데이터 상수 데이터 상수
@@ -83,12 +92,6 @@ const SIM_STEPS = [
{ msg: 'Track ready!', pct: 100 }, { msg: 'Track ready!', pct: 100 },
]; ];
/* ─────────────────────────────────────────────
유틸
───────────────────────────────────────────── */
const pad = (n) => String(Math.floor(n)).padStart(2, '0');
const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`;
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Loading Skeleton Loading Skeleton
───────────────────────────────────────────── */ ───────────────────────────────────────────── */
@@ -261,125 +264,6 @@ const GenerationProgress = ({ progress, stepMsg }) => (
</div> </div>
); );
/* ─────────────────────────────────────────────
Audio Player (실제 <audio> 기반)
───────────────────────────────────────────── */
const AudioPlayer = ({ audioUrl, totalSec, accentColor }) => {
const audioRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [duration, setDuration] = useState(totalSec ?? 0);
const [volume, setVolume] = useState(1);
/* 실제 오디오가 없으면 가짜 타이머로 폴백 */
const isFake = !audioUrl;
const timerRef = useRef(null);
const total = duration || totalSec || 60;
const togglePlay = () => {
if (isFake) {
if (playing) {
clearInterval(timerRef.current);
setPlaying(false);
} else {
setPlaying(true);
timerRef.current = setInterval(() => {
setElapsed((e) => {
if (e >= total - 1) {
clearInterval(timerRef.current);
setPlaying(false);
return 0;
}
return e + 1;
});
}, 1000);
}
return;
}
const el = audioRef.current;
if (!el) return;
playing ? el.pause() : el.play();
};
const handleSeek = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
const newTime = ratio * total;
if (!isFake && audioRef.current) {
audioRef.current.currentTime = newTime;
}
setElapsed(newTime);
};
const handleVolumeChange = (e) => {
const v = Number(e.target.value);
setVolume(v);
if (!isFake && audioRef.current) audioRef.current.volume = v;
};
useEffect(() => () => clearInterval(timerRef.current), []);
const progress = (elapsed / total) * 100;
return (
<div className="ms-audio-player" style={{ '--player-accent': accentColor }}>
{!isFake && (
<audio
ref={audioRef}
src={audioUrl}
onLoadedMetadata={(e) => setDuration(e.target.duration)}
onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => { setPlaying(false); setElapsed(0); }}
/>
)}
<button
type="button"
className={`ms-player__play ${playing ? 'is-playing' : ''}`}
onClick={togglePlay}
aria-label={playing ? '일시정지' : '재생'}
>
{playing ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="2" width="4" height="12" rx="1" />
<rect x="9" y="2" width="4" height="12" rx="1" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 2l10 6-10 6V2z" />
</svg>
)}
</button>
<div className="ms-player__timeline">
<div className="ms-player__bar" onClick={handleSeek} role="slider"
aria-label="재생 위치" aria-valuenow={Math.round(elapsed)} aria-valuemin={0} aria-valuemax={Math.round(total)}>
<div className="ms-player__fill" style={{ width: `${progress}%` }} />
<div className="ms-player__thumb" style={{ left: `${progress}%` }} />
</div>
<div className="ms-player__times">
<span>{fmtTime(elapsed)}</span>
<span>{fmtTime(total)}</span>
</div>
</div>
<div className="ms-volume">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
<path d="M2 5h2.5l3-3v10l-3-3H2V5zm8.5-1.5a4.5 4.5 0 010 7" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
</svg>
<input
type="range" min={0} max={1} step={0.02} value={volume}
onChange={handleVolumeChange}
className="ms-volume__slider"
aria-label="볼륨"
/>
</div>
</div>
);
};
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Track Result Card Track Result Card
───────────────────────────────────────────── */ ───────────────────────────────────────────── */
@@ -451,7 +335,8 @@ const TrackResult = ({ track, onDownload, onNew }) => {
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Library Card Library Card
───────────────────────────────────────────── */ ───────────────────────────────────────────── */
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, isGenerating }) => { const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating }) => {
const [menuOpen, setMenuOpen] = useState(false);
const genre = GENRES.find((g) => g.id === track.genre); const genre = GENRES.find((g) => g.id === track.genre);
const totalSec = track.duration_sec ?? null; const totalSec = track.duration_sec ?? null;
const filename = track.audio_url ? track.audio_url.split('/').pop() : ''; const filename = track.audio_url ? track.audio_url.split('/').pop() : '';
@@ -512,29 +397,35 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
</div> </div>
{hasSunoId && ( {hasSunoId && (
<div className="ms-lib-card__actions"> <div className="ms-lib-card__actions">
<button <button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
type="button" onClick={() => onExtend(track)} disabled={isGenerating}>
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onExtend(track)}
disabled={isGenerating}
title="이 곡을 이어서 연장합니다"
>
Extend Extend
</button> </button>
<button <button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
type="button" onClick={() => onVocalRemoval(track)} disabled={isGenerating}>
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onVocalRemoval(track)}
disabled={isGenerating}
title="보컬과 인스트루멘탈을 분리합니다"
>
🎤 Vocal Split 🎤 Vocal Split
</button> </button>
{track.audio_url && ( {track.audio_url && (
<a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm"> <a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm"> Download</a>
Download
</a>
)} )}
<div className="ms-more-menu">
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => setMenuOpen(!menuOpen)}></button>
{menuOpen && (
<div className="ms-more-menu__dropdown">
<button type="button" onClick={() => { onCoverArt(track); setMenuOpen(false); }}
disabled={isGenerating}>🖼 Cover Art</button>
<button type="button" onClick={() => { onWavConvert(track); setMenuOpen(false); }}
disabled={isGenerating}>📀 WAV Download</button>
<button type="button" onClick={() => { onStemSplit(track); setMenuOpen(false); }}
disabled={isGenerating}>🎛 12 Stems (50cr)</button>
<button type="button" onClick={() => { onSyncedLyrics(track); setMenuOpen(false); }}
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button>
<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
disabled={isGenerating}>🎬 Music Video</button>
</div>
)}
</div>
</div> </div>
)} )}
{!hasSunoId && track.audio_url && ( {!hasSunoId && track.audio_url && (
@@ -554,7 +445,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Library Section Library Section
───────────────────────────────────────────── */ ───────────────────────────────────────────── */
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, isGenerating, loading }) => { const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating, loading }) => {
const [playingId, setPlayingId] = useState(null); const [playingId, setPlayingId] = useState(null);
const handlePlay = (track) => { const handlePlay = (track) => {
@@ -603,6 +494,11 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, isGene
isPlaying={playingId === track.id} isPlaying={playingId === track.id}
onExtend={onExtend} onExtend={onExtend}
onVocalRemoval={onVocalRemoval} onVocalRemoval={onVocalRemoval}
onCoverArt={onCoverArt}
onWavConvert={onWavConvert}
onStemSplit={onStemSplit}
onSyncedLyrics={onSyncedLyrics}
onVideoGenerate={onVideoGenerate}
isGenerating={isGenerating} isGenerating={isGenerating}
/> />
))} ))}
@@ -611,241 +507,6 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, isGene
); );
}; };
/* ─────────────────────────────────────────────
Lyrics Tab
───────────────────────────────────────────── */
const LyricsTab = ({ onUseInCreate }) => {
const [lyrPrompt, setLyrPrompt] = useState('');
const [lyrLoading, setLyrLoading] = useState(false);
const [lyrError, setLyrError] = useState(null);
const [copied, setCopied] = useState(null); // id
const [saved, setSaved] = useState([]); // DB에 저장된 가사
const [loadingSaved, setLoadingSaved] = useState(true);
const [editingId, setEditingId] = useState(null);
const [editTitle, setEditTitle] = useState('');
const [editText, setEditText] = useState('');
/* ── 저장된 가사 로드 ── */
useEffect(() => {
setLoadingSaved(true);
getSavedLyrics()
.then((data) => setSaved(data.lyrics ?? []))
.catch(() => {})
.finally(() => setLoadingSaved(false));
}, []);
/* ── AI 생성 → 즉시 저장 ── */
const handleGenerate = async () => {
if (!lyrPrompt.trim() || lyrLoading) return;
setLyrLoading(true);
setLyrError(null);
try {
const res = await generateMusicLyrics(lyrPrompt.trim());
if (res?.text) {
const record = await saveLyrics({
title: res.title || '',
text: res.text,
prompt: lyrPrompt.trim(),
});
setSaved((prev) => [record, ...prev]);
} else {
setLyrError('가사 생성 결과가 없습니다');
}
} catch (e) {
setLyrError(e.message || '가사 생성에 실패했습니다');
} finally {
setLyrLoading(false);
}
};
/* ── 복사 ── */
const handleCopy = (text, id) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(id);
setTimeout(() => setCopied(null), 2000);
});
};
/* ── 삭제 ── */
const handleDelete = async (id) => {
try {
await deleteLyrics(id);
setSaved((prev) => prev.filter((l) => l.id !== id));
} catch {}
};
/* ── 수정 시작 ── */
const startEdit = (item) => {
setEditingId(item.id);
setEditTitle(item.title);
setEditText(item.text);
};
/* ── 수정 저장 ── */
const handleSaveEdit = async () => {
if (editingId == null) return;
try {
const updated = await updateLyrics(editingId, { title: editTitle, text: editText });
setSaved((prev) => prev.map((l) => l.id === editingId ? updated : l));
setEditingId(null);
} catch {}
};
/* ── 수정 취소 ── */
const cancelEdit = () => setEditingId(null);
return (
<div className="ms-lyrics-tab">
<div className="ms-lyrics-tab__form">
<div className="ms-lyrics-tab__head">
<h2 className="ms-lyrics-tab__title">AI Lyrics Generator</h2>
<p className="ms-lyrics-tab__desc">
원하는 분위기, 주제, 스타일을 설명하면 AI가 가사를 작성합니다.
</p>
</div>
<div className="ms-lyrics-tab__input-wrap">
<textarea
className="ms-lyrics-tab__input"
placeholder="예: 비 오는 밤, 혼자 걷는 도시의 거리를 배경으로 한 감성적인 발라드 가사"
value={lyrPrompt}
onChange={(e) => setLyrPrompt(e.target.value)}
rows={3}
maxLength={200}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleGenerate(); } }}
/>
<div className="ms-lyrics-tab__input-footer">
<span className="ms-lyrics-tab__count">{lyrPrompt.length}/200</span>
<button
type="button"
className={`ms-btn ms-btn--accent ${lyrLoading ? 'is-loading' : ''}`}
onClick={handleGenerate}
disabled={!lyrPrompt.trim() || lyrLoading}
>
{lyrLoading ? (
<><span className="ms-btn__spinner" /> 생성 ...</>
) : (
'✨ 가사 생성'
)}
</button>
</div>
</div>
{lyrError && (
<div className="ms-error-banner">
<span> {lyrError}</span>
</div>
)}
</div>
{lyrLoading && (
<div className="ms-lyrics-tab__loading">
<div className="ms-lyrics-tab__loading-bar" />
<p>AI가 가사를 작성하고 있습니다...</p>
</div>
)}
{/* 저장된 가사 목록 */}
{loadingSaved && (
<div className="ms-lyrics-tab__loading">
<div className="ms-lyrics-tab__loading-bar" />
<p>저장된 가사를 불러오는 ...</p>
</div>
)}
{!loadingSaved && saved.length === 0 && !lyrLoading && (
<div className="ms-lyrics-tab__empty">
<span className="ms-lyrics-tab__empty-icon">🎤</span>
<p>저장된 가사가 없습니다</p>
<p className="ms-lyrics-tab__empty-hint">
프롬프트를 입력하면 AI가 [Verse], [Chorus] 섹션이 포함된 가사를 작성합니다
</p>
</div>
)}
<div className="ms-lyrics-tab__results">
{saved.map((item) => (
<div key={item.id} className={`ms-lyrics-card ${editingId === item.id ? 'is-editing' : ''}`}>
<div className="ms-lyrics-card__header">
{editingId === item.id ? (
<input
className="ms-lyrics-card__title-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="제목"
/>
) : (
<>
{item.title && <h3 className="ms-lyrics-card__title">{item.title}</h3>}
<span className="ms-lyrics-card__prompt">{item.prompt}</span>
</>
)}
<span className="ms-lyrics-card__date">
{item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR') : ''}
</span>
</div>
{editingId === item.id ? (
<textarea
className="ms-lyrics-card__text-input"
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={12}
/>
) : (
<pre className="ms-lyrics-card__text">{item.text}</pre>
)}
<div className="ms-lyrics-card__actions">
{editingId === item.id ? (
<>
<button type="button" className="ms-btn ms-btn--accent ms-btn--sm" onClick={handleSaveEdit}>
저장
</button>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm" onClick={cancelEdit}>
취소
</button>
</>
) : (
<>
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => handleCopy(item.text, item.id)}
>
{copied === item.id ? '✓ 복사됨' : '📋 복사'}
</button>
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onUseInCreate(item.text)}
>
🎵 Create에서 사용
</button>
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => startEdit(item)}
>
수정
</button>
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm ms-btn--danger-text"
onClick={() => handleDelete(item.id)}
>
🗑 삭제
</button>
</>
)}
</div>
</div>
))}
</div>
</div>
);
};
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Main Page Main Page
───────────────────────────────────────────── */ ───────────────────────────────────────────── */
@@ -874,7 +535,20 @@ export default function MusicStudio() {
const [lyricsLoading, setLyricsLoading] = useState(false); const [lyricsLoading, setLyricsLoading] = useState(false);
const [model, setModel] = useState('V4'); const [model, setModel] = useState('V4');
const [models, setModels] = useState([]); const [models, setModels] = useState([]);
const [credits, setCredits] = useState(null);
/* ── Phase 1: 신규 파라미터 ── */
const [vocalGender, setVocalGender] = useState(null); // "m" | "f" | null
const [negativeTags, setNegativeTags] = useState('');
const [styleWeight, setStyleWeight] = useState(50); // UI: 0~100
const [audioWeight, setAudioWeight] = useState(50);
/* ── CoverArt 상태 ── */
const [coverArtModal, setCoverArtModal] = useState(null); // { trackId, images }
/* ── Phase 2 상태 ── */
const [stemModal, setStemModal] = useState(null); // { stems: {} }
const [syncedLyrics, setSyncedLyrics] = useState(null); // { audioUrl, words }
const [styleBoostLoading, setStyleBoostLoading] = useState(false);
/* ── 생성 상태 ── */ /* ── 생성 상태 ── */
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
@@ -917,14 +591,11 @@ export default function MusicStudio() {
.catch(() => setProviderError(true)); .catch(() => setProviderError(true));
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
/* ── 모델 & 크레딧 로드 ── */ /* ── 모델 로드 ── */
useEffect(() => { useEffect(() => {
getMusicModels() getMusicModels()
.then((data) => setModels(data.models ?? [])) .then((data) => setModels(data.models ?? []))
.catch(() => {}); .catch(() => {});
getMusicCredits()
.then((data) => setCredits(data))
.catch(() => {});
}, []); }, []);
/* ── 가사 AI 생성 ── */ /* ── 가사 AI 생성 ── */
@@ -1075,6 +746,10 @@ export default function MusicStudio() {
...(provider === 'suno' ? { ...(provider === 'suno' ? {
lyrics: lyrics || undefined, lyrics: lyrics || undefined,
instrumental, instrumental,
vocal_gender: vocalGender || undefined,
negative_tags: negativeTags || undefined,
style_weight: styleWeight !== 50 ? styleWeight / 100 : undefined,
audio_weight: audioWeight !== 50 ? audioWeight / 100 : undefined,
} : {}), } : {}),
}; };
@@ -1163,6 +838,224 @@ export default function MusicStudio() {
} }
}; };
/* ── 커버 아트 핸들러 ── */
const handleCoverArt = async (track) => {
if (!track.task_id || isGenerating) return;
setTab('create');
setIsGenerating(true);
setTrack(null);
setGenProgress(0);
setGenStep('커버 이미지 생성 요청 중…');
setGenError(null);
try {
const res = await generateCoverImage({
suno_task_id: track.task_id,
track_id: track.id,
});
if (res?.task_id) {
taskIdRef.current = res.task_id;
setGenStep('AI가 커버 이미지를 생성하고 있습니다…');
setGenProgress(5);
clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const status = await getMusicStatus(res.task_id);
setGenProgress(status.progress ?? 0);
setGenStep(status.message ?? '처리 중…');
if (status.status === 'succeeded') {
clearInterval(pollRef.current);
setIsGenerating(false);
const images = JSON.parse(status.audio_url || '[]');
setCoverArtModal({ trackId: track.id, images });
} else if (status.status === 'failed') {
clearInterval(pollRef.current);
setIsGenerating(false);
setGenError(`커버 이미지 생성 실패: ${status.error ?? '알 수 없는 오류'}`);
}
} catch {
clearInterval(pollRef.current);
setIsGenerating(false);
setGenError('커버 이미지 상태 조회 실패');
}
}, 3000);
}
} catch {
setIsGenerating(false);
setGenError('커버 이미지 생성에 실패했습니다');
}
};
const handleCoverSelect = (imageUrl) => {
if (coverArtModal?.trackId) {
setLibrary((prev) => prev.map((t) =>
t.id === coverArtModal.trackId
? { ...t, cover_images: [imageUrl, ...(coverArtModal.images || []).filter(u => u !== imageUrl)] }
: t
));
}
setCoverArtModal(null);
};
/* ── WAV 변환 핸들러 ── */
const handleWavConvert = async (track) => {
if (!track.task_id || !track.suno_id || isGenerating) return;
setTab('create');
setIsGenerating(true);
setTrack(null);
setGenProgress(0);
setGenStep('WAV 변환 요청 중…');
setGenError(null);
try {
const res = await convertToWav({
suno_task_id: track.task_id,
suno_id: track.suno_id,
track_id: track.id,
});
if (res?.task_id) {
taskIdRef.current = res.task_id;
setGenStep('WAV 변환 처리 중…');
setGenProgress(5);
clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const status = await getMusicStatus(res.task_id);
setGenProgress(status.progress ?? 0);
setGenStep(status.message ?? '처리 중…');
if (status.status === 'succeeded') {
clearInterval(pollRef.current);
setIsGenerating(false);
const wavUrl = status.audio_url;
if (wavUrl) {
const a = document.createElement('a');
a.href = wavUrl;
a.download = `${track.title || 'track'}.wav`;
a.click();
}
setGenStep('WAV 변환 완료!');
} else if (status.status === 'failed') {
clearInterval(pollRef.current);
setIsGenerating(false);
setGenError(`WAV 변환 실패: ${status.error ?? '알 수 없는 오류'}`);
}
} catch {
clearInterval(pollRef.current);
setIsGenerating(false);
setGenError('WAV 변환 상태 조회 실패');
}
}, 3000);
}
} catch {
setIsGenerating(false);
setGenError('WAV 변환에 실패했습니다');
}
};
/* ── 12스템 분리 핸들러 ── */
const handleStemSplit = async (track) => {
if (!track.task_id || !track.suno_id || isGenerating) return;
setTab('create');
setIsGenerating(true);
setTrack(null);
setGenProgress(0);
setGenStep('12스템 분리 요청 중…');
setGenError(null);
try {
const res = await splitStems({
suno_task_id: track.task_id,
suno_id: track.suno_id,
track_id: track.id,
});
if (res?.task_id) {
taskIdRef.current = res.task_id;
setGenStep('12스템 분리 처리 중 (약 2~3분)…');
setGenProgress(5);
clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const status = await getMusicStatus(res.task_id);
setGenProgress(status.progress ?? 0);
setGenStep(status.message ?? '처리 중…');
if (status.status === 'succeeded') {
clearInterval(pollRef.current);
setIsGenerating(false);
const stems = JSON.parse(status.audio_url || '{}');
setStemModal({ stems });
} else if (status.status === 'failed') {
clearInterval(pollRef.current);
setIsGenerating(false);
setGenError(`스템 분리 실패: ${status.error ?? '알 수 없는 오류'}`);
}
} catch {
clearInterval(pollRef.current);
setIsGenerating(false);
setGenError('스템 분리 상태 조회 실패');
}
}, 3000);
}
} catch {
setIsGenerating(false);
setGenError('12스템 분리에 실패했습니다');
}
};
/* ── 타임스탬프 가사 핸들러 ── */
const handleSyncedLyrics = async (track) => {
if (!track.task_id || !track.suno_id) return;
try {
const result = await getTimestampedLyrics(track.task_id, track.suno_id);
if (result?.alignedWords || result?.aligned_words) {
setSyncedLyrics({
audioUrl: track.audio_url,
words: result.alignedWords || result.aligned_words,
});
}
} catch {
setGenError('타임스탬프 가사 조회에 실패했습니다');
}
};
/* ── 스타일 부스트 핸들러 ── */
const handleStyleBoost = async () => {
if (!genre || styleBoostLoading) return;
setStyleBoostLoading(true);
try {
const content = [
GENRES.find(g => g.id === genre)?.label,
...moods.map(id => MOODS.find(m => m.id === id)?.label).filter(Boolean),
].join(', ');
const result = await generateStyleBoost(content);
if (result?.result) {
setPrompt(result.result);
}
} catch {}
finally { setStyleBoostLoading(false); }
};
/* ── 뮤직비디오 핸들러 ── */
const handleVideoGenerate = async (track) => {
if (!track.task_id || !track.suno_id || isGenerating) return;
setTab('create');
setIsGenerating(true);
setTrack(null);
setGenProgress(0);
setGenStep('뮤직비디오 생성 요청 중…');
setGenError(null);
try {
const res = await generateVideo({
suno_task_id: track.task_id,
suno_id: track.suno_id,
track_id: track.id,
});
if (res?.task_id) {
taskIdRef.current = res.task_id;
startPolling(res.task_id, `${track.title} (Video)`);
}
} catch {
setIsGenerating(false);
setGenError('뮤직비디오 생성에 실패했습니다');
}
};
const handleNewTrack = () => { const handleNewTrack = () => {
setTrack(null); setTrack(null);
setGenProgress(0); setGenProgress(0);
@@ -1188,14 +1081,7 @@ export default function MusicStudio() {
</p> </p>
</div> </div>
<div className="ms-header__right"> <div className="ms-header__right">
{credits && ( <CreditsBadge />
<div className="ms-credits">
<span className="ms-credits__label">Credits</span>
<span className="ms-credits__value">
{credits.credits_left ?? credits.remaining ?? '—'}
</span>
</div>
)}
<SonicRadar isGenerating={isGenerating} accentColor={accentColor} /> <SonicRadar isGenerating={isGenerating} accentColor={accentColor} />
</div> </div>
</header> </header>
@@ -1226,6 +1112,13 @@ export default function MusicStudio() {
<span className="ms-tab__badge">{library.length}</span> <span className="ms-tab__badge">{library.length}</span>
)} )}
</button> </button>
<button
type="button"
className={`ms-tab ${tab === 'remix' ? 'is-active' : ''}`}
onClick={() => setTab('remix')}
>
<span className="ms-tab__icon">🔄</span> Remix
</button>
</nav> </nav>
{/* ═══ LIBRARY TAB ═══ */} {/* ═══ LIBRARY TAB ═══ */}
@@ -1237,6 +1130,11 @@ export default function MusicStudio() {
onRefresh={loadLibrary} onRefresh={loadLibrary}
onExtend={handleExtend} onExtend={handleExtend}
onVocalRemoval={handleVocalRemoval} onVocalRemoval={handleVocalRemoval}
onCoverArt={handleCoverArt}
onWavConvert={handleWavConvert}
onStemSplit={handleStemSplit}
onSyncedLyrics={handleSyncedLyrics}
onVideoGenerate={handleVideoGenerate}
isGenerating={isGenerating} isGenerating={isGenerating}
/> />
)} )}
@@ -1246,6 +1144,24 @@ export default function MusicStudio() {
<LyricsTab onUseInCreate={(text) => { setLyrics(text); setInstrumental(false); setProvider('suno'); setTab('create'); }} /> <LyricsTab onUseInCreate={(text) => { setLyrics(text); setInstrumental(false); setProvider('suno'); setTab('create'); }} />
)} )}
{/* ═══ REMIX TAB ═══ */}
{tab === 'remix' && (
<RemixTab
onTaskStarted={(taskId, title) => {
setTab('create');
setIsGenerating(true);
setTrack(null);
setGenProgress(0);
setGenStep(`${title} 처리 중…`);
setGenError(null);
taskIdRef.current = taskId;
startPolling(taskId, title);
}}
model={model}
isGenerating={isGenerating}
/>
)}
{/* ═══ CREATE TAB ═══ */} {/* ═══ CREATE TAB ═══ */}
{tab === 'create' && ( {tab === 'create' && (
<div className="ms-layout"> <div className="ms-layout">
@@ -1316,6 +1232,17 @@ export default function MusicStudio() {
<span className="ms-section__step">01</span> <span className="ms-section__step">01</span>
<h2 className="ms-section__title">Genre</h2> <h2 className="ms-section__title">Genre</h2>
<span className="ms-section__hint">장르를 선택하세요</span> <span className="ms-section__hint">장르를 선택하세요</span>
{provider === 'suno' && (
<button
type="button"
className={`ms-btn ms-btn--ghost ms-btn--sm ms-style-boost-btn ${styleBoostLoading ? 'is-loading' : ''}`}
onClick={handleStyleBoost}
disabled={styleBoostLoading || !genre}
title="현재 설정으로 최적 스타일 프롬프트 생성"
>
{styleBoostLoading ? '생성 중...' : '✨ Style Boost'}
</button>
)}
<button type="button" className={`ms-desc-toggle ${isOpen('genre') ? 'is-open' : ''}`} onClick={() => toggleDesc('genre')} aria-label="설명 펼치기"></button> <button type="button" className={`ms-desc-toggle ${isOpen('genre') ? 'is-open' : ''}`} onClick={() => toggleDesc('genre')} aria-label="설명 펼치기"></button>
</div> </div>
<div className={`ms-desc-wrap ${isOpen('genre') ? 'is-open' : ''}`}> <div className={`ms-desc-wrap ${isOpen('genre') ? 'is-open' : ''}`}>
@@ -1529,6 +1456,96 @@ export default function MusicStudio() {
</div> </div>
</div> </div>
</div> </div>
{/* Vocal Gender (Suno only) */}
{provider === 'suno' && (
<div className="ms-param-group">
<label className="ms-param-label">Vocal Gender</label>
<div className="ms-gender-toggle">
{[
{ value: null, label: 'Auto', icon: '🎵' },
{ value: 'm', label: 'Male', icon: '♂' },
{ value: 'f', label: 'Female', icon: '♀' },
].map((opt) => (
<button
key={opt.label}
type="button"
className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''} ${opt.value === 'm' ? 'is-male' : opt.value === 'f' ? 'is-female' : ''}`}
onClick={() => setVocalGender(opt.value)}
>
<span className="ms-gender-btn__icon">{opt.icon}</span>
{opt.label}
</button>
))}
</div>
</div>
)}
{/* Negative Tags (Suno only) */}
{provider === 'suno' && (
<div className="ms-param-group">
<label className="ms-param-label">Exclude Styles</label>
<div className="ms-negative-tags">
<div className="ms-negative-tags__presets">
{['screaming', 'autotune', 'distortion', 'whisper', 'falsetto', 'rap'].map((tag) => (
<button
key={tag}
type="button"
className={`ms-neg-chip ${negativeTags.includes(tag) ? 'is-active' : ''}`}
onClick={() => {
setNegativeTags((prev) => {
const tags = prev.split(',').map(t => t.trim()).filter(Boolean);
if (tags.includes(tag)) return tags.filter(t => t !== tag).join(', ');
return [...tags, tag].join(', ');
});
}}
>
{tag}
</button>
))}
</div>
<input
type="text"
className="ms-negative-tags__input"
placeholder="추가로 제외할 스타일을 입력..."
value={negativeTags}
onChange={(e) => setNegativeTags(e.target.value)}
/>
</div>
</div>
)}
{/* Style Weight / Audio Weight (Suno only) */}
{provider === 'suno' && (
<div className="ms-param-grid">
<div className="ms-param-group">
<div className="ms-param-row">
<label className="ms-param-label">Style Weight</label>
<span className="ms-param-value">{styleWeight}%</span>
</div>
<p className="ms-param-hint ms-param-hint--inline">Prompt Style 밸런스</p>
<input
type="range" min={0} max={100} value={styleWeight}
onChange={(e) => setStyleWeight(Number(e.target.value))}
className="ms-bpm-slider"
aria-label="Style Weight"
/>
</div>
<div className="ms-param-group">
<div className="ms-param-row">
<label className="ms-param-label">Audio Weight</label>
<span className="ms-param-value">{audioWeight}%</span>
</div>
<p className="ms-param-hint ms-param-hint--inline">Original AI 밸런스</p>
<input
type="range" min={0} max={100} value={audioWeight}
onChange={(e) => setAudioWeight(Number(e.target.value))}
className="ms-bpm-slider"
aria-label="Audio Weight"
/>
</div>
</div>
)}
</section> </section>
{/* Step 5: Prompt */} {/* Step 5: Prompt */}
@@ -1720,6 +1737,29 @@ export default function MusicStudio() {
</div> </div>
</div> </div>
)} )}
{coverArtModal && (
<CoverArtModal
images={coverArtModal.images}
onSelect={handleCoverSelect}
onClose={() => setCoverArtModal(null)}
/>
)}
{/* ═══ Stem Modal ═══ */}
{stemModal && (
<StemModal stems={stemModal.stems} onClose={() => setStemModal(null)} />
)}
{/* ═══ Synced Lyrics Player ═══ */}
{syncedLyrics && (
<SyncedLyricsPlayer
audioUrl={syncedLyrics.audioUrl}
alignedWords={syncedLyrics.words}
onClose={() => setSyncedLyrics(null)}
accentColor={accentColor}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,128 @@
import React, { useRef, useState, useEffect } from 'react';
/* ─────────────────────────────────────────────
유틸
───────────────────────────────────────────── */
const pad = (n) => String(Math.floor(n)).padStart(2, '0');
export const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`;
/* ─────────────────────────────────────────────
Audio Player (실제 <audio> 기반)
───────────────────────────────────────────── */
const AudioPlayer = ({ audioUrl, totalSec, accentColor }) => {
const audioRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [duration, setDuration] = useState(totalSec ?? 0);
const [volume, setVolume] = useState(1);
/* 실제 오디오가 없으면 가짜 타이머로 폴백 */
const isFake = !audioUrl;
const timerRef = useRef(null);
const total = duration || totalSec || 60;
const togglePlay = () => {
if (isFake) {
if (playing) {
clearInterval(timerRef.current);
setPlaying(false);
} else {
setPlaying(true);
timerRef.current = setInterval(() => {
setElapsed((e) => {
if (e >= total - 1) {
clearInterval(timerRef.current);
setPlaying(false);
return 0;
}
return e + 1;
});
}, 1000);
}
return;
}
const el = audioRef.current;
if (!el) return;
playing ? el.pause() : el.play();
};
const handleSeek = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
const newTime = ratio * total;
if (!isFake && audioRef.current) {
audioRef.current.currentTime = newTime;
}
setElapsed(newTime);
};
const handleVolumeChange = (e) => {
const v = Number(e.target.value);
setVolume(v);
if (!isFake && audioRef.current) audioRef.current.volume = v;
};
useEffect(() => () => clearInterval(timerRef.current), []);
const progress = (elapsed / total) * 100;
return (
<div className="ms-audio-player" style={{ '--player-accent': accentColor }}>
{!isFake && (
<audio
ref={audioRef}
src={audioUrl}
onLoadedMetadata={(e) => setDuration(e.target.duration)}
onTimeUpdate={(e) => setElapsed(e.target.currentTime)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => { setPlaying(false); setElapsed(0); }}
/>
)}
<button
type="button"
className={`ms-player__play ${playing ? 'is-playing' : ''}`}
onClick={togglePlay}
aria-label={playing ? '일시정지' : '재생'}
>
{playing ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="2" width="4" height="12" rx="1" />
<rect x="9" y="2" width="4" height="12" rx="1" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 2l10 6-10 6V2z" />
</svg>
)}
</button>
<div className="ms-player__timeline">
<div className="ms-player__bar" onClick={handleSeek} role="slider"
aria-label="재생 위치" aria-valuenow={Math.round(elapsed)} aria-valuemin={0} aria-valuemax={Math.round(total)}>
<div className="ms-player__fill" style={{ width: `${progress}%` }} />
<div className="ms-player__thumb" style={{ left: `${progress}%` }} />
</div>
<div className="ms-player__times">
<span>{fmtTime(elapsed)}</span>
<span>{fmtTime(total)}</span>
</div>
</div>
<div className="ms-volume">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden>
<path d="M2 5h2.5l3-3v10l-3-3H2V5zm8.5-1.5a4.5 4.5 0 010 7" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round"/>
</svg>
<input
type="range" min={0} max={1} step={0.02} value={volume}
onChange={handleVolumeChange}
className="ms-volume__slider"
aria-label="볼륨"
/>
</div>
</div>
);
};
export default AudioPlayer;

View File

@@ -0,0 +1,40 @@
import React, { useState } from 'react';
const CoverArtModal = ({ images, onSelect, onClose }) => {
const [selected, setSelected] = useState(null);
if (!images || images.length === 0) return null;
return (
<div className="ms-modal-overlay" onClick={onClose}>
<div className="ms-modal" onClick={(e) => e.stopPropagation()}>
<div className="ms-modal__header">
<h3 className="ms-modal__title">Cover Art 선택</h3>
<button type="button" className="ms-modal__close" onClick={onClose}></button>
</div>
<div className="ms-cover-grid">
{images.map((url, idx) => (
<button
key={idx}
type="button"
className={`ms-cover-option ${selected === idx ? 'is-selected' : ''}`}
onClick={() => setSelected(idx)}
>
<img src={url} alt={`Cover option ${idx + 1}`} className="ms-cover-option__img" />
<span className="ms-cover-option__label">Option {idx + 1}</span>
</button>
))}
</div>
<div className="ms-modal__actions">
<button type="button" className="ms-btn ms-btn--accent" disabled={selected === null}
onClick={() => { if (selected !== null) onSelect(images[selected]); }}>
이미지 사용
</button>
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>취소</button>
</div>
</div>
</div>
);
};
export default CoverArtModal;

View File

@@ -0,0 +1,36 @@
import React, { useEffect, useState, useCallback } from 'react';
import { getMusicCredits } from '../../../api';
const CreditsBadge = () => {
const [credits, setCredits] = useState(null);
const fetchCredits = useCallback(async () => {
try {
const data = await getMusicCredits();
setCredits(data);
} catch {}
}, []);
useEffect(() => {
fetchCredits();
const interval = setInterval(fetchCredits, 30000);
return () => clearInterval(interval);
}, [fetchCredits]);
if (!credits) return null;
const remaining = credits.credits_left ?? credits.remaining ?? credits.data ?? null;
if (remaining == null) return null;
const isLow = remaining <= 10;
return (
<div className={`ms-credits-badge ${isLow ? 'is-low' : ''}`}>
<span className="ms-credits-badge__icon"></span>
<span className="ms-credits-badge__value">{remaining}</span>
<span className="ms-credits-badge__label">credits</span>
</div>
);
};
export default CreditsBadge;

View File

@@ -0,0 +1,245 @@
import React, { useEffect, useState } from 'react';
import {
generateMusicLyrics,
getSavedLyrics,
saveLyrics,
updateLyrics,
deleteLyrics,
} from '../../../api';
/* ─────────────────────────────────────────────
Lyrics Tab
───────────────────────────────────────────── */
const LyricsTab = ({ onUseInCreate }) => {
const [lyrPrompt, setLyrPrompt] = useState('');
const [lyrLoading, setLyrLoading] = useState(false);
const [lyrError, setLyrError] = useState(null);
const [copied, setCopied] = useState(null); // id
const [saved, setSaved] = useState([]); // DB에 저장된 가사
const [loadingSaved, setLoadingSaved] = useState(true);
const [editingId, setEditingId] = useState(null);
const [editTitle, setEditTitle] = useState('');
const [editText, setEditText] = useState('');
/* ── 저장된 가사 로드 ── */
useEffect(() => {
setLoadingSaved(true);
getSavedLyrics()
.then((data) => setSaved(data.lyrics ?? []))
.catch(() => {})
.finally(() => setLoadingSaved(false));
}, []);
/* ── AI 생성 → 즉시 저장 ── */
const handleGenerate = async () => {
if (!lyrPrompt.trim() || lyrLoading) return;
setLyrLoading(true);
setLyrError(null);
try {
const res = await generateMusicLyrics(lyrPrompt.trim());
if (res?.text) {
const record = await saveLyrics({
title: res.title || '',
text: res.text,
prompt: lyrPrompt.trim(),
});
setSaved((prev) => [record, ...prev]);
} else {
setLyrError('가사 생성 결과가 없습니다');
}
} catch (e) {
setLyrError(e.message || '가사 생성에 실패했습니다');
} finally {
setLyrLoading(false);
}
};
/* ── 복사 ── */
const handleCopy = (text, id) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(id);
setTimeout(() => setCopied(null), 2000);
});
};
/* ── 삭제 ── */
const handleDelete = async (id) => {
try {
await deleteLyrics(id);
setSaved((prev) => prev.filter((l) => l.id !== id));
} catch {}
};
/* ── 수정 시작 ── */
const startEdit = (item) => {
setEditingId(item.id);
setEditTitle(item.title);
setEditText(item.text);
};
/* ── 수정 저장 ── */
const handleSaveEdit = async () => {
if (editingId == null) return;
try {
const updated = await updateLyrics(editingId, { title: editTitle, text: editText });
setSaved((prev) => prev.map((l) => l.id === editingId ? updated : l));
setEditingId(null);
} catch {}
};
/* ── 수정 취소 ── */
const cancelEdit = () => setEditingId(null);
return (
<div className="ms-lyrics-tab">
<div className="ms-lyrics-tab__form">
<div className="ms-lyrics-tab__head">
<h2 className="ms-lyrics-tab__title">AI Lyrics Generator</h2>
<p className="ms-lyrics-tab__desc">
원하는 분위기, 주제, 스타일을 설명하면 AI가 가사를 작성합니다.
</p>
</div>
<div className="ms-lyrics-tab__input-wrap">
<textarea
className="ms-lyrics-tab__input"
placeholder="예: 비 오는 밤, 혼자 걷는 도시의 거리를 배경으로 한 감성적인 발라드 가사"
value={lyrPrompt}
onChange={(e) => setLyrPrompt(e.target.value)}
rows={3}
maxLength={200}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleGenerate(); } }}
/>
<div className="ms-lyrics-tab__input-footer">
<span className="ms-lyrics-tab__count">{lyrPrompt.length}/200</span>
<button
type="button"
className={`ms-btn ms-btn--accent ${lyrLoading ? 'is-loading' : ''}`}
onClick={handleGenerate}
disabled={!lyrPrompt.trim() || lyrLoading}
>
{lyrLoading ? (
<><span className="ms-btn__spinner" /> 생성 ...</>
) : (
'✨ 가사 생성'
)}
</button>
</div>
</div>
{lyrError && (
<div className="ms-error-banner">
<span> {lyrError}</span>
</div>
)}
</div>
{lyrLoading && (
<div className="ms-lyrics-tab__loading">
<div className="ms-lyrics-tab__loading-bar" />
<p>AI가 가사를 작성하고 있습니다...</p>
</div>
)}
{/* 저장된 가사 목록 */}
{loadingSaved && (
<div className="ms-lyrics-tab__loading">
<div className="ms-lyrics-tab__loading-bar" />
<p>저장된 가사를 불러오는 ...</p>
</div>
)}
{!loadingSaved && saved.length === 0 && !lyrLoading && (
<div className="ms-lyrics-tab__empty">
<span className="ms-lyrics-tab__empty-icon">🎤</span>
<p>저장된 가사가 없습니다</p>
<p className="ms-lyrics-tab__empty-hint">
프롬프트를 입력하면 AI가 [Verse], [Chorus] 섹션이 포함된 가사를 작성합니다
</p>
</div>
)}
<div className="ms-lyrics-tab__results">
{saved.map((item) => (
<div key={item.id} className={`ms-lyrics-card ${editingId === item.id ? 'is-editing' : ''}`}>
<div className="ms-lyrics-card__header">
{editingId === item.id ? (
<input
className="ms-lyrics-card__title-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="제목"
/>
) : (
<>
{item.title && <h3 className="ms-lyrics-card__title">{item.title}</h3>}
<span className="ms-lyrics-card__prompt">{item.prompt}</span>
</>
)}
<span className="ms-lyrics-card__date">
{item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR') : ''}
</span>
</div>
{editingId === item.id ? (
<textarea
className="ms-lyrics-card__text-input"
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={12}
/>
) : (
<pre className="ms-lyrics-card__text">{item.text}</pre>
)}
<div className="ms-lyrics-card__actions">
{editingId === item.id ? (
<>
<button type="button" className="ms-btn ms-btn--accent ms-btn--sm" onClick={handleSaveEdit}>
저장
</button>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm" onClick={cancelEdit}>
취소
</button>
</>
) : (
<>
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => handleCopy(item.text, item.id)}
>
{copied === item.id ? '✓ 복사됨' : '📋 복사'}
</button>
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onUseInCreate(item.text)}
>
🎵 Create에서 사용
</button>
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => startEdit(item)}
>
수정
</button>
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm ms-btn--danger-text"
onClick={() => handleDelete(item.id)}
>
🗑 삭제
</button>
</>
)}
</div>
</div>
))}
</div>
</div>
);
};
export default LyricsTab;

View File

@@ -0,0 +1,193 @@
import React, { useState } from 'react';
import { uploadAndCover, uploadAndExtend, addVocals, addInstrumental } from '../../../api';
const REMIX_ACTIONS = [
{ id: 'cover', label: 'AI Cover', icon: '🎨', desc: '외부 음원을 Suno AI 스타일로 리메이크' },
{ id: 'extend', label: 'Extend', icon: '⏩', desc: '외부 음원을 이어서 확장' },
{ id: 'add-vocals', label: 'Add Vocals', icon: '🎤', desc: '인스트루멘탈에 AI 보컬 입히기' },
{ id: 'add-instrumental', label: 'Add Instrumental', icon: '🎹', desc: '보컬에 AI 반주 입히기' },
];
const RemixTab = ({ onTaskStarted, model, isGenerating }) => {
const [uploadUrl, setUploadUrl] = useState('');
const [activeAction, setActiveAction] = useState(null);
// 각 액션별 파라미터
const [title, setTitle] = useState('');
const [style, setStyle] = useState('');
const [prompt, setPrompt] = useState('');
const [tags, setTags] = useState('');
const [negativeTags, setNegativeTags] = useState('');
const [vocalGender, setVocalGender] = useState(null);
const [continueAt, setContinueAt] = useState(0);
const [instrumental, setInstrumental] = useState(false);
const handleSubmit = async () => {
if (!uploadUrl || !activeAction || isGenerating) return;
let apiCall;
let payload = {};
switch (activeAction) {
case 'cover':
apiCall = uploadAndCover;
payload = {
upload_url: uploadUrl, model, custom_mode: true,
instrumental, prompt, style, title,
vocal_gender: vocalGender || undefined,
negative_tags: negativeTags || undefined,
};
break;
case 'extend':
apiCall = uploadAndExtend;
payload = {
upload_url: uploadUrl, model,
default_param_flag: !prompt,
continue_at: continueAt || undefined,
prompt, style, title, instrumental,
vocal_gender: vocalGender || undefined,
negative_tags: negativeTags || undefined,
};
break;
case 'add-vocals':
apiCall = addVocals;
payload = {
upload_url: uploadUrl, prompt, title, style,
negative_tags: negativeTags,
vocal_gender: vocalGender || undefined,
model: 'V4_5PLUS',
};
break;
case 'add-instrumental':
apiCall = addInstrumental;
payload = {
upload_url: uploadUrl, title, tags,
negative_tags: negativeTags,
vocal_gender: vocalGender || undefined,
model: 'V4_5PLUS',
};
break;
default:
return;
}
try {
const res = await apiCall(payload);
if (res?.task_id) {
onTaskStarted(res.task_id, `Remix: ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`);
}
} catch (e) {
// 에러는 부모 컴포넌트에서 처리
}
};
return (
<div className="ms-remix-tab">
<div className="ms-remix-tab__header">
<h2 className="ms-remix-tab__title">Remix Studio</h2>
<p className="ms-remix-tab__desc">외부 음원을 AI로 리메이크, 확장, 보컬/반주 추가</p>
</div>
<div className="ms-param-group">
<label className="ms-param-label">Audio URL</label>
<input
type="url"
className="ms-negative-tags__input"
placeholder="리믹스할 오디오 파일 URL (예: /media/music/track.mp3)"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className="ms-remix-actions">
{REMIX_ACTIONS.map((action) => (
<button
key={action.id}
type="button"
className={`ms-remix-card ${activeAction === action.id ? 'is-active' : ''}`}
onClick={() => setActiveAction(activeAction === action.id ? null : action.id)}
>
<span className="ms-remix-card__icon">{action.icon}</span>
<span className="ms-remix-card__label">{action.label}</span>
<span className="ms-remix-card__desc">{action.desc}</span>
</button>
))}
</div>
{activeAction && (
<div className="ms-remix-params">
{/* 공통 파라미터 */}
<div className="ms-param-group">
<label className="ms-param-label">Title</label>
<input type="text" className="ms-negative-tags__input" value={title}
onChange={(e) => setTitle(e.target.value)} placeholder="곡 제목" style={{ width: '100%' }} />
</div>
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
<div className="ms-param-group">
<label className="ms-param-label">Prompt / Lyrics</label>
<textarea className="ms-prompt" value={prompt}
onChange={(e) => setPrompt(e.target.value)} rows={3}
placeholder="가사 또는 스타일 설명" />
</div>
)}
{(activeAction === 'cover' || activeAction === 'extend' || activeAction === 'add-vocals') && (
<div className="ms-param-group">
<label className="ms-param-label">Style</label>
<input type="text" className="ms-negative-tags__input" value={style}
onChange={(e) => setStyle(e.target.value)} placeholder="예: Pop, Energetic, Piano" style={{ width: '100%' }} />
</div>
)}
{activeAction === 'add-instrumental' && (
<div className="ms-param-group">
<label className="ms-param-label">Tags (스타일/특성)</label>
<input type="text" className="ms-negative-tags__input" value={tags}
onChange={(e) => setTags(e.target.value)} placeholder="예: acoustic, warm, dreamy" style={{ width: '100%' }} />
</div>
)}
{activeAction === 'extend' && (
<div className="ms-param-group">
<label className="ms-param-label">Continue At ()</label>
<input type="number" className="ms-negative-tags__input" value={continueAt}
onChange={(e) => setContinueAt(Number(e.target.value))} min={0} style={{ width: '120px' }} />
</div>
)}
<div className="ms-param-group">
<label className="ms-param-label">Exclude Styles</label>
<input type="text" className="ms-negative-tags__input" value={negativeTags}
onChange={(e) => setNegativeTags(e.target.value)} placeholder="제외할 스타일" style={{ width: '100%' }} />
</div>
<div className="ms-param-group">
<label className="ms-param-label">Vocal Gender</label>
<div className="ms-gender-toggle">
{[{ value: null, label: 'Auto' }, { value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }].map((opt) => (
<button key={opt.label} type="button"
className={`ms-gender-btn ${vocalGender === opt.value ? 'is-active' : ''}`}
onClick={() => setVocalGender(opt.value)}>
{opt.label}
</button>
))}
</div>
</div>
<button
type="button"
className="ms-btn ms-btn--accent ms-remix-submit"
disabled={!uploadUrl || isGenerating}
onClick={handleSubmit}
>
{isGenerating ? 'Processing...' : `Start ${REMIX_ACTIONS.find(a => a.id === activeAction)?.label}`}
</button>
</div>
)}
</div>
);
};
export default RemixTab;

View File

@@ -0,0 +1,55 @@
import React, { useState } from 'react';
const STEM_ICONS = {
vocal: '🎤', backing_vocals: '🎶', drums: '🥁', bass: '🎸',
guitar: '🎸', keyboard: '🎹', strings: '🎻', brass: '🎺',
woodwinds: '🪈', percussion: '🪘', synth: '🎛', fx: '✨',
};
const StemModal = ({ stems, onClose }) => {
const [playingStem, setPlayingStem] = useState(null);
if (!stems || Object.keys(stems).length === 0) return null;
return (
<div className="ms-modal-overlay" onClick={onClose}>
<div className="ms-modal ms-modal--wide" onClick={(e) => e.stopPropagation()}>
<div className="ms-modal__header">
<h3 className="ms-modal__title">12 Stems</h3>
<span className="ms-modal__subtitle"> 스템을 개별 재생 다운로드할 있습니다</span>
<button type="button" className="ms-modal__close" onClick={onClose}></button>
</div>
<div className="ms-stem-grid">
{Object.entries(stems).map(([name, url]) => {
if (!url) return null;
const isPlaying = playingStem === name;
return (
<div key={name} className={`ms-stem-card ${isPlaying ? 'is-playing' : ''}`}>
<span className="ms-stem-card__icon">{STEM_ICONS[name] || '🎵'}</span>
<span className="ms-stem-card__name">{name.replace(/_/g, ' ')}</span>
<div className="ms-stem-card__actions">
<button
type="button"
className="ms-btn--icon"
onClick={() => setPlayingStem(isPlaying ? null : name)}
>
{isPlaying ? '■' : '▶'}
</button>
<a href={url} download className="ms-btn--icon" aria-label="다운로드"></a>
</div>
{isPlaying && (
<audio src={url} autoPlay onEnded={() => setPlayingStem(null)} />
)}
</div>
);
})}
</div>
<div className="ms-modal__actions">
<button type="button" className="ms-btn ms-btn--ghost" onClick={onClose}>닫기</button>
</div>
</div>
</div>
);
};
export default StemModal;

View File

@@ -0,0 +1,51 @@
import React, { useEffect, useRef, useState } from 'react';
const SyncedLyricsPlayer = ({ audioUrl, alignedWords, onClose, accentColor }) => {
const audioRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
const handler = () => setCurrentTime(el.currentTime);
el.addEventListener('timeupdate', handler);
return () => el.removeEventListener('timeupdate', handler);
}, []);
if (!alignedWords || alignedWords.length === 0) return null;
return (
<div className="ms-synced-player" style={{ '--synced-accent': accentColor }}>
<div className="ms-synced-player__header">
<h4 className="ms-synced-player__title">Synced Lyrics</h4>
<button type="button" className="ms-modal__close" onClick={onClose}></button>
</div>
<audio
ref={audioRef}
src={audioUrl}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
controls
className="ms-synced-player__audio"
/>
<div className="ms-synced-player__lyrics">
{alignedWords.map((word, idx) => {
const isActive = currentTime >= word.startS && currentTime < word.endS;
const isPast = currentTime >= word.endS;
return (
<span
key={idx}
className={`ms-synced-word ${isActive ? 'is-active' : ''} ${isPast ? 'is-past' : ''}`}
>
{word.word}{' '}
</span>
);
})}
</div>
</div>
);
};
export default SyncedLyricsPlayer;

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,6 @@ const Lotto = lazy(() => import('./pages/lotto/Lotto'));
const Travel = lazy(() => import('./pages/travel/Travel')); const Travel = lazy(() => import('./pages/travel/Travel'));
const Stock = lazy(() => import('./pages/stock/Stock')); const Stock = lazy(() => import('./pages/stock/Stock'));
const StockTrade = lazy(() => import('./pages/stock/StockTrade')); const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
const RealEstate = lazy(() => import('./pages/realestate/RealEstate'));
const Subscription = lazy(() => import('./pages/subscription/Subscription')); const Subscription = lazy(() => import('./pages/subscription/Subscription'));
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab')); const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream')); const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
@@ -69,7 +68,7 @@ export const navLinks = [
label: 'Realestate', label: 'Realestate',
path: '/realestate', path: '/realestate',
subtitle: '부동산', subtitle: '부동산',
description: '청약 자격 비교, 일정 관리, 관심 단지 정보를 관리하는 공간', description: '청약 공고 자동 수집, 매칭, 프로필 기반 자격 분석',
icon: <IconBuilding />, icon: <IconBuilding />,
accent: '#f43f5e', accent: '#f43f5e',
}, },
@@ -118,6 +117,15 @@ export const navLinks = [
icon: <IconTodo />, icon: <IconTodo />,
accent: '#f472b6', accent: '#f472b6',
}, },
{
id: 'agent-office',
label: 'Agent Office',
path: '/agent-office',
subtitle: 'AI LAB',
description: 'AI 에이전트 사무실',
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
accent: '#8b5cf6',
},
]; ];
export const appRoutes = [ export const appRoutes = [
@@ -145,10 +153,6 @@ export const appRoutes = [
path: 'realestate', path: 'realestate',
element: <Subscription />, element: <Subscription />,
}, },
{
path: 'realestate/property',
element: <RealEstate />,
},
{ {
path: 'travel', path: 'travel',
element: <Travel />, element: <Travel />,
@@ -177,4 +181,8 @@ export const appRoutes = [
path: 'todo', path: 'todo',
element: <Todo />, element: <Todo />,
}, },
{
path: 'agent-office',
lazy: () => import('./pages/agent-office/AgentOffice'),
},
]; ];